diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist
index 4079669da..cde2496c7 100644
--- a/macos/Ghostty-Info.plist
+++ b/macos/Ghostty-Info.plist
@@ -4,23 +4,23 @@
CFBundleDocumentTypes
-
- CFBundleTypeExtensions
-
- command
- tool
- sh
- zsh
- csh
- pl
-
- CFBundleTypeIconFile
- AppIcon.icns
- CFBundleTypeName
- Terminal scripts
- CFBundleTypeRole
- Editor
-
+
+ CFBundleTypeExtensions
+
+ command
+ tool
+ sh
+ zsh
+ csh
+ pl
+
+ CFBundleTypeIconFile
+ AppIcon.icns
+ CFBundleTypeName
+ Terminal scripts
+ CFBundleTypeRole
+ Editor
+
CFBundleTypeName
Folders
@@ -42,87 +42,57 @@
+ GhosttyCommit
+
LSEnvironment
GHOSTTY_MAC_APP
1
- NSServices
-
-
- NSMenuItem
-
- default
- New Ghostty Tab Here
-
- NSMessage
- openTab
- NSRequiredContext
-
- NSTextContent
- FilePath
-
- NSSendTypes
-
- NSFilenamesPboardType
- public.plain-text
-
-
-
- NSMenuItem
-
- default
- New Ghostty Window Here
-
- NSMessage
- openWindow
- NSRequiredContext
-
- NSTextContent
- FilePath
-
- NSSendTypes
-
- NSFilenamesPboardType
- public.plain-text
-
-
-
NSHighResolutionCapable
- NSAppleEventsUsageDescription
- A program in Ghostty wants to use AppleScript.
- NSCalendarsUsageDescription
- A program in Ghostty wants to use your calendar.
- NSCameraUsageDescription
- A program in Ghostty wants to use the camera.
- NSContactsUsageDescription
- A program in Ghostty wants to use your contacts.
- NSLocalNetworkUsageDescription
- A program in Ghostty wants to access the local network.
- NSLocationTemporaryUsageDescriptionDictionary
- A program in Ghostty wants to use your location temporarily.
- NSLocationUsageDescription
- A program in Ghostty wants to use your location information.
- NSMicrophoneUsageDescription
- A program in Ghostty wants to use your microphone.
- NSMotionUsageDescription
- A program in Ghostty wants to access motion data.
- NSPhotoLibraryUsageDescription
- A program in Ghostty wants to use your photo library.
- NSRemindersUsageDescription
- A program in Ghostty wants to access your reminders.
- NSSpeechRecognitionUsageDescription
- A program in Ghostty wants to use speech recognition.
- NSSystemAdministrationUsageDescription
- A program in Ghostty requires elevated privileges.
+ NSServices
+
+
+ NSMenuItem
+
+ default
+ New Ghostty Tab Here
+
+ NSMessage
+ openTab
+ NSRequiredContext
+
+ NSTextContent
+ FilePath
+
+ NSSendTypes
+
+ NSFilenamesPboardType
+ public.plain-text
+
+
+
+ NSMenuItem
+
+ default
+ New Ghostty Window Here
+
+ NSMessage
+ openWindow
+ NSRequiredContext
+
+ NSTextContent
+ FilePath
+
+ NSSendTypes
+
+ NSFilenamesPboardType
+ public.plain-text
+
+
+
SUPublicEDKey
wsNcGf5hirwtdXMVnYoxRIX/SqZQLMOsYlD3q3imeok=
- CFBundleVersion
-
- CFBundleShortVersionString
-
- GhosttyCommit
-
diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj
index e3ad5adf3..502eb3e6a 100644
--- a/macos/Ghostty.xcodeproj/project.pbxproj
+++ b/macos/Ghostty.xcodeproj/project.pbxproj
@@ -9,7 +9,6 @@
/* Begin PBXBuildFile section */
55154BE02B33911F001622DC /* ghostty in Resources */ = {isa = PBXBuildFile; fileRef = 55154BDF2B33911F001622DC /* ghostty */; };
552964E62B34A9B400030505 /* vim in Resources */ = {isa = PBXBuildFile; fileRef = 552964E52B34A9B400030505 /* vim */; };
- 8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */; };
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
@@ -22,6 +21,9 @@
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */; };
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */; };
A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */; };
+ A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */; };
+ A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */; };
+ A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */; };
A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */; };
A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */; };
A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */; };
@@ -94,7 +96,6 @@
3B39CAA42B33949B00DABEB8 /* GhosttyReleaseLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyReleaseLocal.entitlements; sourceTree = ""; };
55154BDF2B33911F001622DC /* ghostty */ = {isa = PBXFileReference; lastKnownFileType = folder; name = ghostty; path = "../zig-out/share/ghostty"; sourceTree = ""; };
552964E52B34A9B400030505 /* vim */ = {isa = PBXFileReference; lastKnownFileType = folder; name = vim; path = "../zig-out/share/vim"; sourceTree = ""; };
- 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenHandler.swift; sourceTree = ""; };
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; };
A514C8D52B54A16400493A16 /* Ghostty.Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Config.swift; sourceTree = ""; };
A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindow.swift; sourceTree = ""; };
@@ -105,6 +106,9 @@
A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateDelegate.swift; sourceTree = ""; };
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Input.swift; sourceTree = ""; };
A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalScreen.swift; sourceTree = ""; };
+ A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fullscreen.swift; sourceTree = ""; };
+ A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FullscreenMode+Extension.swift"; sourceTree = ""; };
+ A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSScreen+Extension.swift"; sourceTree = ""; };
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_UIKit.swift; sourceTree = ""; };
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossKit.swift; sourceTree = ""; };
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_AppKit.swift; sourceTree = ""; };
@@ -233,11 +237,12 @@
A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */,
A5CBD0572C9F30860017A1AE /* Cursor.swift */,
A5D0AF3C2B37804400D21823 /* CodableBridge.swift */,
- 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */,
+ A52FFF582CAA4FF1000C6A5B /* Fullscreen.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */,
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */,
+ A52FFF5C2CAB4D05000C6A5B /* NSScreen+Extension.swift */,
C1F26EA62B738B9900404083 /* NSView+Extension.swift */,
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */,
A5985CD62C320C4500C57AD3 /* String+Extension.swift */,
@@ -314,6 +319,7 @@
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */,
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */,
A55685DF29A03A9F004303CE /* AppError.swift */,
+ A52FFF5A2CAA54A8000C6A5B /* FullscreenMode+Extension.swift */,
);
path = Ghostty;
sourceTree = "";
@@ -573,8 +579,10 @@
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
+ A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
+ A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */,
A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */,
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
@@ -597,8 +605,8 @@
A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */,
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */,
A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */,
- 8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */,
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */,
+ A52FFF592CAA4FF3000C6A5B /* Fullscreen.swift in Sources */,
AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */,
C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */,
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */,
@@ -704,8 +712,21 @@
INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript.";
+ INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar.";
+ INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera.";
+ INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network.";
+ INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily.";
+ INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone.";
+ INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data.";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library.";
+ INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders.";
+ INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition.";
+ INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@@ -858,8 +879,21 @@
INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript.";
+ INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar.";
+ INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera.";
+ INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network.";
+ INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily.";
+ INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone.";
+ INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data.";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library.";
+ INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders.";
+ INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition.";
+ INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@@ -867,7 +901,7 @@
MACOSX_DEPLOYMENT_TARGET = 12.0;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
- PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty;
+ PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty.debug;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h";
@@ -898,8 +932,21 @@
INFOPLIST_FILE = "Ghostty-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
+ INFOPLIST_KEY_NSAppleEventsUsageDescription = "A program in Ghostty wants to use AppleScript.";
+ INFOPLIST_KEY_NSCalendarsUsageDescription = "A program in Ghostty wants to use your calendar.";
+ INFOPLIST_KEY_NSCameraUsageDescription = "A program in Ghostty wants to use the camera.";
+ INFOPLIST_KEY_NSContactsUsageDescription = "A program in Ghostty wants to use your contacts.";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INFOPLIST_KEY_NSLocalNetworkUsageDescription = "A program in Ghostty wants to access the local network.";
+ INFOPLIST_KEY_NSLocationTemporaryUsageDescriptionDictionary = "A program in Ghostty wants to use your location temporarily.";
+ INFOPLIST_KEY_NSLocationUsageDescription = "A program in Ghostty wants to use your location information.";
INFOPLIST_KEY_NSMainNibFile = MainMenu;
+ INFOPLIST_KEY_NSMicrophoneUsageDescription = "A program in Ghostty wants to use your microphone.";
+ INFOPLIST_KEY_NSMotionUsageDescription = "A program in Ghostty wants to access motion data.";
+ INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "A program in Ghostty wants to use your photo library.";
+ INFOPLIST_KEY_NSRemindersUsageDescription = "A program in Ghostty wants to access your reminders.";
+ INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "A program in Ghostty wants to use speech recognition.";
+ INFOPLIST_KEY_NSSystemAdministrationUsageDescription = "A program in Ghostty requires elevated privileges.";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift
index bb8b5665d..2d230561b 100644
--- a/macos/Sources/Features/Terminal/TerminalController.swift
+++ b/macos/Sources/Features/Terminal/TerminalController.swift
@@ -4,12 +4,13 @@ import SwiftUI
import GhosttyKit
/// A classic, tabbed terminal experience.
-class TerminalController: BaseTerminalController
+class TerminalController: BaseTerminalController,
+ FullscreenDelegate
{
override var windowNibName: NSNib.Name? { "Terminal" }
/// Fullscreen state management.
- let fullscreenHandler = FullScreenHandler()
+ private(set) var fullscreenStyle: FullscreenStyle?
/// This is set to true when we care about frame changes. This is a small optimization since
/// this controller registers a listener for ALL frame change notifications and this lets us bail
@@ -199,6 +200,63 @@ class TerminalController: BaseTerminalController
}
}
+ // MARK: Fullscreen
+
+ /// Toggle fullscreen for the given mode.
+ func toggleFullscreen(mode: FullscreenMode) {
+ // We need a window to fullscreen
+ guard let window = self.window else { return }
+
+ // If we have a previous fullscreen style initialized, we want to check if
+ // our mode changed. If it changed and we're in fullscreen, we exit so we can
+ // toggle it next time. If it changed and we're not in fullscreen we can just
+ // switch the handler.
+ var newStyle = mode.style(for: window)
+ newStyle?.delegate = self
+ old: if let oldStyle = self.fullscreenStyle {
+ // If we're not fullscreen, we can nil it out so we get the new style
+ if !oldStyle.isFullscreen {
+ self.fullscreenStyle = newStyle
+ break old
+ }
+
+ assert(oldStyle.isFullscreen)
+
+ // We consider our mode changed if the types change (obvious) but
+ // also if its nil (not obvious) because nil means that the style has
+ // likely changed but we don't support it.
+ if newStyle == nil || type(of: newStyle) != type(of: oldStyle) {
+ // Our mode changed. Exit fullscreen (since we're toggling anyways)
+ // and then unset the style so that we replace it next time.
+ oldStyle.exit()
+ self.fullscreenStyle = nil
+
+ // We're done
+ return
+ }
+
+ // Style is the same.
+ } else {
+ // We have no previous style
+ self.fullscreenStyle = newStyle
+ }
+ guard let fullscreenStyle else { return }
+
+ if fullscreenStyle.isFullscreen {
+ fullscreenStyle.exit()
+ } else {
+ fullscreenStyle.enter()
+ }
+ }
+
+ func fullscreenDidChange() {
+ // For some reason focus can get lost when we change fullscreen. Regardless of
+ // mode above we just move it back.
+ if let focusedSurface {
+ Ghostty.moveFocus(to: focusedSurface)
+ }
+ }
+
//MARK: - NSWindowController
override func windowWillLoad() {
@@ -531,17 +589,16 @@ class TerminalController: BaseTerminalController
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
- // We need a window to fullscreen
- guard let window = self.window else { return }
-
- // Check whether we use non-native fullscreen
- guard let fullscreenModeAny = notification.userInfo?[Ghostty.Notification.FullscreenModeKey] else { return }
- guard let fullscreenMode = fullscreenModeAny as? ghostty_action_fullscreen_e else { return }
- self.fullscreenHandler.toggleFullscreen(window: window, mode: fullscreenMode)
-
- // For some reason focus always gets lost when we toggle fullscreen, so we set it back.
- if let focusedSurface {
- Ghostty.moveFocus(to: focusedSurface)
+ // Get the fullscreen mode we want to toggle
+ let fullscreenMode: FullscreenMode
+ if let any = notification.userInfo?[Ghostty.Notification.FullscreenModeKey],
+ let mode = any as? FullscreenMode {
+ fullscreenMode = mode
+ } else {
+ Ghostty.logger.warning("no fullscreen mode specified or invalid mode, doing nothing")
+ return
}
+
+ toggleFullscreen(mode: fullscreenMode)
}
}
diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift
index 3930012df..f71e198ee 100644
--- a/macos/Sources/Features/Terminal/TerminalManager.swift
+++ b/macos/Sources/Features/Terminal/TerminalManager.swift
@@ -65,17 +65,25 @@ class TerminalManager {
let c = createWindow(withBaseConfig: base)
let window = c.window!
- // We want to go fullscreen if we're configured for new windows to go fullscreen
- var toggleFullScreen = ghostty.config.windowFullscreen
-
- // If the previous focused window prior to creating this window is fullscreen,
- // then this window also becomes fullscreen.
- if let parent = focusedSurface?.window, parent.styleMask.contains(.fullScreen) {
- toggleFullScreen = true
- }
-
- if (toggleFullScreen && !window.styleMask.contains(.fullScreen)) {
+ // If the previous focused window was native fullscreen, the new window also
+ // becomes native fullscreen.
+ if let parent = focusedSurface?.window,
+ parent.styleMask.contains(.fullScreen) {
window.toggleFullScreen(nil)
+ } else if ghostty.config.windowFullscreen {
+ switch (ghostty.config.windowFullscreenMode) {
+ case .native:
+ // Native has to be done immediately so that our stylemask contains
+ // fullscreen for the logic later in this method.
+ c.toggleFullscreen(mode: .native)
+
+ case .nonNative, .nonNativeVisibleMenu:
+ // If we're non-native then we have to do it on a later loop
+ // so that the content view is setup.
+ DispatchQueue.main.async {
+ c.toggleFullscreen(mode: self.ghostty.config.windowFullscreenMode)
+ }
+ }
}
// If our app isn't active, we make it active. All new_window actions
@@ -114,7 +122,8 @@ class TerminalManager {
// If our parent is in non-native fullscreen, then new tabs do not work.
// See: https://github.com/mitchellh/ghostty/issues/392
if let controller = parent.windowController as? TerminalController,
- controller.fullscreenHandler.isInNonNativeFullscreen {
+ let fullscreenStyle = controller.fullscreenStyle,
+ fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs {
let alert = NSAlert()
alert.messageText = "Cannot Create New Tab"
alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again."
diff --git a/macos/Sources/Ghostty/FullscreenMode+Extension.swift b/macos/Sources/Ghostty/FullscreenMode+Extension.swift
new file mode 100644
index 000000000..fffd8e84b
--- /dev/null
+++ b/macos/Sources/Ghostty/FullscreenMode+Extension.swift
@@ -0,0 +1,20 @@
+import GhosttyKit
+
+extension FullscreenMode {
+ /// Initialize from a Ghostty fullscreen action.
+ static func from(ghostty: ghostty_action_fullscreen_e) -> Self? {
+ return switch ghostty {
+ case GHOSTTY_FULLSCREEN_NATIVE:
+ .native
+
+ case GHOSTTY_FULLSCREEN_NON_NATIVE:
+ .nonNative
+
+ case GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU:
+ .nonNativeVisibleMenu
+
+ default:
+ nil
+ }
+ }
+}
diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift
index 05c01a75e..70e4ca94c 100644
--- a/macos/Sources/Ghostty/Ghostty.App.swift
+++ b/macos/Sources/Ghostty/Ghostty.App.swift
@@ -598,7 +598,7 @@ extension Ghostty {
private static func toggleFullscreen(
_ app: ghostty_app_t,
target: ghostty_target_s,
- mode: ghostty_action_fullscreen_e) {
+ mode raw: ghostty_action_fullscreen_e) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle fullscreen does nothing with an app target")
@@ -607,6 +607,10 @@ extension Ghostty {
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface else { return }
guard let surfaceView = self.surfaceView(from: surface) else { return }
+ guard let mode = FullscreenMode.from(ghostty: raw) else {
+ Ghostty.logger.warning("unknow fullscreen mode raw=\(raw.rawValue)")
+ return
+ }
NotificationCenter.default.post(
name: Notification.ghosttyToggleFullscreen,
object: surfaceView,
diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift
index 76f85d2a3..40a0e0fde 100644
--- a/macos/Sources/Ghostty/Ghostty.Config.swift
+++ b/macos/Sources/Ghostty/Ghostty.Config.swift
@@ -219,6 +219,28 @@ extension Ghostty {
return v
}
+ #if canImport(AppKit)
+ var windowFullscreenMode: FullscreenMode {
+ let defaultValue: FullscreenMode = .native
+ guard let config = self.config else { return defaultValue }
+ var v: UnsafePointer? = nil
+ let key = "macos-non-native-fullscreen"
+ guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return defaultValue }
+ guard let ptr = v else { return defaultValue }
+ let str = String(cString: ptr)
+ return switch str {
+ case "false":
+ .native
+ case "true":
+ .nonNative
+ case "visible-menu":
+ .nonNativeVisibleMenu
+ default:
+ defaultValue
+ }
+ }
+ #endif
+
var windowTitleFontFamily: String? {
guard let config = self.config else { return nil }
var v: UnsafePointer? = nil
diff --git a/macos/Sources/Helpers/FullScreenHandler.swift b/macos/Sources/Helpers/FullScreenHandler.swift
deleted file mode 100644
index d12809d71..000000000
--- a/macos/Sources/Helpers/FullScreenHandler.swift
+++ /dev/null
@@ -1,239 +0,0 @@
-import SwiftUI
-import GhosttyKit
-
-class FullScreenHandler {
- var previousTabGroup: NSWindowTabGroup?
- var previousTabGroupIndex: Int?
- var previousContentFrame: NSRect?
- var previousStyleMask: NSWindow.StyleMask? = nil
-
- // We keep track of whether we entered non-native fullscreen in case
- // a user goes to fullscreen, changes the config to disable non-native fullscreen
- // and then wants to toggle it off
- var isInNonNativeFullscreen: Bool = false
- var isInFullscreen: Bool = false
-
- func toggleFullscreen(window: NSWindow, mode: ghostty_action_fullscreen_e) {
- let useNonNativeFullscreen = switch (mode) {
- case GHOSTTY_FULLSCREEN_NATIVE:
- false
-
- case GHOSTTY_FULLSCREEN_NON_NATIVE, GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU:
- true
-
- default:
- false
- }
-
- if isInFullscreen {
- if useNonNativeFullscreen || isInNonNativeFullscreen {
- leaveFullscreen(window: window)
- isInNonNativeFullscreen = false
- } else {
- // Restore titlebar separator style. See below for explanation.
- window.titlebarSeparatorStyle = .automatic
- window.toggleFullScreen(nil)
- }
- isInFullscreen = false
- } else {
- if useNonNativeFullscreen {
- let hideMenu = mode != GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU
- enterFullscreen(window: window, hideMenu: hideMenu)
- isInNonNativeFullscreen = true
- } else {
- // The titlebar separator shows up erroneously in fullscreen if the tab bar
- // is made to appear and then disappear by opening and then closing a tab.
- // We get rid of the separator while in fullscreen to prevent this.
- window.titlebarSeparatorStyle = .none
- window.toggleFullScreen(nil)
- }
- isInFullscreen = true
- }
- }
-
- func enterFullscreen(window: NSWindow, hideMenu: Bool) {
- guard let screen = window.screen else { return }
- guard let contentView = window.contentView else { return }
-
- previousTabGroup = window.tabGroup
- previousTabGroupIndex = window.tabGroup?.windows.firstIndex(of: window)
-
- // Save previous contentViewFrame and screen
- previousContentFrame = window.convertToScreen(contentView.frame)
-
- // Change presentation style to hide menu bar and dock if needed
- // It's important to do this in two calls, because setting them in a single call guarantees
- // that the menu bar will also be hidden on any additional displays (why? nobody knows!)
- // When these options are set separately, the menu bar hiding problem will only occur in
- // specific scenarios. More investigation is needed to pin these scenarios down precisely,
- // but it seems to have something to do with which app had focus last.
- // Furthermore, it's much easier to figure out which screen the dock is on if the menubar
- // has not yet been hidden, so the order matters here!
- if (shouldHideDock(screen: screen)) {
- self.hideDock()
-
- // Ensure that we always hide the dock bar for this window, but not for non fullscreen ones
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(FullScreenHandler.hideDock),
- name: NSWindow.didBecomeMainNotification,
- object: window)
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(FullScreenHandler.unHideDock),
- name: NSWindow.didResignMainNotification,
- object: window)
- }
- if (hideMenu) {
- self.hideMenu()
-
- // Ensure that we always hide the menu bar for this window, but not for non fullscreen ones
- // This is not the best way to do this, not least because it causes the menu to stay visible
- // for a brief moment before being hidden in some cases (e.g. when switching spaces).
- // If we end up adding a NSWindowDelegate to PrimaryWindow, then we may be better off
- // handling this there.
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(FullScreenHandler.hideMenu),
- name: NSWindow.didBecomeMainNotification,
- object: window)
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(FullScreenHandler.onDidResignMain),
- name: NSWindow.didResignMainNotification,
- object: window)
- }
-
- // This is important: it gives us the full screen, including the
- // notch area on MacBooks.
- self.previousStyleMask = window.styleMask
- window.styleMask.remove(.titled)
-
- // Set frame to screen size, accounting for the menu bar if needed
- let frame = calculateFullscreenFrame(screen: screen, subtractMenu: !hideMenu)
- window.setFrame(frame, display: true)
-
- // Focus window
- window.makeKeyAndOrderFront(nil)
- }
-
- @objc func hideMenu() {
- NSApp.presentationOptions.insert(.autoHideMenuBar)
- }
-
- @objc func onDidResignMain(_ notification: Notification) {
- guard let resigningWindow = notification.object as? NSWindow else { return }
- guard let mainWindow = NSApplication.shared.mainWindow else { return }
-
- // We're only unhiding the menu bar, if the focus shifted within our application.
- // In that case, `mainWindow` is the window of our application the focus shifted
- // to.
- if !resigningWindow.isEqual(mainWindow) {
- NSApp.presentationOptions.remove(.autoHideMenuBar)
- }
- }
-
- @objc func hideDock() {
- NSApp.presentationOptions.insert(.autoHideDock)
- }
-
- @objc func unHideDock() {
- NSApp.presentationOptions.remove(.autoHideDock)
- }
-
- func calculateFullscreenFrame(screen: NSScreen, subtractMenu: Bool)->NSRect {
- if (subtractMenu) {
- if let menuHeight = NSApp.mainMenu?.menuBarHeight {
- var padding: CGFloat = 0
-
- // Detect the notch. If there is a safe area on top it includes the
- // menu height as a safe area so we also subtract that from it.
- if (screen.safeAreaInsets.top > 0) {
- padding = screen.safeAreaInsets.top - menuHeight;
- }
-
- return NSMakeRect(
- screen.frame.minX,
- screen.frame.minY,
- screen.frame.width,
- screen.frame.height - (menuHeight + padding)
- )
- }
- }
- return screen.frame
- }
-
- func leaveFullscreen(window: NSWindow) {
- guard let previousFrame = previousContentFrame else { return }
-
- // Restore the style mask
- window.styleMask = self.previousStyleMask!
-
- // Restore previous presentation options
- NSApp.presentationOptions = []
-
- // Stop handling any window focus notifications
- // that we use to manage menu bar visibility
- NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeMainNotification, object: window)
- NotificationCenter.default.removeObserver(self, name: NSWindow.didResignMainNotification, object: window)
-
- // Restore frame
- window.setFrame(window.frameRect(forContentRect: previousFrame), display: true)
-
- // Have titlebar tabs set itself up again, since removing the titlebar when fullscreen breaks its constraints.
- if let window = window as? TerminalWindow, window.titlebarTabs {
- window.titlebarTabs = true
- }
-
- // If the window was previously in a tab group that isn't empty now, we re-add it
- if let group = previousTabGroup, let tabIndex = previousTabGroupIndex, !group.windows.isEmpty {
- var tabWindow: NSWindow?
- var order: NSWindow.OrderingMode = .below
-
- // Index of the window before `window`
- let tabIndexBefore = tabIndex-1
- if tabIndexBefore < 0 {
- // If we were the first tab, we add the window *before* (.below) the first one.
- tabWindow = group.windows.first
- } else if tabIndexBefore < group.windows.count {
- // If we weren't the first tab in the group, we add our window after
- // the tab that was before it.
- tabWindow = group.windows[tabIndexBefore]
- order = .above
- } else {
- // If index is after group, add it after last window
- tabWindow = group.windows.last
- }
-
- // Add the window
- tabWindow?.addTabbedWindow(window, ordered: order)
- }
-
- // Focus window
- window.makeKeyAndOrderFront(nil)
- }
-
- // We only want to hide the dock if it's not already going to be hidden automatically, and if
- // it's on the same display as the ghostty window that we want to make fullscreen.
- func shouldHideDock(screen: NSScreen) -> Bool {
- if let dockAutohide = UserDefaults.standard.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool {
- if (dockAutohide) { return false }
- }
-
- // There is no public API to directly ask about dock visibility, so we have to figure it out
- // by comparing the sizes of visibleFrame (the currently usable area of the screen) and
- // frame (the full screen size). We also need to account for the menubar, any inset caused
- // by the notch on macbooks, and a little extra padding to compensate for the boundary area
- // which triggers showing the dock.
- let frame = screen.frame
- let visibleFrame = screen.visibleFrame
- let menuHeight = NSApp.mainMenu?.menuBarHeight ?? 0
- var notchInset = 0.0
- if #available(macOS 12, *) {
- notchInset = screen.safeAreaInsets.top
- }
- let boundaryAreaPadding = 5.0
-
- return visibleFrame.height < (frame.height - max(menuHeight, notchInset) - boundaryAreaPadding) || visibleFrame.width < frame.width
- }
-}
diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift
new file mode 100644
index 000000000..65de2f627
--- /dev/null
+++ b/macos/Sources/Helpers/Fullscreen.swift
@@ -0,0 +1,362 @@
+import Cocoa
+import GhosttyKit
+
+/// The fullscreen modes we support define how the fullscreen behaves.
+enum FullscreenMode {
+ case native
+ case nonNative
+ case nonNativeVisibleMenu
+
+ /// Initializes the fullscreen style implementation for the mode. This will not toggle any
+ /// fullscreen properties. This may fail if the window isn't configured properly for a given
+ /// mode.
+ func style(for window: NSWindow) -> FullscreenStyle? {
+ switch self {
+ case .native:
+ return NativeFullscreen(window)
+
+ case .nonNative:
+ return NonNativeFullscreen(window)
+
+ case .nonNativeVisibleMenu:
+ return NonNativeFullscreenVisibleMenu(window)
+ }
+ }
+}
+
+/// Protocol that must be implemented by all fullscreen styles.
+protocol FullscreenStyle {
+ var delegate: FullscreenDelegate? { get set }
+ var isFullscreen: Bool { get }
+ var supportsTabs: Bool { get }
+ init?(_ window: NSWindow)
+ func enter()
+ func exit()
+}
+
+/// Delegate that can be implemented for fullscreen implementations.
+protocol FullscreenDelegate: AnyObject {
+ /// Called whenever the fullscreen state changed. You can call isFullscreen to see
+ /// the current state.
+ func fullscreenDidChange()
+}
+
+extension FullscreenDelegate {
+ func fullscreenDidChange() {}
+}
+
+/// macOS native fullscreen. This is the typical behavior you get by pressing the green fullscreen
+/// button on regular titlebars.
+class NativeFullscreen: FullscreenStyle {
+ private let window: NSWindow
+
+ weak var delegate: FullscreenDelegate?
+ var isFullscreen: Bool { window.styleMask.contains(.fullScreen) }
+ var supportsTabs: Bool { true }
+
+ required init?(_ window: NSWindow) {
+ // TODO: There are many requirements for native fullscreen we should
+ // check here such as the stylemask.
+
+ self.window = window
+ }
+
+ func enter() {
+ guard !isFullscreen else { return }
+
+ // The titlebar separator shows up erroneously in fullscreen if the tab bar
+ // is made to appear and then disappear by opening and then closing a tab.
+ // We get rid of the separator while in fullscreen to prevent this.
+ window.titlebarSeparatorStyle = .none
+
+ // Enter fullscreen
+ window.toggleFullScreen(self)
+
+ // Notify the delegate
+ delegate?.fullscreenDidChange()
+ }
+
+ func exit() {
+ guard isFullscreen else { return }
+
+ // Restore titlebar separator style. See enter for explanation.
+ window.titlebarSeparatorStyle = .automatic
+
+ window.toggleFullScreen(nil)
+
+ // Notify the delegate
+ delegate?.fullscreenDidChange()
+ }
+}
+
+class NonNativeFullscreen: FullscreenStyle {
+ weak var delegate: FullscreenDelegate?
+
+ // Non-native fullscreen never supports tabs because tabs require
+ // the "titled" style and we don't have it for non-native fullscreen.
+ var supportsTabs: Bool { false }
+
+ // isFullscreen is dependent on if we have saved state currently. We
+ // could one day try to do fancier stuff like inspecting the window
+ // state but there isn't currently a need for it.
+ var isFullscreen: Bool { savedState != nil }
+
+ // The default properties. Subclasses can override this to change
+ // behavior. This shouldn't be written to (only computed) because
+ // it must be immutable.
+ var properties: Properties { Properties() }
+
+ struct Properties {
+ var hideMenu: Bool = true
+ }
+
+ private let window: NSWindow
+ private var savedState: SavedState?
+
+ required init?(_ window: NSWindow) {
+ self.window = window
+ }
+
+ func enter() {
+ // If we are in fullscreen we don't do it again.
+ guard !isFullscreen else { return }
+
+ // If we are in native fullscreen, exit native fullscreen. This is counter
+ // intuitive but if we entered native fullscreen (through the green max button
+ // or an external event) and we press the fullscreen keybind, we probably
+ // want to EXIT fullscreen.
+ if window.styleMask.contains(.fullScreen) {
+ window.toggleFullScreen(nil)
+ return
+ }
+
+ // This is the screen that we're going to go fullscreen on. We use the
+ // screen the window is currently on.
+ guard let screen = window.screen else { return }
+
+ // Save the state that we need to exit again
+ guard let savedState = SavedState(window) else { return }
+ self.savedState = savedState
+
+ // We hide the dock if the window is on a screen with the dock.
+ if (savedState.dock) {
+ hideDock()
+ }
+
+ // Hide the menu if requested
+ if (properties.hideMenu) {
+ hideMenu()
+ }
+
+ // When this window becomes or resigns main we need to run some logic.
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(windowDidBecomeMain),
+ name: NSWindow.didBecomeMainNotification,
+ object: window)
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(windowDidResignMain),
+ name: NSWindow.didResignMainNotification,
+ object: window)
+
+ // When we change screens we need to redo everything.
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(windowDidChangeScreen),
+ name: NSWindow.didChangeScreenNotification,
+ object: window)
+
+ // Being untitled let's our content take up the full frame.
+ window.styleMask.remove(.titled)
+
+ // Focus window
+ window.makeKeyAndOrderFront(nil)
+
+ // Set frame to screen size, accounting for any elements such as the menu bar.
+ // We do this async so that all the style edits above (title removal, dock
+ // hide, menu hide, etc.) take effect. This fixes:
+ // https://github.com/ghostty-org/ghostty/issues/1996
+ DispatchQueue.main.async {
+ self.window.setFrame(self.fullscreenFrame(screen), display: true)
+ self.delegate?.fullscreenDidChange()
+ }
+ }
+
+ func exit() {
+ guard isFullscreen else { return }
+ guard let savedState else { return }
+
+ // Remove all our notifications
+ NotificationCenter.default.removeObserver(self)
+
+ // Unhide our elements
+ if savedState.dock {
+ unhideDock()
+ }
+ unhideMenu()
+
+ // Restore our saved state
+ window.styleMask = savedState.styleMask
+ window.setFrame(window.frameRect(forContentRect: savedState.contentFrame), display: true)
+
+ // This is a hack that I want to remove from this but for now, we need to
+ // fix up the titlebar tabs here before we do everything below.
+ if let window = window as? TerminalWindow,
+ window.titlebarTabs {
+ window.titlebarTabs = true
+ }
+
+ // If the window was previously in a tab group that isn't empty now,
+ // we re-add it. We have to do this because our process of doing non-native
+ // fullscreen removes the window from the tab group.
+ if let tabGroup = savedState.tabGroup,
+ let tabIndex = savedState.tabGroupIndex,
+ !tabGroup.windows.isEmpty {
+ if tabIndex == 0 {
+ // We were previously the first tab. Add it before ("below")
+ // the first window in the tab group currently.
+ tabGroup.windows.first!.addTabbedWindow(window, ordered: .below)
+ } else if tabIndex <= tabGroup.windows.count {
+ // We were somewhere in the middle
+ tabGroup.windows[tabIndex - 1].addTabbedWindow(window, ordered: .above)
+ } else {
+ // We were at the end
+ tabGroup.windows.last!.addTabbedWindow(window, ordered: .below)
+ }
+ }
+
+ // Unset our saved state, we're restored!
+ self.savedState = nil
+
+ // Focus window
+ window.makeKeyAndOrderFront(nil)
+
+ // Notify the delegate
+ self.delegate?.fullscreenDidChange()
+ }
+
+ private func fullscreenFrame(_ screen: NSScreen) -> NSRect {
+ // It would make more sense to use "visibleFrame" but visibleFrame
+ // will omit space by our dock and isn't updated until an event
+ // loop tick which we don't have time for. So we use frame and
+ // calculate this ourselves.
+ var frame = screen.frame
+
+ if (!properties.hideMenu) {
+ // We need to subtract the menu height since we're still showing it.
+ frame.size.height -= NSApp.mainMenu?.menuBarHeight ?? 0
+
+ // NOTE on macOS bugs: macOS used to have a bug where menuBarHeight
+ // didn't account for the notch. I reported this as a radar and it
+ // was fixed at some point. I don't know when that was so I can't
+ // put an #available check, but it was in a bug fix release so I think
+ // if a bug is reported to Ghostty we can just advise the user to
+ // update.
+ }
+
+ return frame
+ }
+
+ // MARK: Window Events
+
+ @objc func windowDidChangeScreen(_ notification: Notification) {
+ guard isFullscreen else { return }
+ guard let savedState else { return }
+
+ // This should always be true due to how we register but just be sure
+ guard let object = notification.object as? NSWindow,
+ object == window else { return }
+
+ // Our screens must have changed
+ guard savedState.screen != window.screen else { return }
+
+ // When we change screens, we simply exit fullscreen. Changing
+ // screens shouldn't naturally be possible, it can only happen
+ // through external window managers. There's a lot of accounting
+ // to do to get the screen change right so instead of breaking
+ // we just exit out. The user can re-enter fullscreen thereafter.
+ exit()
+ }
+
+ @objc func windowDidBecomeMain(_ notification: Notification) {
+ guard let savedState else { return }
+
+ // This should always be true due to how we register but just be sure
+ guard let object = notification.object as? NSWindow,
+ object == window else { return }
+
+ // This is crazy but at least on macOS 15.0, you must hide the dock
+ // FIRST then hide the menu. If you do the opposite, it does not
+ // work.
+
+ if savedState.dock {
+ hideDock()
+ }
+
+ if (properties.hideMenu) {
+ hideMenu()
+ }
+ }
+
+ @objc func windowDidResignMain(_ notification: Notification) {
+ guard let savedState else { return }
+
+ // This should always be true due to how we register but just be sure
+ guard let object = notification.object as? NSWindow,
+ object == window else { return }
+
+ if (properties.hideMenu) {
+ unhideMenu()
+ }
+
+ if savedState.dock {
+ unhideDock()
+ }
+ }
+
+ // MARK: Dock
+
+ private func hideDock() {
+ NSApp.presentationOptions.insert(.autoHideDock)
+ }
+
+ private func unhideDock() {
+ NSApp.presentationOptions.remove(.autoHideDock)
+ }
+
+ // MARK: Menu
+
+ func hideMenu() {
+ NSApp.presentationOptions.insert(.autoHideMenuBar)
+ }
+
+ func unhideMenu() {
+ NSApp.presentationOptions.remove(.autoHideMenuBar)
+ }
+
+ /// The state that must be saved for non-native fullscreen to exit fullscreen.
+ class SavedState {
+ weak var screen: NSScreen?
+ let tabGroup: NSWindowTabGroup?
+ let tabGroupIndex: Int?
+ let contentFrame: NSRect
+ let styleMask: NSWindow.StyleMask
+ let dock: Bool
+
+ init?(_ window: NSWindow) {
+ guard let contentView = window.contentView else { return nil }
+
+ self.screen = window.screen
+ self.tabGroup = window.tabGroup
+ self.tabGroupIndex = window.tabGroup?.windows.firstIndex(of: window)
+ self.contentFrame = window.convertToScreen(contentView.frame)
+ self.styleMask = window.styleMask
+ self.dock = window.screen?.hasDock ?? false
+ }
+ }
+}
+
+class NonNativeFullscreenVisibleMenu: NonNativeFullscreen {
+ override var properties: Properties { Properties(hideMenu: false) }
+}
diff --git a/macos/Sources/Helpers/NSScreen+Extension.swift b/macos/Sources/Helpers/NSScreen+Extension.swift
new file mode 100644
index 000000000..f5a08b524
--- /dev/null
+++ b/macos/Sources/Helpers/NSScreen+Extension.swift
@@ -0,0 +1,36 @@
+import Cocoa
+
+extension NSScreen {
+ // Returns true if the given screen has a visible dock. This isn't
+ // point-in-time visible, this is true if the dock is always visible
+ // AND present on this screen.
+ var hasDock: Bool {
+ // If the dock autohides then we don't have a dock ever.
+ if let dockAutohide = UserDefaults.standard.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool {
+ if (dockAutohide) { return false }
+ }
+
+ // There is no public API to directly ask about dock visibility, so we have to figure it out
+ // by comparing the sizes of visibleFrame (the currently usable area of the screen) and
+ // frame (the full screen size). We also need to account for the menubar, any inset caused
+ // by the notch on macbooks, and a little extra padding to compensate for the boundary area
+ // which triggers showing the dock.
+
+ // If our visible width is less than the frame we assume its the dock.
+ if (visibleFrame.width < frame.width) {
+ return true
+ }
+
+ // We need to see if our visible frame height is less than the full
+ // screen height minus the menu and notch and such.
+ let menuHeight = NSApp.mainMenu?.menuBarHeight ?? 0
+ let notchInset: CGFloat = if #available(macOS 12, *) {
+ safeAreaInsets.top
+ } else {
+ 0
+ }
+ let boundaryAreaPadding = 5.0
+
+ return visibleFrame.height < (frame.height - max(menuHeight, notchInset) - boundaryAreaPadding)
+ }
+}
diff --git a/src/config/Config.zig b/src/config/Config.zig
index 0f5e9b81b..35156dc18 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -1375,8 +1375,15 @@ keybind: Keybinds = .{},
/// using a new space. It's faster than the native fullscreen mode since it
/// doesn't use animations.
///
-/// Warning: tabs do not work with a non-native fullscreen window. This
-/// can be fixed but is looking for contributors to help. See issue #392.
+/// Important: tabs DO NOT WORK in this mode. Non-native fullscreen removes
+/// the titlebar and macOS native tabs require the titlebar. If you use tabs,
+/// you should not use this mode.
+///
+/// If you fullscreen a window with tabs, the currently focused tab will
+/// become fullscreen while the others will remain in a separate window in
+/// the background. You can switch to that window using normal window-switching
+/// keybindings such as command+tilde. When you exit fullscreen, the window
+/// will return to the tabbed state it was in before.
///
/// Allowable values are:
///
@@ -1384,6 +1391,9 @@ keybind: Keybinds = .{},
/// * `true` - Use non-native macOS fullscreen, hide the menu bar
/// * `false` - Use native macOS fullscreen
///
+/// Changing this option at runtime works, but will only apply to the next
+/// time the window is made fullscreen. If a window is already fullscreen,
+/// it will retain the previous setting until fullscreen is exited.
@"macos-non-native-fullscreen": NonNativeFullscreen = .false,
/// The style of the macOS titlebar. Available values are: "native",