From 453e6590e8a648b7bf6617afb71e7fc07ad782a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 14 Apr 2025 10:37:54 -0700 Subject: [PATCH 1/2] macOS: non-native fullscreen should not hide menu on fullscreen space Fixes #7075 We have to use private APIs for this, I couldn't find a reliable way otherwise. --- macos/Ghostty.xcodeproj/project.pbxproj | 18 ++++- .../QuickTerminalController.swift | 12 +-- macos/Sources/Helpers/Fullscreen.swift | 13 ++- .../Sources/Helpers/NSWindow+Extension.swift | 8 ++ macos/Sources/Helpers/Private/CGS.swift | 81 +++++++++++++++++++ .../Sources/Helpers/{ => Private}/Dock.swift | 0 6 files changed, 120 insertions(+), 12 deletions(-) create mode 100644 macos/Sources/Helpers/NSWindow+Extension.swift create mode 100644 macos/Sources/Helpers/Private/CGS.swift rename macos/Sources/Helpers/{ => Private}/Dock.swift (100%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b4c00946c..b69541504 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -55,6 +55,8 @@ A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; }; A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; }; + A5874D992DAD751B00E83852 /* CGS.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D982DAD751A00E83852 /* CGS.swift */; }; + A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A59630972AEE163600D64628 /* HostingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630962AEE163600D64628 /* HostingWindow.swift */; }; A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; }; @@ -154,6 +156,8 @@ A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; }; A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; + A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = ""; }; + A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = ""; }; A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; }; @@ -274,13 +278,13 @@ A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( + A5874D9B2DAD781100E83852 /* Private */, A5AEB1642D5BE7BF00513529 /* LastWindowPosition.swift */, A5A6F7292CC41B8700B232A5 /* Xcode.swift */, A5CEAFFE29C2410700646FDA /* Backport.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, - A5A2A3C92D4445E20033CF96 /* Dock.swift */, A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */, @@ -293,6 +297,7 @@ A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, + A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */, A5CA378D2D31D6C100931030 /* Weak.swift */, @@ -403,6 +408,15 @@ path = "Secure Input"; sourceTree = ""; }; + A5874D9B2DAD781100E83852 /* Private */ = { + isa = PBXGroup; + children = ( + A5874D982DAD751A00E83852 /* CGS.swift */, + A5A2A3C92D4445E20033CF96 /* Dock.swift */, + ); + path = Private; + sourceTree = ""; + }; A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( @@ -634,6 +648,7 @@ A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */, + A5874D9D2DAD786100E83852 /* NSWindow+Extension.swift in Sources */, A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */, @@ -669,6 +684,7 @@ A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, + A5874D992DAD751B00E83852 /* CGS.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index fac3a2fbb..896b25326 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -3,12 +3,6 @@ import Cocoa import SwiftUI import GhosttyKit -// This is a Apple's private function that we need to call to get the active space. -@_silgen_name("CGSGetActiveSpace") -func CGSGetActiveSpace(_ cid: Int) -> size_t -@_silgen_name("CGSMainConnectionID") -func CGSMainConnectionID() -> Int - /// Controller for the "quick" terminal. class QuickTerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { "QuickTerminal" } @@ -25,7 +19,7 @@ class QuickTerminalController: BaseTerminalController { private var previousApp: NSRunningApplication? = nil // The active space when the quick terminal was last shown. - private var previousActiveSpace: size_t = 0 + private var previousActiveSpace: CGSSpace? = nil /// Non-nil if we have hidden dock state. private var hiddenDock: HiddenDock? = nil @@ -154,7 +148,7 @@ class QuickTerminalController: BaseTerminalController { animateOut() case .move: - let currentActiveSpace = CGSGetActiveSpace(CGSMainConnectionID()) + let currentActiveSpace = CGSSpace.active() if previousActiveSpace == currentActiveSpace { // We haven't moved spaces. We lost focus to another app on the // current space. Animate out. @@ -224,7 +218,7 @@ class QuickTerminalController: BaseTerminalController { } // Set previous active space - self.previousActiveSpace = CGSGetActiveSpace(CGSMainConnectionID()) + self.previousActiveSpace = CGSSpace.active() // Animate the window in animateWindowIn(window: window, from: position) diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 59865fc9e..1daf9f142 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -180,7 +180,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { } // Hide the menu if requested - if (properties.hideMenu) { + if (properties.hideMenu && savedState.menu) { hideMenu() } @@ -224,7 +224,9 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { if savedState.dock { unhideDock() } - unhideMenu() + if (properties.hideMenu && savedState.menu) { + unhideMenu() + } // Restore our saved state window.styleMask = savedState.styleMask @@ -340,6 +342,7 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { let contentFrame: NSRect let styleMask: NSWindow.StyleMask let dock: Bool + let menu: Bool init?(_ window: NSWindow) { guard let contentView = window.contentView else { return nil } @@ -350,6 +353,12 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { self.contentFrame = window.convertToScreen(contentView.frame) self.styleMask = window.styleMask self.dock = window.screen?.hasDock ?? false + + // We hide the menu only if this window is not on any fullscreen + // spaces. We do this because fullscreen spaces already hide the + // menu and if we insert/remove this presentation option we get + // issues (see #7075) + self.menu = CGSSpace.list(for: window.cgWindowId).allSatisfy { $0.type != .fullscreen } } } } diff --git a/macos/Sources/Helpers/NSWindow+Extension.swift b/macos/Sources/Helpers/NSWindow+Extension.swift new file mode 100644 index 000000000..c7523bdb7 --- /dev/null +++ b/macos/Sources/Helpers/NSWindow+Extension.swift @@ -0,0 +1,8 @@ +import AppKit + +extension NSWindow { + /// Get the CGWindowID type for the window (used for low level CoreGraphics APIs). + var cgWindowId: CGWindowID { + CGWindowID(windowNumber) + } +} diff --git a/macos/Sources/Helpers/Private/CGS.swift b/macos/Sources/Helpers/Private/CGS.swift new file mode 100644 index 000000000..f9c20afe9 --- /dev/null +++ b/macos/Sources/Helpers/Private/CGS.swift @@ -0,0 +1,81 @@ +import AppKit + +// MARK: - CGS Private API Declarations + +typealias CGSConnectionID = Int32 +typealias CGSSpaceID = size_t + +@_silgen_name("CGSMainConnectionID") +private func CGSMainConnectionID() -> CGSConnectionID + +@_silgen_name("CGSGetActiveSpace") +private func CGSGetActiveSpace(_ cid: CGSConnectionID) -> CGSSpaceID + +@_silgen_name("CGSSpaceGetType") +private func CGSSpaceGetType(_ cid: CGSConnectionID, _ spaceID: CGSSpaceID) -> CGSSpaceType + +@_silgen_name("CGSCopySpacesForWindows") +func CGSCopySpacesForWindows( + _ cid: CGSConnectionID, + _ mask: CGSSpaceMask, + _ windowIDs: CFArray +) -> Unmanaged? + +// MARK: - CGS Space + +/// https://github.com/NUIKit/CGSInternal/blob/c4f6f559d624dc1cfc2bf24c8c19dbf653317fcf/CGSSpace.h#L40 +/// converted to Swift +struct CGSSpaceMask: OptionSet { + let rawValue: UInt32 + + static let includesCurrent = CGSSpaceMask(rawValue: 1 << 0) + static let includesOthers = CGSSpaceMask(rawValue: 1 << 1) + static let includesUser = CGSSpaceMask(rawValue: 1 << 2) + + static let includesVisible = CGSSpaceMask(rawValue: 1 << 16) + + static let currentSpace: CGSSpaceMask = [.includesUser, .includesCurrent] + static let otherSpaces: CGSSpaceMask = [.includesOthers, .includesCurrent] + static let allSpaces: CGSSpaceMask = [.includesUser, .includesOthers, .includesCurrent] + static let allVisibleSpaces: CGSSpaceMask = [.includesVisible, .allSpaces] +} + +/// Represents a unique identifier for a macOS Space (Desktop, Fullscreen, etc). +struct CGSSpace: Hashable, CustomStringConvertible { + let rawValue: CGSSpaceID + + var description: String { + "SpaceID(\(rawValue))" + } + + /// Returns the currently active space. + static func active() -> CGSSpace { + let space = CGSGetActiveSpace(CGSMainConnectionID()) + return .init(rawValue: space) + } + + /// List the sapces for the given window. + static func list(for windowID: CGWindowID, mask: CGSSpaceMask = .allSpaces) -> [CGSSpace] { + guard let spaces = CGSCopySpacesForWindows( + CGSMainConnectionID(), + mask, + [windowID] as CFArray + ) else { return [] } + guard let spaceIDs = spaces.takeRetainedValue() as? [CGSSpaceID] else { return [] } + return spaceIDs.map(CGSSpace.init) + } +} + +// MARK: - CGS Space Types + +enum CGSSpaceType: UInt32 { + case user = 0 + case system = 2 + case fullscreen = 4 +} + +extension CGSSpace { + var type: CGSSpaceType { + CGSSpaceGetType(CGSMainConnectionID(), rawValue) + } +} diff --git a/macos/Sources/Helpers/Dock.swift b/macos/Sources/Helpers/Private/Dock.swift similarity index 100% rename from macos/Sources/Helpers/Dock.swift rename to macos/Sources/Helpers/Private/Dock.swift From d1c15dbf0768ecdb7fdde6a590c1d4b252b9532e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 14 Apr 2025 10:50:30 -0700 Subject: [PATCH 2/2] macOS: quick terminal should retain menu if not frontmost This is a bug I noticed in the following scenario: 1. Open Ghostty 2. Fullscreen normal terminal window (native fullscreen) 3. Open quick terminal 4. Move spaces, QT follows 5. Fullscreen the quick terminal The result was that the menu bar would not disappear since our app is not frontmost but we set the fullscreen frame such that we expected it. --- .../QuickTerminalController.swift | 34 ++++++++++++++++--- macos/Sources/Helpers/Fullscreen.swift | 11 ++++-- .../Helpers/NSApplication+Extension.swift | 12 +++++++ macos/Sources/Helpers/Private/CGS.swift | 2 +- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 896b25326..6e5607c6f 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -45,7 +45,7 @@ class QuickTerminalController: BaseTerminalController { object: nil) center.addObserver( self, - selector: #selector(onToggleFullscreen), + selector: #selector(onToggleFullscreen(notification:)), name: Ghostty.Notification.ghosttyToggleFullscreen, object: nil) center.addObserver( @@ -154,8 +154,18 @@ class QuickTerminalController: BaseTerminalController { // current space. Animate out. animateOut() } else { - // We've moved to a different space. Bring the quick terminal back - // into view. + // We've moved to a different space. + + // If we're fullscreen, we need to exit fullscreen because the visible + // bounds may have changed causing a new behavior. + if let fullscreenStyle, fullscreenStyle.isFullscreen { + fullscreenStyle.exit() + DispatchQueue.main.async { + self.onToggleFullscreen() + } + } + + // Make the window visible again on this space DispatchQueue.main.async { self.window?.makeKeyAndOrderFront(nil) } @@ -479,9 +489,23 @@ class QuickTerminalController: BaseTerminalController { @objc private func onToggleFullscreen(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard target == self.focusedSurface else { return } + onToggleFullscreen() + } - // We ignore the requested mode and always use non-native for the quick terminal - toggleFullscreen(mode: .nonNative) + private func onToggleFullscreen() { + // We ignore the configured fullscreen style and always use non-native + // because the way the quick terminal works doesn't support native. + // + // An additional detail is that if the is NOT frontmost, then our + // NSApp.presentationOptions will not take effect so we must always + // do the visible menu mode since we can't get rid of the menu. + let mode: FullscreenMode = if (NSApp.isFrontmost) { + .nonNative + } else { + .nonNativeVisibleMenu + } + + toggleFullscreen(mode: mode) } @objc private func ghosttyConfigDidChange(_ notification: Notification) { diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 1daf9f142..b6fb08271 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -275,7 +275,8 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // calculate this ourselves. var frame = screen.frame - if (!properties.hideMenu) { + if (!NSApp.presentationOptions.contains(.autoHideMenuBar) && + !NSApp.presentationOptions.contains(.hideMenuBar)) { // We need to subtract the menu height since we're still showing it. frame.size.height -= NSApp.mainMenu?.menuBarHeight ?? 0 @@ -358,7 +359,13 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { // spaces. We do this because fullscreen spaces already hide the // menu and if we insert/remove this presentation option we get // issues (see #7075) - self.menu = CGSSpace.list(for: window.cgWindowId).allSatisfy { $0.type != .fullscreen } + let activeSpace = CGSSpace.active() + let spaces = CGSSpace.list(for: window.cgWindowId) + if spaces.contains(activeSpace) { + self.menu = activeSpace.type != .fullscreen + } else { + self.menu = spaces.allSatisfy { $0.type != .fullscreen } + } } } } diff --git a/macos/Sources/Helpers/NSApplication+Extension.swift b/macos/Sources/Helpers/NSApplication+Extension.swift index 0580cd5fc..d8e41523a 100644 --- a/macos/Sources/Helpers/NSApplication+Extension.swift +++ b/macos/Sources/Helpers/NSApplication+Extension.swift @@ -1,5 +1,7 @@ import Cocoa +// MARK: Presentation Options + extension NSApplication { private static var presentationOptionCounts: [NSApplication.PresentationOptions.Element: UInt] = [:] @@ -29,3 +31,13 @@ extension NSApplication.PresentationOptions.Element: @retroactive Hashable { hasher.combine(rawValue) } } + +// MARK: Frontmost + +extension NSApplication { + /// True if the application is frontmost. This isn't exactly the same as isActive because + /// an app can be active but not be frontmost if the window with activity is an NSPanel. + var isFrontmost: Bool { + NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Bundle.main.bundleIdentifier + } +} diff --git a/macos/Sources/Helpers/Private/CGS.swift b/macos/Sources/Helpers/Private/CGS.swift index f9c20afe9..0d3b9aa4c 100644 --- a/macos/Sources/Helpers/Private/CGS.swift +++ b/macos/Sources/Helpers/Private/CGS.swift @@ -54,7 +54,7 @@ struct CGSSpace: Hashable, CustomStringConvertible { return .init(rawValue: space) } - /// List the sapces for the given window. + /// List the spaces for the given window. static func list(for windowID: CGWindowID, mask: CGSSpaceMask = .allSpaces) -> [CGSSpace] { guard let spaces = CGSCopySpacesForWindows( CGSMainConnectionID(),