diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2b8040d2a..c55cbdfc3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -82,8 +82,8 @@ 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", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSToolbar", "NSView", "NSWindow", "NSWorkspace"] } -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSNotification", "NSOperation", "NSString"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSEvent", "NSGraphics", "NSResponder", "NSScreen", "NSTitlebarAccessoryViewController", "NSToolbar", "NSView", "NSViewController", "NSWindow", "NSWindowTabGroup", "NSWorkspace"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "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/commands/mod.rs b/src-tauri/src/commands/mod.rs index 21196cd42..94fd6dae0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -87,7 +87,6 @@ pub fn builder() -> tauri_specta::Builder { window_group::window_group_close, window_group::window_group_close_tabs, window_group::window_group_cancel_close, - window_group::window_group_before_close, window_group::window_group_reorder, window_group::window_group_restore, app::light_edit_open, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 6bd343542..84d8d8bd8 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -193,17 +193,10 @@ pub fn window_open_projects( .collect()) } -/// Whether this window fills the screen: in native full screen, or (macOS) shown in its window -/// group's full-screen Space. +/// Whether this window is in native full screen (on macOS, also a tab of a fullscreen group). #[tauri::command] #[specta::specta] pub fn window_is_fullscreen(window: WebviewWindow) -> bool { - #[cfg(target_os = "macos")] - { - use tauri::Manager; - crate::window_groups::in_fullscreen(window.app_handle(), &window) - } - #[cfg(not(target_os = "macos"))] window.is_fullscreen().unwrap_or(false) } diff --git a/src-tauri/src/commands/window_group.rs b/src-tauri/src/commands/window_group.rs index f016e9519..f6d4d29ae 100644 --- a/src-tauri/src/commands/window_group.rs +++ b/src-tauri/src/commands/window_group.rs @@ -55,20 +55,6 @@ pub fn window_group_cancel_close(window: WebviewWindow) { groups::cancel_close(window.app_handle(), window.label()); } -/// This window closes next (its prompts passed): a fullscreen group's host first hands its Space -/// to the tab that takes over. -#[tauri::command] -#[specta::specta] -pub async fn window_group_before_close(window: WebviewWindow) -> IdeResult<()> { - #[cfg(target_os = "macos")] - return groups::before_close(window.app_handle(), window.label()).await; - #[cfg(not(target_os = "macos"))] - { - let _ = window; - Ok(()) - } -} - #[tauri::command] #[specta::specta] pub async fn window_group_reorder(window: WebviewWindow, labels: Vec) -> IdeResult<()> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04c1f40db..e5dd7f88f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -190,6 +190,10 @@ pub fn run() { #[cfg(target_os = "macos")] macos::intercept_system_quit(app.handle()); #[cfg(target_os = "macos")] + if let Some(mtm) = objc2::MainThreadMarker::new() { + macos::prepare_window_tabbing(mtm); + } + #[cfg(target_os = "macos")] application_focus::observe(app.handle()); app.manage(menu_state); if let Some(paths) = setup_opens.lock().take() { diff --git a/src-tauri/src/macos.rs b/src-tauri/src/macos.rs index 752fe29a9..d9205d8db 100644 --- a/src-tauri/src/macos.rs +++ b/src-tauri/src/macos.rs @@ -1,15 +1,13 @@ -//! macOS window tab preferences, applied to application-drawn project tabs. +//! macOS project tabs: native window tab groups behind the page's own tab bar. 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::{AnyClass, AnyObject, Imp, ProtocolObject, Sel}, + runtime::{AnyClass, AnyObject, Imp, Sel}, }; -use objc2_app_kit::{NSWindow, NSWindowAnimationBehavior, NSWindowUserTabbingPreference}; -use objc2_foundation::{NSNotificationCenter, NSObjectProtocol, NSRect}; -use std::cell::Cell; +use objc2_app_kit::{NSWindow, NSWindowUserTabbingPreference}; use tauri::{AppHandle, Manager, WebviewWindow}; fn tabbing_preference(app: &AppHandle) -> IdeResult { @@ -47,33 +45,13 @@ 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 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 fullscreen = active.as_ref().is_some_and(|w| w.is_fullscreen().unwrap_or(false)); 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) .flatten()) } -struct FullscreenObserver(Retained>); - -impl Drop for FullscreenObserver { - fn drop(&mut self) { - let observer: &ProtocolObject = &self.0; - unsafe { NSNotificationCenter::defaultCenter().removeObserver(observer.as_ref()) }; - } -} - -thread_local! { - /// Fullscreen hosts of window groups, watched for leaving fullscreen. - static FULLSCREEN_HOSTS: std::cell::RefCell> = std::cell::RefCell::default(); -} - -/// 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() -} - /// 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() { @@ -86,182 +64,74 @@ fn on_main(window: &WebviewWindow, f: impl FnOnce() -> R + Se 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()) }; - }) +/// Project tabs are native window tabs, so a group shares one frame and enters full screen as one +/// window, with the system's window buttons on whichever tab is shown. The tab bar is the page's +/// own; AppKit's is hidden, and windows never join a tab group on their own. +pub fn prepare_window_tabbing(mtm: MainThreadMarker) { + NSWindow::setAllowsAutomaticWindowTabbing(false, mtm); } -/// 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()); +fn native(window: &WebviewWindow) -> Option<&'static NSWindow> { + let pointer = window.ns_window().ok()?; + Some(unsafe { &*pointer.cast::() }) +} + +fn same_group(a: &NSWindow, b: &NSWindow) -> bool { + match (a.tabGroup(), b.tabGroup()) { + (Some(a), Some(b)) => Retained::as_ptr(&a) == Retained::as_ptr(&b), + _ => false, + } +} + +/// Puts `members` in one native tab group and shows `selected`. +pub fn sync_tab_group(members: Vec, selected: &WebviewWindow) -> tauri::Result<()> { + let target = selected.clone(); + on_main(selected, move || { + let Some(shown) = native(&target) else { return }; + for member in members.iter().filter(|m| m.label() != target.label()) { + if let Some(window) = native(member) + && !same_group(shown, window) + { + shown.addTabbedWindow_ordered(window, objc2_app_kit::NSWindowOrderingMode::Above); } } - if let Some(frame) = frame { - native.setFrame_display(frame, true); + if let Some(group) = shown.tabGroup() { + group.setSelectedWindow(Some(shown)); } - target.state::().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged); - }) -} - -/// 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), - Some(native), - None, - &block, - ); - FULLSCREEN_HOSTS.with_borrow_mut(|hosts| hosts.insert(label, FullscreenObserver(observer))); - }) -} - -thread_local! { - /// Windows being waited on to finish entering fullscreen. - static ENTERING_FULLSCREEN: std::cell::RefCell> = std::cell::RefCell::default(); -} - -/// Toggles `window` into native fullscreen and resolves once it is there, or after `timeout`. -pub async fn enter_fullscreen_and_wait(window: &WebviewWindow, timeout: std::time::Duration) -> tauri::Result<()> { - let (tx, rx) = tokio::sync::oneshot::channel(); - let label = window.label().to_owned(); - let target = window.clone(); - on_main(window, move || unsafe { - let Ok(pointer) = target.ns_window() else { return }; - let native = &*pointer.cast::(); - let tx = std::cell::Cell::new(Some(tx)); - let block = block2::RcBlock::new(move |_| { - if let Some(tx) = tx.take() { - let _ = tx.send(()); + shown.makeKeyAndOrderFront(None); + for member in &members { + if let Some(window) = native(member) { + hide_tab_bar(window); } - }); - let observer = NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( - Some(objc2_app_kit::NSWindowDidEnterFullScreenNotification), - Some(native), - None, - &block, - ); - ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.insert(label, FullscreenObserver(observer))); - })?; - let toggled = native_toggle_fullscreen(window); - if toggled.is_ok() { - let _ = tokio::time::timeout(timeout, rx).await; - } - let label = window.label().to_owned(); + } + }) +} + +/// Takes `window` out of its tab group into a window of its own; in full screen, a Space of its own. +pub fn move_tab_to_new_window(window: &WebviewWindow) -> tauri::Result<()> { + let target = window.clone(); on_main(window, move || { - ENTERING_FULLSCREEN.with_borrow_mut(|waits| waits.remove(&label)); - })?; - toggled + let Some(native) = native(&target) else { return }; + if native.tabbedWindows().is_some_and(|tabs| tabs.count() > 1) { + native.moveTabToNewWindow(None); + } + hide_tab_bar(native); + }) } -/// An observer on the workspace's notification center, removed when dropped. -struct WorkspaceObserver(Retained, Retained>); - -impl Drop for WorkspaceObserver { - fn drop(&mut self) { - let observer: &ProtocolObject = &self.1; - unsafe { self.0.removeObserver(observer.as_ref()) }; - } +fn has_tab_bar(view: &objc2_app_kit::NSView) -> bool { + let class = view.class().name().to_string_lossy(); + class.contains("TabBar") || view.subviews().iter().any(|v| has_tab_bar(&v)) } -/// Work waiting for the active Space to change, with the observer that runs it. -struct SpaceChange { - _observer: WorkspaceObserver, - then: Box, -} - -thread_local! { - static SPACE_CHANGE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; -} - -/// 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)(); +/// Hides AppKit's tab bar, which a window shows while its group has more than one tab. It moves to +/// whichever tab is shown and comes back when the group changes, so this runs after each change. +pub fn hide_tab_bar(window: &NSWindow) { + for accessory in window.titlebarAccessoryViewControllers().iter() { + if !accessory.isHidden() && has_tab_bar(&accessory.view()) { + accessory.setHidden(true); } } - 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); - }); } type ShouldTerminate = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject) -> usize; @@ -301,165 +171,4 @@ unsafe extern "C-unwind" fn should_terminate(_this: *mut AnyObject, _selector: S TERMINATE_CANCEL } -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 for the whole group. -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); - - let selector = objc2::sel!(constrainFrameRect:toScreen:); - let Some(method) = (*class).instance_method(selector) else { return }; - let native: ConstrainFrameRect = std::mem::transmute(method.implementation()); - let types = objc2::ffi::method_getTypeEncoding(method); - let _ = NATIVE_CONSTRAIN_FRAME_RECT.set(native); - let replacement: Imp = std::mem::transmute(constrain_frame_rect as ConstrainFrameRect); - objc2::ffi::class_replaceMethod(class, selector, replacement, types); - }); -} - -type ConstrainFrameRect = unsafe extern "C-unwind" fn(*mut AnyObject, Sel, NSRect, *mut AnyObject) -> NSRect; - -static NATIVE_CONSTRAIN_FRAME_RECT: std::sync::OnceLock = std::sync::OnceLock::new(); - -thread_local! { - /// Native window whose next frame assignment must keep the rectangle it was given. - static EXACT_FRAME: Cell = const { Cell::new(0) }; -} - -struct ExactFrame(usize); - -impl ExactFrame { - fn begin(native: usize) -> Self { - EXACT_FRAME.with(|slot| slot.set(native)); - Self(native) - } -} - -impl Drop for ExactFrame { - fn drop(&mut self) { - EXACT_FRAME.with(|slot| { - if slot.get() == self.0 { - slot.set(0); - } - }); - } -} - -/// Copies `from`'s frame onto `to`. `setFrame:display:animate:` finishes before it returns; -/// tao's position and size setters only schedule the change. -pub fn copy_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { - apply_window_frame(from, to, false) -} - -/// Copies the frame and orders `to` front before AppKit can animate or constrain it. -/// A later `show` runs `constrainFrameRect:toScreen:` and slides a titled window into place. -pub fn present_window_frame(from: &WebviewWindow, to: &WebviewWindow) -> tauri::Result<()> { - apply_window_frame(from, to, true) -} - -fn apply_window_frame(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> { - if MainThreadMarker::new().is_some() { - return apply_window_frame_on_main(from, to, show); - } - let (tx, rx) = std::sync::mpsc::sync_channel(1); - let from = from.clone(); - let to = to.clone(); - to.clone().run_on_main_thread(move || { - let _ = tx.send(apply_window_frame_on_main(&from, &to, show)); - })?; - rx.recv().map_err(|error| tauri::Error::Io(std::io::Error::other(error.to_string())))? -} - -fn apply_window_frame_on_main(from: &WebviewWindow, to: &WebviewWindow, show: bool) -> tauri::Result<()> { - let source_pointer = from.ns_window()?; - let target_pointer = to.ns_window()?; - unsafe { - let source = &*source_pointer.cast::(); - let target = &*target_pointer.cast::(); - let frame = source.frame(); - let current = target.frame(); - let differs = frame.origin.x != current.origin.x - || frame.origin.y != current.origin.y - || frame.size.width != current.size.width - || frame.size.height != current.size.height; - if !differs && !show { - return Ok(()); - } - // Zoom and ordering use the window's animation behavior. `animate: false` alone does not - // stop a zoomed window from sliding to the new frame as it is shown. - let behavior = target.animationBehavior(); - target.setAnimationBehavior(NSWindowAnimationBehavior::None); - let result = (|| { - let _exact = ExactFrame::begin(target_pointer as usize); - if differs { - target.setFrame_display_animate(frame, false, false); - } - if show { - to.show()?; - to.set_focus()?; - } - Ok(()) - })(); - target.setAnimationBehavior(behavior); - result - } -} - -/// 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 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 { - if EXACT_FRAME.with(|slot| slot.get() == this as usize) { - return frame; - } - match NATIVE_CONSTRAIN_FRAME_RECT.get() { - Some(native) => unsafe { native(this, selector, frame, screen) }, - None => frame, - } -} - -unsafe extern "C-unwind" fn toggle_fullscreen(this: *mut AnyObject, selector: Sel, sender: *mut AnyObject) { - 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"); - } - return; - } - if let Some(native) = NATIVE_TOGGLE_FULLSCREEN.get() { - unsafe { native(this, selector, sender) }; - } -} - -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)) -} - -/// `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.then_some(window) -} diff --git a/src-tauri/src/titlebar.rs b/src-tauri/src/titlebar.rs index eabe726ec..d79a75523 100644 --- a/src-tauri/src/titlebar.rs +++ b/src-tauri/src/titlebar.rs @@ -12,8 +12,9 @@ use objc2::{ runtime::ProtocolObject, }; use objc2_app_kit::{ - NSToolbar, NSWindow, NSWindowDidEnterFullScreenNotification, NSWindowDidExitFullScreenNotification, - NSWindowStyleMask, NSWindowToolbarStyle, NSWindowWillEnterFullScreenNotification, + NSToolbar, NSWindow, NSWindowDidBecomeKeyNotification, NSWindowDidEnterFullScreenNotification, + NSWindowDidExitFullScreenNotification, NSWindowStyleMask, NSWindowToolbarStyle, + NSWindowWillEnterFullScreenNotification, }; use objc2_foundation::{NSNotificationCenter, NSObjectProtocol}; use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc}; @@ -56,7 +57,7 @@ impl Drop for Observer { struct Tracked { state: Rc, - _observers: [Observer; 3], + _observers: [Observer; 4], } thread_local! { @@ -85,8 +86,9 @@ fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked { change(&state); if let Some(window) = weak.load() { apply(&window, &state); + crate::macos::hide_tab_bar(&window); } - // The web title bar shows the window buttons itself in full screen. + // The web title bar releases the room kept for the window buttons in full screen. app.state::().sink.emit(ide_core::IdeEvent::WindowFullscreenChanged); }); let observer = unsafe { @@ -104,6 +106,8 @@ fn track(window: &NSWindow, app: &tauri::AppHandle) -> Tracked { observe(NSWindowWillEnterFullScreenNotification, Fullscreen::will_enter), observe(NSWindowDidEnterFullScreenNotification, Fullscreen::will_enter), observe(NSWindowDidExitFullScreenNotification, Fullscreen::did_exit), + // A tab shown in its group: AppKit's tab bar moves to it, and it may be in full screen. + observe(NSWindowDidBecomeKeyNotification, |_| {}), ] }; Tracked { state, _observers: observers } diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 70ea2dd37..bac36d3a4 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -8,22 +8,13 @@ use serde::{Deserialize, Serialize}; use specta::Type; use std::collections::VecDeque; use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; +use std::sync::atomic::Ordering; use tauri::{AppHandle, Manager, WebviewWindow}; -/// Wait for the selection's asynchronous `hide` calls to take effect before moving the peers. -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); -/// Longest wait for the tab taking over from a closing host to enter its own fullscreen Space. -#[cfg(target_os = "macos")] -const SPACE_HANDOVER: Duration = Duration::from_millis(2000); -/// Coalesces the move/resize events a drag emits. -static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0); +#[cfg(not(target_os = "macos"))] +mod frames; + +type Group = ide_workspace::window_groups::WindowGroup; #[derive(Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -62,410 +53,57 @@ fn changed(app: &AppHandle) { app.state::().sink.emit(IdeEvent::WindowGroupsChanged); } -/// Outer frame in physical pixels. A maximized window is represented by the rectangle it -/// occupies, not by the maximized flag: that flag can match while the rectangles do not. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct OuterFrame { - x: i32, - y: i32, - width: u32, - height: u32, -} - -/// Where a group member can be placed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum FrameHost { - /// The client can move and resize the window. - Desktop, - /// Wayland does not let clients place top-level windows. - #[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))] - Wayland, - /// Native fullscreen owns a Space. A different frame is transferred, not assigned. - NativeFullscreen, -} - -/// What to write before the target window is shown. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum FrameWrite { - /// Already the same rectangle, or this host must not be placed. - Leave, - TransferFullscreen, - Assign(OuterFrame), -} - -/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen -/// 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 => FrameWrite::Leave, - FrameHost::NativeFullscreen if from == to => FrameWrite::Leave, - FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen, - FrameHost::Desktop if from == to => FrameWrite::Leave, - FrameHost::Desktop => FrameWrite::Assign(from), - } -} - -fn outer_frame(window: &WebviewWindow) -> IdeResult { - let err = |error: tauri::Error| IdeError::internal(error.to_string()); - let position = window.outer_position().map_err(err)?; - let size = window.outer_size().map_err(err)?; - Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height }) -} - -fn frame_host(from: &WebviewWindow) -> IdeResult { - #[cfg(target_os = "linux")] - if crate::linux_window_state::is_wayland() { - return Ok(FrameHost::Wayland); - } - if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? { - return Ok(FrameHost::NativeFullscreen); - } - Ok(FrameHost::Desktop) -} - -/// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on -/// the monitor it last occupied; maximizing or going fullscreen there would make the window -/// visibly jump across displays when its tab is selected. macOS copies the AppKit frame instead. -#[cfg(not(target_os = "macos"))] -fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { - // Wayland does not let clients place top-level windows; maximizing stays on that output. - #[cfg(target_os = "linux")] - if crate::linux_window_state::is_wayland() { - return Ok(()); - } - let err = |e: tauri::Error| IdeError::internal(e.to_string()); - if let Some(monitor) = from.current_monitor().map_err(err)? { - to.set_position(*monitor.position()).map_err(err)?; - } - Ok(()) -} - -/// Gives `to` the source window's outer frame before `to` is shown. -/// -/// Tauri's position, size and maximize calls are queued. On macOS the AppKit calls return -/// before the frame changes, while `show` orders the window front immediately, so the old -/// 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 host == FrameHost::Wayland { - return Ok(()); - } - // Tauri's physical rectangles round through the scale factor. On macOS the AppKit frame is - // the one `show` will present, so compare that instead of skipping a real difference. - #[cfg(target_os = "macos")] - if host == FrameHost::Desktop { - return crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string())); - } - match frame_write(host, outer_frame(from)?, outer_frame(to)?) { - FrameWrite::Leave => Ok(()), - FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to), - FrameWrite::Assign(frame) => assign_outer_frame(from, to, frame), - } -} - -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")] - let _ = (from, to, err); - #[cfg(not(target_os = "macos"))] - { - to.set_fullscreen(false).map_err(err)?; - place_on_monitor(from, to)?; - from.set_fullscreen(false).map_err(err)?; - to.set_fullscreen(true).map_err(err)?; - } - Ok(()) -} - -fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow, _frame: OuterFrame) -> IdeResult<()> { - #[cfg(target_os = "macos")] - { - crate::macos::copy_window_frame(from, to).map_err(|error| IdeError::internal(error.to_string())) - } - #[cfg(not(target_os = "macos"))] - { - let err = |error: tauri::Error| IdeError::internal(error.to_string()); - to.set_fullscreen(false).map_err(err)?; - if from.is_maximized().map_err(err)? { - // Maximize on the source's monitor. The rectangles differed, so the maximized flags - // alone are not enough; a hidden peer would otherwise appear at its old size. - to.unmaximize().map_err(err)?; - place_on_monitor(from, to)?; - to.maximize().map_err(err)?; - } else { - to.unmaximize().map_err(err)?; - to.set_position(from.outer_position().map_err(err)?).map_err(err)?; - // `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland. - #[cfg(target_os = "linux")] - let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?; - #[cfg(not(target_os = "linux"))] - let size = from.inner_size().map_err(err)?; - #[cfg(target_os = "linux")] - crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); - to.set_size(size).map_err(err)?; - } - Ok(()) - } -} - -/// Shows `to` in place of `from`, at `from`'s frame. -fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { - let err = |error: tauri::Error| IdeError::internal(error.to_string()); - // Order the target front in the same AppKit turn as the frame write. A separate `show` runs - // after that turn and constrains the titled window, which is the jump. Native fullscreen is - // handed over by `copy_frame` instead. - #[cfg(target_os = "macos")] - if !from.is_fullscreen().map_err(err)? { - to.unminimize().map_err(err)?; - return crate::macos::present_window_frame(from, to).map_err(err); - } - copy_frame(from, to)?; - to.unminimize().map_err(err)?; - to.show().map_err(err)?; - to.set_focus().map_err(err) -} - -/// Keeps hidden group members at the selected window's frame. Selecting a tab then only -/// changes which window is visible instead of moving or resizing one on screen. -fn sync_member_frames(app: &AppHandle, selected: &str) { - let Some(group) = app.state::().groups.lock().group(selected).cloned() else { return }; - if group.selected != selected { - return; - } - let Some(from) = app.get_webview_window(selected) else { return }; - 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) { - let Some(to) = app.get_webview_window(member) else { continue }; - // A visible peer is the one being presented; never move it behind the user's back. - if to.is_visible().unwrap_or(false) { - continue; - } - if let Err(error) = copy_frame(&from, &to) { - tracing::warn!("cannot align window {member} with {selected}: {}", error.message); - } - } -} - -/// A presented group window moved or resized. Align its hidden peers after the user stops, so -/// the next tab selection is only a visibility change. +/// A presented group window moved or resized. Native tabs (macOS) share one frame already. pub fn frame_changed(app: &AppHandle, label: &str) { - let Some(group) = app.state::().groups.lock().group(label).cloned() else { return }; - if group.selected != label { - return; - } - let generation = FRAME_SYNC_GENERATION.fetch_add(1, Ordering::Relaxed) + 1; - let app = app.clone(); - let label = label.to_owned(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(FRAME_SETTLE_AFTER_MOVE).await; - if FRAME_SYNC_GENERATION.load(Ordering::Relaxed) != generation { - return; - } - if !app.state::().quitting() { - sync_member_frames(&app, &label); - } - }); -} - -/// 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 members.iter().filter_map(|m| app.get_webview_window(m)).find(|w| w.is_fullscreen().unwrap_or(false)); #[cfg(not(target_os = "macos"))] - { - let _ = (app, members); - None - } + frames::frame_changed(app, label); + #[cfg(target_os = "macos")] + let _ = (app, label); } -/// The fullscreen host of `label`'s group, when that is another window. -#[cfg(target_os = "macos")] -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) -} - -/// 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. +/// Fullscreen for `window`. On macOS a group is one native tab group, which AppKit takes into a +/// fullscreen Space as a whole. pub fn toggle_fullscreen(window: &WebviewWindow) -> IdeResult { let err = |e: tauri::Error| IdeError::internal(e.to_string()); - #[cfg(target_os = "macos")] - { - let app = window.app_handle(); - 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); - } - 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)?; window.set_fullscreen(on).map_err(err)?; 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<()> { - prepare_host(app, host)?; - crate::macos::native_toggle_fullscreen(host).map_err(|e| IdeError::internal(e.to_string())) -} - -/// `host` and the other members of its group, ready for `host` to enter fullscreen. -#[cfg(target_os = "macos")] -fn prepare_host(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) -} - -/// A fullscreen group's host is about to close, past its prompts. The tab that takes over leaves -/// for a Space of its own first, so the screen moves straight to it; were the host closed first, -/// its Space would end and show the desktop until the next one opened. -#[cfg(target_os = "macos")] -pub async fn before_close(app: &AppHandle, caller: &str) -> IdeResult<()> { - let state = app.state::(); - let Some(members) = state.groups.lock().group(caller).map(|g| g.members.clone()) else { return Ok(()) }; - if state.quitting() || fullscreen_host(app, &members).is_none_or(|host| host.label() != caller) { - return Ok(()); - } - // Detached now, the closing host leaves nothing for `destroyed` to hand over. - let next = state.groups.lock().detach(caller); - changed(app); - let Some(next) = next.and_then(|label| app.get_webview_window(&label)) else { return Ok(()) }; - prepare_host(app, &next)?; - crate::macos::enter_fullscreen_and_wait(&next, SPACE_HANDOVER) - .await - .map_err(|e| IdeError::internal(e.to_string()))?; - windows::focus(&next); - Ok(()) -} - -#[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 joins the host's Space, covering its screen. - let host = fullscreen_host(app, &previous.members); - let result = (|| { - #[cfg(target_os = "macos")] - 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 host.is_none() - && previous.selected != target - && let Some(from) = app.get_webview_window(&previous.selected) - { - show_at_frame(&from, &to)?; - } 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()))?; - } - } - Ok(()) - })(); + #[cfg(target_os = "macos")] + let result = native_select(app, &previous, &to); + #[cfg(not(target_os = "macos"))] + let result = frames::present(app, &previous, target, &to); if result.is_err() { let _ = state.groups.lock().select(caller, &previous.selected); - for member in &previous.members { - if let Some(window) = app.get_webview_window(member) { - if member == &previous.selected { - let _ = window.show(); - let _ = window.set_focus(); - } else { - let _ = window.hide(); - } - } + #[cfg(target_os = "macos")] + if let Some(window) = app.get_webview_window(&previous.selected) { + let _ = native_select(app, &previous, &window); } + #[cfg(not(target_os = "macos"))] + frames::restore_selection(app, &previous); } else if !state.quitting() { - // Align the members that are now hidden while they stay invisible, so the next - // selection does not move or resize a window on screen. Hiding is asynchronous; - // wait for it before moving a peer that is still on screen. - let app = app.clone(); - let target = target.to_owned(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(FRAME_SETTLE_AFTER_SELECT).await; - if !app.state::().quitting() { - sync_member_frames(&app, &target); - } - }); + #[cfg(not(target_os = "macos"))] + frames::selected(app, target); } changed(app); + // A tab shown in a fullscreen group is in full screen too; its title bar asks again. + app.state::().sink.emit(IdeEvent::WindowFullscreenChanged); result } +/// Shows `to` as the selected tab of the group's native tab group. +#[cfg(target_os = "macos")] +fn native_select(app: &AppHandle, group: &Group, to: &WebviewWindow) -> IdeResult<()> { + let members = group.members.iter().filter_map(|m| app.get_webview_window(m)).collect(); + to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?; + crate::macos::sync_tab_group(members, to).map_err(|e| IdeError::internal(e.to_string())) +} + #[cfg(target_os = "macos")] pub fn join(app: &AppHandle, anchor: &str, new_window: &str) -> IdeResult<()> { let state = app.state::(); @@ -492,88 +130,43 @@ pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> { } pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> { + let err = |e: tauri::Error| IdeError::internal(e.to_string()); 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())?; + let Some(next) = state.groups.lock().detach(caller) else { changed(app); return Ok(()); + }; + let window = app.get_webview_window(caller); + // A tab moved out of a fullscreen group stays fullscreen in a Space of its own. + #[cfg(target_os = "macos")] + if let Some(window) = &window { + crate::macos::move_tab_to_new_window(window).map_err(err)?; } - if let Some(next) = next { - if let Some(window) = app.get_webview_window(&next) { - window.show().map_err(|e| IdeError::internal(e.to_string()))?; + if let Some(next) = app.get_webview_window(&next) { + #[cfg(target_os = "macos")] + if state.groups.lock().group(next.label()).is_some() { + select(app, next.label(), next.label())?; } - if let Some(window) = app.get_webview_window(caller) { - if let Ok(position) = window.outer_position() { - let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32)); - } - windows::focus(&window); + #[cfg(not(target_os = "macos"))] + next.show().map_err(err)?; + } + if let Some(window) = window { + if !window.is_fullscreen().map_err(err)? + && let Ok(position) = window.outer_position() + { + let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32)); } + windows::focus(&window); } changed(app); 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 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 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(); @@ -773,47 +366,3 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> { } Ok(()) } - -#[cfg(test)] -mod tests { - use super::{FrameHost, FrameWrite, OuterFrame, frame_write}; - - fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame { - OuterFrame { x, y, width, height } - } - - #[test] - fn matching_outer_rects_are_left_alone() { - let frame = frame(100, 80, 1200, 800); - assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave); - } - - #[test] - fn a_different_outer_rect_is_assigned_before_the_window_is_shown() { - let from = frame(100, 80, 1200, 800); - let to = frame(40, 40, 900, 600); - assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from)); - } - - #[test] - fn maximized_windows_with_different_rects_still_assign() { - let from = frame(0, 25, 1728, 1055); - let to = frame(0, 25, 1512, 900); - assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from)); - } - - #[test] - fn wayland_does_not_place_a_window() { - let from = frame(0, 0, 1200, 800); - let to = frame(10, 10, 800, 600); - assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave); - } - - #[test] - fn native_fullscreen_transfers_only_when_the_rects_differ() { - let from = frame(0, 0, 1728, 1117); - let other = frame(100, 80, 1200, 800); - assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave); - assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen); - } -} diff --git a/src-tauri/src/window_groups/frames.rs b/src-tauri/src/window_groups/frames.rs new file mode 100644 index 000000000..72b6fceef --- /dev/null +++ b/src-tauri/src/window_groups/frames.rs @@ -0,0 +1,285 @@ +//! Window groups without native tabs: one member is shown at a time, and the hidden ones are kept +//! at its frame so that selecting a tab only changes which window is visible. +use super::Group; +use crate::windows::WindowsState; +use ide_core::{IdeError, IdeResult}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use tauri::{AppHandle, Manager, WebviewWindow}; + +/// Wait for the selection's asynchronous `hide` calls to take effect before moving the peers. +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); +/// Coalesces the move/resize events a drag emits. +static FRAME_SYNC_GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Outer frame in physical pixels. A maximized window is represented by the rectangle it +/// occupies, not by the maximized flag: that flag can match while the rectangles do not. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct OuterFrame { + x: i32, + y: i32, + width: u32, + height: u32, +} + +/// Where a group member can be placed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FrameHost { + /// The client can move and resize the window. + Desktop, + /// Wayland does not let clients place top-level windows. + #[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))] + Wayland, + /// Native fullscreen owns a Space. A different frame is transferred, not assigned. + NativeFullscreen, +} + +/// What to write before the target window is shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum FrameWrite { + /// Already the same rectangle, or this host must not be placed. + Leave, + TransferFullscreen, + Assign(OuterFrame), +} + +/// Two windows are aligned only when their outer rectangles match. Maximized or fullscreen +/// 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 => FrameWrite::Leave, + FrameHost::NativeFullscreen if from == to => FrameWrite::Leave, + FrameHost::NativeFullscreen => FrameWrite::TransferFullscreen, + FrameHost::Desktop if from == to => FrameWrite::Leave, + FrameHost::Desktop => FrameWrite::Assign(from), + } +} + +fn outer_frame(window: &WebviewWindow) -> IdeResult { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + let position = window.outer_position().map_err(err)?; + let size = window.outer_size().map_err(err)?; + Ok(OuterFrame { x: position.x, y: position.y, width: size.width, height: size.height }) +} + +fn frame_host(from: &WebviewWindow) -> IdeResult { + #[cfg(target_os = "linux")] + if crate::linux_window_state::is_wayland() { + return Ok(FrameHost::Wayland); + } + if from.is_fullscreen().map_err(|error| IdeError::internal(error.to_string()))? { + return Ok(FrameHost::NativeFullscreen); + } + Ok(FrameHost::Desktop) +} + +/// Moves a hidden window onto `from`'s monitor. A restored window keeps its normal position on +/// the monitor it last occupied; maximizing or going fullscreen there would make the window +/// visibly jump across displays when its tab is selected. +fn place_on_monitor(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + // Wayland does not let clients place top-level windows; maximizing stays on that output. + #[cfg(target_os = "linux")] + if crate::linux_window_state::is_wayland() { + return Ok(()); + } + let err = |e: tauri::Error| IdeError::internal(e.to_string()); + if let Some(monitor) = from.current_monitor().map_err(err)? { + to.set_position(*monitor.position()).map_err(err)?; + } + Ok(()) +} + +/// Gives `to` the source window's outer frame before `to` is shown. +fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + let host = frame_host(from)?; + if host == FrameHost::Wayland { + return Ok(()); + } + match frame_write(host, outer_frame(from)?, outer_frame(to)?) { + FrameWrite::Leave => Ok(()), + FrameWrite::TransferFullscreen => transfer_native_fullscreen(from, to), + FrameWrite::Assign(_) => assign_outer_frame(from, to), + } +} + +fn transfer_native_fullscreen(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + to.set_fullscreen(false).map_err(err)?; + place_on_monitor(from, to)?; + from.set_fullscreen(false).map_err(err)?; + to.set_fullscreen(true).map_err(err)?; + Ok(()) +} + +fn assign_outer_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + to.set_fullscreen(false).map_err(err)?; + if from.is_maximized().map_err(err)? { + // Maximize on the source's monitor. The rectangles differed, so the maximized flags + // alone are not enough; a hidden peer would otherwise appear at its old size. + to.unmaximize().map_err(err)?; + place_on_monitor(from, to)?; + to.maximize().map_err(err)?; + } else { + to.unmaximize().map_err(err)?; + to.set_position(from.outer_position().map_err(err)?).map_err(err)?; + // `set_size` excludes GTK's CSD shadow, which tao's inner size includes on Wayland. + #[cfg(target_os = "linux")] + let size = crate::linux_window_state::content_size(&from.as_ref().window()).map_err(err)?; + #[cfg(not(target_os = "linux"))] + let size = from.inner_size().map_err(err)?; + #[cfg(target_os = "linux")] + crate::linux_window_state::normal_size_requested(to.app_handle(), to.label(), size); + to.set_size(size).map_err(err)?; + } + Ok(()) +} + +/// Shows `to` in place of `from`, at `from`'s frame. +fn show_at_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + let err = |error: tauri::Error| IdeError::internal(error.to_string()); + copy_frame(from, to)?; + to.unminimize().map_err(err)?; + to.show().map_err(err)?; + to.set_focus().map_err(err) +} + +/// Keeps hidden group members at the selected window's frame. Selecting a tab then only +/// changes which window is visible instead of moving or resizing one on screen. +fn sync_member_frames(app: &AppHandle, selected: &str) { + let Some(group) = app.state::().groups.lock().group(selected).cloned() else { return }; + if group.selected != selected { + return; + } + let Some(from) = app.get_webview_window(selected) else { return }; + if from.is_fullscreen().unwrap_or(false) || from.is_minimized().unwrap_or(false) { + return; + } + for member in group.members.iter().filter(|member| member.as_str() != selected) { + let Some(to) = app.get_webview_window(member) else { continue }; + // A visible peer is the one being presented; never move it behind the user's back. + if to.is_visible().unwrap_or(false) { + continue; + } + if let Err(error) = copy_frame(&from, &to) { + tracing::warn!("cannot align window {member} with {selected}: {}", error.message); + } + } +} + +/// A presented group window moved or resized. Align its hidden peers after the user stops, so +/// the next tab selection is only a visibility change. +pub(super) fn frame_changed(app: &AppHandle, label: &str) { + let Some(group) = app.state::().groups.lock().group(label).cloned() else { return }; + if group.selected != label { + return; + } + let generation = FRAME_SYNC_GENERATION.fetch_add(1, Ordering::Relaxed) + 1; + let app = app.clone(); + let label = label.to_owned(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(FRAME_SETTLE_AFTER_MOVE).await; + if FRAME_SYNC_GENERATION.load(Ordering::Relaxed) != generation { + return; + } + if !app.state::().quitting() { + sync_member_frames(&app, &label); + } + }); +} + +/// Shows `to` in place of the group's previously selected member and hides the others. +pub(super) fn present(app: &AppHandle, previous: &Group, target: &str, to: &WebviewWindow) -> IdeResult<()> { + let err = |e: tauri::Error| IdeError::internal(e.to_string()); + if previous.selected != target + && let Some(from) = app.get_webview_window(&previous.selected) + { + show_at_frame(&from, to)?; + } else { + to.unminimize().map_err(err)?; + to.show().map_err(err)?; + to.set_focus().map_err(err)?; + } + for member in &previous.members { + if member != target + && let Some(window) = app.get_webview_window(member) + { + window.hide().map_err(err)?; + } + } + Ok(()) +} + +/// Shows the previously selected member again after a failed selection. +pub(super) fn restore_selection(app: &AppHandle, previous: &Group) { + for member in &previous.members { + if let Some(window) = app.get_webview_window(member) { + if member == &previous.selected { + let _ = window.show(); + let _ = window.set_focus(); + } else { + let _ = window.hide(); + } + } + } +} + +/// Aligns the members that are now hidden while they stay invisible, so the next selection does +/// not move or resize a window on screen. Hiding is asynchronous; wait for it before moving a +/// peer that is still on screen. +pub(super) fn selected(app: &AppHandle, target: &str) { + let app = app.clone(); + let target = target.to_owned(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(FRAME_SETTLE_AFTER_SELECT).await; + if !app.state::().quitting() { + sync_member_frames(&app, &target); + } + }); +} + +#[cfg(test)] +mod tests { + use super::{FrameHost, FrameWrite, OuterFrame, frame_write}; + + fn frame(x: i32, y: i32, width: u32, height: u32) -> OuterFrame { + OuterFrame { x, y, width, height } + } + + #[test] + fn matching_outer_rects_are_left_alone() { + let frame = frame(100, 80, 1200, 800); + assert_eq!(frame_write(FrameHost::Desktop, frame, frame), FrameWrite::Leave); + } + + #[test] + fn a_different_outer_rect_is_assigned_before_the_window_is_shown() { + let from = frame(100, 80, 1200, 800); + let to = frame(40, 40, 900, 600); + assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from)); + } + + #[test] + fn maximized_windows_with_different_rects_still_assign() { + let from = frame(0, 25, 1728, 1055); + let to = frame(0, 25, 1512, 900); + assert_eq!(frame_write(FrameHost::Desktop, from, to), FrameWrite::Assign(from)); + } + + #[test] + fn wayland_does_not_place_a_window() { + let from = frame(0, 0, 1200, 800); + let to = frame(10, 10, 800, 600); + assert_eq!(frame_write(FrameHost::Wayland, from, to), FrameWrite::Leave); + } + + #[test] + fn native_fullscreen_transfers_only_when_the_rects_differ() { + let from = frame(0, 0, 1728, 1117); + let other = frame(100, 80, 1200, 800); + assert_eq!(frame_write(FrameHost::NativeFullscreen, from, from), FrameWrite::Leave); + assert_eq!(frame_write(FrameHost::NativeFullscreen, from, other), FrameWrite::TransferFullscreen); + } +} diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 143f7fb7a..fd9c9fd26 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -699,8 +699,6 @@ fn create_window_for( } let window = window?; #[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); diff --git a/src/bindings.ts b/src/bindings.ts index 361021fbc..1af8bf51b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -42,11 +42,6 @@ export const commands = { windowGroupCloseTabs: (keep: string, labels: string[]) => typedError(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })), /** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */ windowGroupCancelClose: () => __TAURI_INVOKE("window_group_cancel_close"), - /** - * This window closes next (its prompts passed): a fullscreen group's host first hands its Space - * to the tab that takes over. - */ - windowGroupBeforeClose: () => typedError(__TAURI_INVOKE("window_group_before_close")), windowGroupReorder: (labels: string[]) => typedError(__TAURI_INVOKE("window_group_reorder", { labels })), windowGroupRestore: () => typedError(__TAURI_INVOKE("window_group_restore")), /** @@ -925,10 +920,7 @@ export const commands = { windowFlashTaskbar: (flash: boolean) => typedError(__TAURI_INVOKE("window_flash_taskbar", { flash })), /** Height of the web title bar in native logical pixels (CSS height × IDE zoom). */ windowSetTitlebarHeight: (height: number | null) => typedError(__TAURI_INVOKE("window_set_titlebar_height", { height })), - /** - * Whether this window fills the screen: in native full screen, or (macOS) shown in its window - * group's full-screen Space. - */ + /** Whether this window is in native full screen (on macOS, also a tab of a fullscreen group). */ windowIsFullscreen: () => __TAURI_INVOKE("window_is_fullscreen"), /** * Starts dragging `window` while the primary mouse button is still down; returns whether a drag began. diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 448435502..084fb5244 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -205,7 +205,6 @@ async function onCloseRequested(deps: CloseDeps): Promise { deps, ); if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose()); - else if (!ctx.quitting) await commands.windowGroupBeforeClose(); return ok; } catch (e) { maybeEditorService()?.cancelClose?.(); diff --git a/src/features/settings/__tests__/contrast.test.ts b/src/features/settings/__tests__/contrast.test.ts index b86e397d3..19b8d7819 100644 --- a/src/features/settings/__tests__/contrast.test.ts +++ b/src/features/settings/__tests__/contrast.test.ts @@ -123,7 +123,7 @@ describe("theme tokens", () => { * on purpose: colours the user assigns (project and data source colours), the * platform's caption close and macOS window buttons, and the tint strength. */ -const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|mac-window-|tint-strength$)/; +const INHERITED_FROM_BASE = /^--(project-color-|db-color-|caption-close-|fg-caption-close-|tint-strength$)/; describe("palette themes", () => { const ISLANDS = ["light", "dark", "dark-hc"]; diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 2d3d6e111..77cee26a7 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -197,7 +197,6 @@ "window.caption.minimize": "Minimize", "window.caption.maximize": "Maximize", "window.caption.restore": "Restore Down", - "window.caption.exitFullScreen": "Exit Full Screen", "window.caption.close": "Close", "menu.help.keymapReference": "Keymap Reference", "menu.help.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 3e59fcf9e..97c7e6649 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -197,7 +197,6 @@ "window.caption.minimize": "最小化", "window.caption.maximize": "最大化", "window.caption.restore": "向下还原", - "window.caption.exitFullScreen": "退出全屏", "window.caption.close": "关闭", "menu.help.keymapReference": "快捷键参考", "menu.help.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}", diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 46e414cbf..1476e486b 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -436,18 +436,6 @@ html { --fg-caption-close-hover: #ffffff; } -/* macOS window buttons drawn by the web title bar in full screen: the system colours in every theme. */ -:root, -:root[data-theme="light"], -:root[data-theme="dark-hc"] { - --mac-window-close: #ff5f57; - --mac-window-minimize: #febc2e; - --mac-window-zoom: #28c840; - --mac-window-glyph: rgb(0 0 0 / 55%); - --mac-window-edge: rgb(0 0 0 / 12%); - --mac-window-inactive: color-mix(in srgb, var(--fg-strong) 22%, transparent); -} - /* * Islands Light. Every colour token of :root is redefined here. Values are * PyCharm's own; its secondary greys, comment / metadata colours, line numbers diff --git a/src/ui/icons.tsx b/src/ui/icons.tsx index 5ca3e7018..08822404d 100644 --- a/src/ui/icons.tsx +++ b/src/ui/icons.tsx @@ -252,9 +252,6 @@ const ICONS = { "window-maximize": "openidea/general/windowMaximize", "window-restore": "openidea/general/windowRestore", "window-close": "openidea/general/windowClose", - "mac-window-close": "openidea/general/macWindowClose", - "mac-window-minimize": "openidea/general/macWindowMinimize", - "mac-window-exit-full-screen": "openidea/general/macWindowExitFullScreen", hide: "general/hide", pin: "general/pin", eye: "general/show", diff --git a/src/ui/openidea/general/macWindowClose.svg b/src/ui/openidea/general/macWindowClose.svg deleted file mode 100644 index dcf91cfbd..000000000 --- a/src/ui/openidea/general/macWindowClose.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/ui/openidea/general/macWindowExitFullScreen.svg b/src/ui/openidea/general/macWindowExitFullScreen.svg deleted file mode 100644 index 4c0b528a9..000000000 --- a/src/ui/openidea/general/macWindowExitFullScreen.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/ui/openidea/general/macWindowMinimize.svg b/src/ui/openidea/general/macWindowMinimize.svg deleted file mode 100644 index ab44ef431..000000000 --- a/src/ui/openidea/general/macWindowMinimize.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/workbench/MacWindowButtons.tsx b/src/workbench/MacWindowButtons.tsx deleted file mode 100644 index 28f6da788..000000000 --- a/src/workbench/MacWindowButtons.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/** - * macOS window buttons in the title bar's slot while the window fills the screen. Native full - * screen moves the system's buttons into the bar revealed with the menu bar, and a project tab - * shown in its group's full-screen Space has none, so the slot kept for them would be empty. - * Close closes this project's tab, the green button leaves full screen, minimize is disabled. - * Like the system's, they take no keyboard focus. - */ -import { useEffect, useState } from "react"; -import clsx from "clsx"; -import { t } from "@/platform/i18n"; -import { commands, isTauri, onIdeEvent } from "@/platform/ipc"; -import { Icon } from "@/ui"; -import styles from "./TitleBar.module.css"; - -type AppWindow = import("@tauri-apps/api/window").Window; - -function useFullscreenWindow(): { fullscreen: boolean; active: boolean } { - const [fullscreen, setFullscreen] = useState(false); - const [active, setActive] = useState(() => document.hasFocus()); - useEffect(() => { - // The plain-browser shell and tests have no Tauri window to ask. - if (!isTauri()) return; - let disposed = false; - const offs: (() => void)[] = []; - const sync = () => - void commands - .windowIsFullscreen() - .then((on) => !disposed && setFullscreen(on)) - .catch(() => {}); - sync(); - offs.push(onIdeEvent("windowFullscreenChanged", sync)); - void import("@tauri-apps/api/window") - .then(async ({ getCurrentWindow }) => { - const w: AppWindow = getCurrentWindow(); - const listeners = await Promise.all([ - w.onResized(sync), - w.onFocusChanged(({ payload }) => { - setActive(payload); - sync(); - }), - ]); - if (disposed) listeners.forEach((off) => off()); - else offs.push(...listeners); - }) - .catch(() => {}); - return () => { - disposed = true; - offs.forEach((off) => off()); - }; - }, []); - return { fullscreen, active }; -} - -function closeWindow() { - if (!isTauri()) return; - void import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().close()); -} - -export function MacWindowButtons() { - const { fullscreen, active } = useFullscreenWindow(); - if (!fullscreen) return null; - return ( -
e.preventDefault()} - > - - - -
- ); -} diff --git a/src/workbench/TitleBar.module.css b/src/workbench/TitleBar.module.css index de70f2b38..834b1baf4 100644 --- a/src/workbench/TitleBar.module.css +++ b/src/workbench/TitleBar.module.css @@ -16,6 +16,10 @@ /* Keep the gear on the icon-only right stripe's centre line, including compact mode. */ padding-right: calc(var(--stripe-outer-padding) + (var(--stripe-icon-button) - var(--titlebar-tool-button)) / 2); } +/* Native full screen moves the window buttons out of the bar. */ +.overlayFullscreen { + padding-left: 10px; +} /* Welcome window: plain title on the frame colour with a hairline, like IntelliJ's welcome frame. */ .minimal { background: var(--bg-elev); @@ -195,52 +199,3 @@ background: var(--caption-close-pressed); color: var(--fg-caption-close-hover); } - -/* macOS window buttons drawn in the traffic-light slot while the window fills the screen. */ -.macButtons { - position: fixed; - top: 0; - left: 0; - height: var(--h-titlebar); - display: flex; - align-items: center; - gap: 8px; - padding-left: 12px; - zoom: calc(1 / var(--titlebar-zoom, 1)); - z-index: 1; -} -.macButton { - width: 12px; - height: 12px; - padding: 0; - border: none; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: var(--mac-window-glyph); - box-shadow: inset 0 0 0 0.5px var(--mac-window-edge); -} -.macButton > * { - opacity: 0; -} -.macButtons:hover .macButton:not(:disabled) > * { - opacity: 1; -} -.macClose { - background: var(--mac-window-close); -} -.macMinimize { - background: var(--mac-window-minimize); -} -.macZoom { - background: var(--mac-window-zoom); -} -.macButton:active:not(:disabled) { - filter: brightness(0.8); -} -/* Disabled in full screen, and every button of an inactive window, are grey as AppKit draws them. */ -.macButton:disabled, -.macButtonsInactive:not(:hover) .macButton { - background: var(--mac-window-inactive); -} diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index aeedf51c0..7340163d4 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -21,7 +21,7 @@ import { widgetTitle, } from "./mainToolbar"; import { WindowControls } from "./WindowControls"; -import { MacWindowButtons } from "./MacWindowButtons"; +import { useFullscreen } from "./useFullscreen"; import { usePlatform } from "@/platform/platformInfo"; import { projectInitials } from "./projectColor"; import { keymapItems, themeItems, themePreview, type ThemePreview, VIEW_MODE_ITEMS } from "./quickSwitch"; @@ -291,18 +291,23 @@ export function TitleBar({ title?: string; }) { const overlay = usePlatform((s) => s.overlayTitleBar); + const fullscreen = useFullscreen(); const frameless = usePlatform((s) => s.framelessTitleBar); const { map, shown, menu } = useToolbar(); const [menuExpanded, setMenuExpanded] = useState(false); const [bar, setBar] = useState(null); useNativeTitlebar(bar); - const className = clsx(styles.bar, overlay && styles.overlay, frameless && styles.frameless); + const className = clsx( + styles.bar, + overlay && styles.overlay, + overlay && fullscreen && styles.overlayFullscreen, + frameless && styles.frameless, + ); // Distraction Free Mode: the window frame only. Where the title bar carries the // main menu (Windows, Linux) it stays, or nothing would lead back out of the mode. if (bare) return (
- {overlay && } {frameless && ( <> @@ -325,7 +330,6 @@ export function TitleBar({ showContextMenu(e, menu); }} > - {overlay && } {frameless && !minimal && } {title && ( diff --git a/src/workbench/useFullscreen.ts b/src/workbench/useFullscreen.ts new file mode 100644 index 000000000..d7c02f4cc --- /dev/null +++ b/src/workbench/useFullscreen.ts @@ -0,0 +1,28 @@ +/** + * Whether this window fills the screen: native full screen, or a project tab shown in its + * group's full-screen Space. Native full screen moves the window buttons out of the title bar, + * so the room kept for them is released. + */ +import { useEffect, useState } from "react"; +import { commands, isTauri, onIdeEvent } from "@/platform/ipc"; + +export function useFullscreen(): boolean { + const [fullscreen, setFullscreen] = useState(false); + useEffect(() => { + // The plain-browser shell and tests have no Tauri window to ask. + if (!isTauri()) return; + let disposed = false; + const sync = () => + void commands + .windowIsFullscreen() + .then((on) => !disposed && setFullscreen(on)) + .catch(() => {}); + sync(); + const off = onIdeEvent("windowFullscreenChanged", sync); + return () => { + disposed = true; + off(); + }; + }, []); + return fullscreen; +}