macOS: fix quick terminal fullscreen issues (#7091)

Supersedes #7075 
Fixes #7070 

This fixes a few separate fullscreen issues with the quick terminal:

1. If we're on a fullscreen space, we can't reliably set the
`autoHideMenuBar` presentation option because macOS itself is managing
it. The fix is to use private APIs to detect we're on a fullscreen space
and avoid this.

2. If our quick terminal is fullscreen when we move spaces, we must exit
and re-enter fullscreen because the frame may change (e.g. due to
menubar changes).

3. If we aren't the frontmost app, we must avoid hiding the menu because
it has no effect and our fullscreen frame would be wrong.
pull/7094/head
Mitchell Hashimoto 2025-04-14 11:29:04 -07:00 committed by GitHub
commit 8bab8f7d64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 169 additions and 18 deletions

View File

@ -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 = "<group>"; };
A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = "<group>"; };
A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = "<group>"; };
A5874D982DAD751A00E83852 /* CGS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGS.swift; sourceTree = "<group>"; };
A5874D9C2DAD785F00E83852 /* NSWindow+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Extension.swift"; sourceTree = "<group>"; };
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = "<group>"; };
A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = "<group>"; };
@ -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 = "<group>";
};
A5874D9B2DAD781100E83852 /* Private */ = {
isa = PBXGroup;
children = (
A5874D982DAD751A00E83852 /* CGS.swift */,
A5A2A3C92D4445E20033CF96 /* Dock.swift */,
);
path = Private;
sourceTree = "<group>";
};
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 */,

View File

@ -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
@ -51,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,14 +148,24 @@ 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.
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)
}
@ -224,7 +228,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)
@ -485,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) {

View File

@ -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()
}
if (properties.hideMenu && savedState.menu) {
unhideMenu()
}
// Restore our saved state
window.styleMask = savedState.styleMask
@ -273,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
@ -340,6 +343,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 +354,18 @@ 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)
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 }
}
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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<CFArray>?
// 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 spaces 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)
}
}