macos: add support for non-native fullscreen mode

This adds support for what's commonly referred to as "non-native
fullscreen": a fullscreen-mode that doesn't use macOS' native fullscreen
mechanism and thus doesn't use animations and a separate space on which
to display the fullscreen window. Instead it's really fast and it allows
the user to `Cmd+tab` to other windows, with the fullscreen-ed window
staying in the background.

Another name for it is "traditional fullscreen" since it was the default
pre Mac OS X Lion, if I remember correctly.

Other applications that offer macOS non-native fullscreen:

- Kitty: https://sw.kovidgoyal.net/kitty/conf/#opt-kitty.macos_traditional_fullscreen
- wezterm: https://wezfurlong.org/wezterm/config/lua/config/native_macos_fullscreen_mode.html
- MacVim
- IINA: fc66b27d50/iina/MainWindowController.swift (L1401-L1423)
- mpv: https://mpv.io/manual/stable/#options-native-fs
- iTerm2

Adding this wasn't straightforward, as it turned out. Mainly because
SwiftUI's app lifecycle management doesn't allow one to use a custom
class for the windows it creates. And without custom classes we'd always
get a warning when entering/leaving fullscreen mode.

So what I did here is the following:

- remove SwiftUI app lifecycle management
- introduce `MainMenu.xib` to define the main menu via interface builder
- add `GhosttyAppController` to handle requests from the app
- add a `main.swift` file to boot up the app without a storyboard and
  without SwiftUI lifecycle management
- introduce the `FullScreenHandler` to manage non-native fullscreen -
  this is where the "magic" is

But since removing the SwiftUI lifecycle management also means removing
the top-level `App` that means I had to introduce the menu (which I
mentioned), but also tab and window management.

So I also added the `WindowService` which manages open tabs and windows.
It's based on the ideas presented in

    https://christiantietze.de/posts/2019/07/nswindow-tabbing-multiple-nswindowcontroller/

and essentially keeps tracks of windows.

Then there's some auxilliary changes: `CustomWindow` and `WindowController` and so on.

Now everything still works, in addition to non-native fullscreen:

* opening/closing of tabs
* opening/closing of windows
* splits
* `gotoTab`

Worthy of note: when toggling back from non-native fullscreen to
non-fullscreen I had to manually implement the logic to re-add the
window back to a tabgroup. The only other app that supports tabs with
non-native FS is iTerm2 and they have implemented their own tab
management to keep the tab bar even in non-native FS -- that's a bit too
much for me. Every other app has non-native apps and doesn't have to
wory about it.
This commit is contained in:
Thorsten Ball
2023-06-30 06:33:46 +02:00
committed by Mitchell Hashimoto
parent 4df462ce6f
commit 850bf3e945
12 changed files with 682 additions and 209 deletions

View File

@@ -7,6 +7,12 @@
objects = {
/* Begin PBXBuildFile section */
8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */; };
85102A1A2A6E32720084AB3E /* WindowService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85102A192A6E32720084AB3E /* WindowService.swift */; };
85102A1C2A6E32890084AB3E /* WindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85102A1B2A6E32890084AB3E /* WindowController.swift */; };
852655222A597CA900E4F7AD /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852655212A597CA900E4F7AD /* main.swift */; };
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
85DE1C922A6A3DCA00493853 /* CustomWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DE1C912A6A3DCA00493853 /* CustomWindow.swift */; };
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; };
A545D1A22A5772CE006E0AE4 /* shell-integration in Resources */ = {isa = PBXBuildFile; fileRef = A545D1A12A5772CE006E0AE4 /* shell-integration */; };
A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; };
@@ -17,7 +23,7 @@
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyApp.swift */; };
A5B30535299BEAAA0047F10C /* GhosttyAppController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyAppController.swift */; };
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; };
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; };
@@ -27,6 +33,12 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenHandler.swift; sourceTree = "<group>"; };
85102A192A6E32720084AB3E /* WindowService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowService.swift; sourceTree = "<group>"; };
85102A1B2A6E32890084AB3E /* WindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowController.swift; sourceTree = "<group>"; };
852655212A597CA900E4F7AD /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
85DE1C912A6A3DCA00493853 /* CustomWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWindow.swift; sourceTree = "<group>"; };
A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
A545D1A12A5772CE006E0AE4 /* shell-integration */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "shell-integration"; path = "../zig-out/share/shell-integration"; sourceTree = "<group>"; };
A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
@@ -38,7 +50,7 @@
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; };
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30534299BEAAA0047F10C /* GhosttyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyApp.swift; sourceTree = "<group>"; };
A5B30534299BEAAA0047F10C /* GhosttyAppController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAppController.swift; sourceTree = "<group>"; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = "<group>"; };
@@ -67,13 +79,19 @@
A5D495A0299BEC2200DD1313 /* Preview Content */,
A5CEAFDA29B8005900646FDA /* SplitView */,
A55B7BB429B6F4410055DE60 /* Ghostty */,
A5B30534299BEAAA0047F10C /* GhosttyApp.swift */,
A5B30534299BEAAA0047F10C /* GhosttyAppController.swift */,
85DE1C912A6A3DCA00493853 /* CustomWindow.swift */,
857F63802A5E64F200CA4815 /* MainMenu.xib */,
A535B9D9299C569B0017E2E4 /* ErrorView.swift */,
A55685DF29A03A9F004303CE /* AppError.swift */,
A59444F629A2ED5200725BBA /* SettingsView.swift */,
A5CEAFFE29C2410700646FDA /* Backport.swift */,
A5FECBD629D1FC3900022361 /* ContentView.swift */,
A5FECBD829D2010400022361 /* WindowAccessor.swift */,
8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */,
852655212A597CA900E4F7AD /* main.swift */,
85102A192A6E32720084AB3E /* WindowService.swift */,
85102A1B2A6E32890084AB3E /* WindowController.swift */,
);
path = Sources;
sourceTree = "<group>";
@@ -204,6 +222,7 @@
A545D1A22A5772CE006E0AE4 /* shell-integration in Resources */,
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */,
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */,
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -214,6 +233,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
85102A1A2A6E32720084AB3E /* WindowService.swift in Sources */,
85DE1C922A6A3DCA00493853 /* CustomWindow.swift in Sources */,
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A5FECBD729D1FC3900022361 /* ContentView.swift in Sources */,
@@ -224,8 +245,11 @@
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */,
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */,
A5B30535299BEAAA0047F10C /* GhosttyAppController.swift in Sources */,
85102A1C2A6E32890084AB3E /* WindowController.swift in Sources */,
852655222A597CA900E4F7AD /* main.swift in Sources */,
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */,
8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */,
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A5B30530299BEAAA0047F10C"
BuildableName = "Ghostty.app"
BlueprintName = "Ghostty"
ReferencedContainer = "container:Ghostty.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A5B30530299BEAAA0047F10C"
BuildableName = "Ghostty.app"
BlueprintName = "Ghostty"
ReferencedContainer = "container:Ghostty.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<AdditionalOptions>
<AdditionalOption
key = "NSZombieEnabled"
value = "YES"
isEnabled = "YES">
</AdditionalOption>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "A5B30530299BEAAA0047F10C"
BuildableName = "Ghostty.app"
BlueprintName = "Ghostty"
ReferencedContainer = "container:Ghostty.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -5,12 +5,24 @@ struct ContentView: View {
let ghostty: Ghostty.AppState
// We need access to our app delegate to know if we're quitting or not.
@EnvironmentObject private var appDelegate: AppDelegate
// Make sure to use `@ObservedObject` so we can keep track of `appDelegate.confirmQuit`.
@ObservedObject var appDelegate: AppDelegate
// We need this to report back up the app controller which surface in this view is focused.
let focusedSurfaceWrapper: FocusedSurfaceWrapper
// We need access to our window to know if we're the key window to determine
// if we show the quit confirmation or not.
@State private var window: NSWindow?
// This handles non-native fullscreen
@State private var fsHandler = FullScreenHandler()
// This seems like a crutch after switchign from SwiftUI to AppKit lifecycle.
@FocusState private var focused: Bool
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
var body: some View {
switch ghostty.readiness {
case .loading:
@@ -35,12 +47,17 @@ struct ContentView: View {
}, set: {
self.appDelegate.confirmQuit = $0
})
Ghostty.TerminalSplit(onClose: Self.closeWindow)
.ghosttyApp(ghostty.app!)
.background(WindowAccessor(window: $window))
.onReceive(gotoTab) { onGotoTab(notification: $0) }
.onReceive(toggleFullscreen) { onToggleFullscreen(notification: $0) }
.focused($focused)
.onAppear { self.focused = true }
.onChange(of: focusedSurface) { newValue in
self.focusedSurfaceWrapper.surface = newValue?.surface
}
.confirmationDialog(
"Quit Ghostty?",
isPresented: confirmQuitting) {
@@ -63,7 +80,7 @@ struct ContentView: View {
guard let currentWindow = NSApp.keyWindow else { return }
currentWindow.close()
}
private func onGotoTab(notification: SwiftUI.Notification) {
// Notification center indiscriminately sends to every subscriber (makes sense)
// but we only want to process this once. In order to process it once lets only
@@ -94,6 +111,8 @@ struct ContentView: View {
guard let window = self.window else { return }
guard window.isKeyWindow else { return }
window.toggleFullScreen(nil)
self.fsHandler.toggleFullscreen(window: window)
// After toggling fullscreen we need to focus the terminal again.
self.focused = true
}
}

View File

@@ -0,0 +1,39 @@
import Cocoa
import SwiftUI
import GhosttyKit
// FocusedSurfaceWrapper is here so that we can pass a reference down
// the view hierarchy and keep track of which surface is focused.
class FocusedSurfaceWrapper {
var surface: ghostty_surface_t?
}
// CustomWindow exists purely so we can override canBecomeKey and canBecomeMain.
// We need that for the non-native fullscreen.
// If we don't use `CustomWindow` we'll get warning messages in the output to say that
// `makeKeyWindow` was called and returned NO.
class CustomWindow: NSWindow {
var focusedSurfaceWrapper: FocusedSurfaceWrapper = FocusedSurfaceWrapper()
static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate) -> CustomWindow {
let window = CustomWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
window.center()
window.contentView = NSHostingView(rootView: ContentView(
ghostty: ghostty,
appDelegate: appDelegate,
focusedSurfaceWrapper: window.focusedSurfaceWrapper))
window.windowController?.shouldCascadeWindows = true
return window
}
override var canBecomeKey: Bool {
return true
}
override var canBecomeMain: Bool {
return true
}
}

View File

@@ -0,0 +1,88 @@
import SwiftUI
class FullScreenHandler {
var previousTabGroup: NSWindowTabGroup?
var previousTabGroupIndex: Int?
var previousContentFrame: NSRect?
var previousStyleMask: NSWindow.StyleMask?
var isInFullscreen: Bool = false
func toggleFullscreen(window: NSWindow) {
if isInFullscreen {
leaveFullscreen(window: window)
isInFullscreen = false
} else {
enterFullscreen(window: window)
isInFullscreen = true
}
}
func enterFullscreen(window: NSWindow) {
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 style mask
previousStyleMask = window.styleMask
// Save previous contentViewFrame and screen
previousContentFrame = window.convertToScreen(contentView.frame)
// Change presentation style to hide menu bar and dock
NSApp.presentationOptions = [.autoHideMenuBar, .autoHideDock]
// Turn it into borderless window
window.styleMask.insert(.borderless)
// This is important: it gives us the full screen, including the
// notch area on MacBooks.
window.styleMask.remove(.titled)
// Set frame to screen size
window.setFrame(screen.frame, display: true)
// Focus window
window.makeKeyAndOrderFront(nil)
}
func leaveFullscreen(window: NSWindow) {
guard let previousFrame = previousContentFrame else { return }
guard let previousStyleMask = previousStyleMask else { return }
// Restore previous style
window.styleMask = previousStyleMask
// Restore previous presentation options
NSApp.presentationOptions = []
// Restore frame
window.setFrame(window.frameRect(forContentRect: previousFrame), display: 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)
}
}

View File

@@ -38,7 +38,7 @@ extension Ghostty {
init() {
// Initialize ghostty global state. This happens once per process.
guard ghostty_init() == GHOSTTY_SUCCESS else {
GhosttyApp.logger.critical("ghostty_init failed")
GhosttyAppController.logger.critical("ghostty_init failed")
readiness = .error
return
}
@@ -68,7 +68,7 @@ extension Ghostty {
// Create the ghostty app.
guard let app = ghostty_app_new(&runtime_cfg, cfg) else {
GhosttyApp.logger.critical("ghostty_app_new failed")
GhosttyAppController.logger.critical("ghostty_app_new failed")
readiness = .error
return
}
@@ -87,7 +87,7 @@ extension Ghostty {
static func reloadConfig() -> ghostty_config_t? {
// Initialize the global configuration.
guard let cfg = ghostty_config_new() else {
GhosttyApp.logger.critical("ghostty_config_new failed")
GhosttyAppController.logger.critical("ghostty_config_new failed")
return nil
}
@@ -189,7 +189,7 @@ extension Ghostty {
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? {
guard let newConfig = AppState.reloadConfig() else {
GhosttyApp.logger.warning("failed to reload configuration")
GhosttyAppController.logger.warning("failed to reload configuration")
return nil
}

View File

@@ -1,197 +0,0 @@
import OSLog
import SwiftUI
import GhosttyKit
@main
struct GhosttyApp: App {
static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: GhosttyApp.self)
)
/// The ghostty global state. Only one per process.
@StateObject private var ghostty = Ghostty.AppState()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
/// The current focused Ghostty surface in this app
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
var body: some Scene {
WindowGroup {
ContentView(ghostty: ghostty)
}
.backport.defaultSize(width: 800, height: 600)
.commands {
CommandGroup(after: .newItem) {
Button("New Tab", action: Self.newTab).keyboardShortcut("t", modifiers: [.command])
Divider()
Button("Split Horizontally", action: splitHorizontally).keyboardShortcut("d", modifiers: [.command])
Button("Split Vertically", action: splitVertically).keyboardShortcut("d", modifiers: [.command, .shift])
Divider()
Button("Close", action: close).keyboardShortcut("w", modifiers: [.command])
Button("Close Window", action: Self.closeWindow).keyboardShortcut("w", modifiers: [.command, .shift])
}
CommandGroup(before: .windowArrangement) {
Divider()
Button("Select Previous Split") { splitMoveFocus(direction: .previous) }
.keyboardShortcut("[", modifiers: .command)
Button("Select Next Split") { splitMoveFocus(direction: .next) }
.keyboardShortcut("]", modifiers: .command)
Menu("Select Split") {
Button("Select Split Above") { splitMoveFocus(direction: .top) }
.keyboardShortcut(.upArrow, modifiers: [.command, .option])
Button("Select Split Below") { splitMoveFocus(direction: .bottom) }
.keyboardShortcut(.downArrow, modifiers: [.command, .option])
Button("Select Split Left") { splitMoveFocus(direction: .left) }
.keyboardShortcut(.leftArrow, modifiers: [.command, .option])
Button("Select Split Right") { splitMoveFocus(direction: .right)}
.keyboardShortcut(.rightArrow, modifiers: [.command, .option])
}
Divider()
}
}
Settings {
SettingsView()
}
}
// Create a new tab in the currently active window
static func newTab() {
guard let currentWindow = NSApp.keyWindow else { return }
guard let windowController = currentWindow.windowController else { return }
windowController.newWindowForTab(nil)
if let newWindow = NSApp.keyWindow, currentWindow != newWindow {
currentWindow.addTabbedWindow(newWindow, ordered: .above)
}
}
static func closeWindow() {
guard let currentWindow = NSApp.keyWindow else { return }
currentWindow.close()
}
func close() {
guard let surfaceView = focusedSurface else {
Self.closeWindow()
return
}
guard let surface = surfaceView.surface else { return }
ghostty.requestClose(surface: surface)
}
func splitHorizontally() {
guard let surfaceView = focusedSurface else { return }
guard let surface = surfaceView.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT)
}
func splitVertically() {
guard let surfaceView = focusedSurface else { return }
guard let surface = surfaceView.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
}
func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
guard let surfaceView = focusedSurface else { return }
guard let surface = surfaceView.surface else { return }
ghostty.splitMoveFocus(surface: surface, direction: direction)
}
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@Published var confirmQuit: Bool = false
// See CursedMenuManager for more information.
private var menuManager: CursedMenuManager?
func applicationDidFinishLaunching(_ notification: Notification) {
UserDefaults.standard.register(defaults: [
// Disable this so that repeated key events make it through to our terminal views.
"ApplePressAndHoldEnabled": false,
])
// Create our menu manager to create some custom menu items that
// we can't create from SwiftUI.
menuManager = CursedMenuManager()
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
let windows = NSApplication.shared.windows
if (windows.isEmpty) { return .terminateNow }
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
// quite work with SwiftUI because windows are retained on close. So instead we check
// if there are any that are visible. I'm guessing this breaks under certain scenarios.
if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow }
// If the user is shutting down, restarting, or logging out, we don't confirm quit.
if let event = NSAppleEventManager.shared().currentAppleEvent {
if let why = event.attributeDescriptor(forKeyword: AEKeyword("why?")!) {
switch (why.typeCodeValue) {
case kAEShutDown:
fallthrough
case kAERestart:
fallthrough
case kAEReallyLogOut:
return .terminateNow
default:
break
}
}
}
// We have some visible window, and all our windows will watch the confirmQuit.
confirmQuit = true
return .terminateLater
}
}
/// SwiftUI as of macOS 13.x provides no way to manage the default menu items that are created
/// as part of a WindowGroup. This class is prefixed with "Cursed" because this is a truly cursed
/// solution to the problem and I think its quite brittle. As soon as SwiftUI supports a better option
/// we should conditionally compile for that when supported.
///
/// The way this works is by setting up KVO on various menu objects and reacting to it. For example,
/// when SwiftUI tries to add a "Close" menu, we intercept it and delete it. Nice try!
private class CursedMenuManager {
var mainToken: NSKeyValueObservation?
var fileToken: NSKeyValueObservation?
init() {
// If the whole menu changed we want to setup our new KVO
self.mainToken = NSApp.observe(\.mainMenu, options: .new) { app, change in
self.onNewMenu()
}
// Initial setup
onNewMenu()
}
private func onNewMenu() {
guard let menu = NSApp.mainMenu else { return }
guard let file = menu.item(withTitle: "File") else { return }
guard let submenu = file.submenu else { return }
fileToken = submenu.observe(\.items) { (_, _) in
let remove = ["Close", "Close All"]
// We look for the items in reverse since we're removing only the
// ones SwiftUI inserts which are at the end. We make replacements
// which we DON'T want deleted.
let items = submenu.items.reversed()
remove.forEach { title in
if let item = items.first(where: { $0.title.caseInsensitiveCompare(title) == .orderedSame }) {
submenu.removeItem(item)
}
}
}
}
}

View File

@@ -0,0 +1,138 @@
import OSLog
import SwiftUI
import AppKit
import GhosttyKit
class GhosttyAppController: NSObject {
@IBOutlet weak fileprivate var mainMenu: NSMenu!
static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: AppDelegate.self)
)
/// The ghostty global state. Only one per process.
var ghostty: Ghostty.AppState = Ghostty.AppState()
/// Manages windows and tabs, ensuring they're allocated/deallocated correctly
var windowService: WindowService!
override init() {
super.init()
// We're initialized through the MainMenu, because we're a referenced objected.
// So when we're here, we initialize the WindowService, which will open first window.
windowService = WindowService(ghostty: self.ghostty)
}
@IBAction func newWindow(_ sender: Any?) {
windowService.addNewWindow()
}
@IBAction func newTab(_ sender: Any?) {
windowService.addNewTab()
}
@IBAction func closeWindow(_ sender: Any) {
guard let currentWindow = NSApp.keyWindow else { return }
currentWindow.close()
}
@IBAction func close(_ sender: Any) {
guard let surface = focusedSurface() else {
self.closeWindow(self)
return
}
ghostty.requestClose(surface: surface)
}
private func focusedSurface() -> ghostty_surface_t? {
guard let window = NSApp.keyWindow as? CustomWindow else { return nil }
return window.focusedSurfaceWrapper.surface
}
@IBAction func splitHorizontally(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT)
}
@IBAction func splitVertically(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
}
@IBAction func splitMoveFocusPrevious(_ sender: Any) {
splitMoveFocus(direction: .previous)
}
@IBAction func splitMoveFocusNext(_ sender: Any) {
splitMoveFocus(direction: .next)
}
@IBAction func splitMoveFocusAbove(_ sender: Any) {
splitMoveFocus(direction: .top)
}
@IBAction func splitMoveFocusBelow(_ sender: Any) {
splitMoveFocus(direction: .bottom)
}
@IBAction func splitMoveFocusLeft(_ sender: Any) {
splitMoveFocus(direction: .left)
}
@IBAction func splitMoveFocusRight(_ sender: Any) {
splitMoveFocus(direction: .right)
}
func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
guard let surface = focusedSurface() else { return }
ghostty.splitMoveFocus(surface: surface, direction: direction)
}
}
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
// confirmQuit published so other views can check whether quit needs to be confirmed.
@Published var confirmQuit: Bool = false
func applicationDidFinishLaunching(_ notification: Notification) {
UserDefaults.standard.register(defaults: [
// Disable this so that repeated key events make it through to our terminal views.
"ApplePressAndHoldEnabled": false,
])
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
let windows = NSApplication.shared.windows
if (windows.isEmpty) { return .terminateNow }
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
// quite work with SwiftUI because windows are retained on close. So instead we check
// if there are any that are visible. I'm guessing this breaks under certain scenarios.
if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow }
// If the user is shutting down, restarting, or logging out, we don't confirm quit.
if let event = NSAppleEventManager.shared().currentAppleEvent {
if let why = event.attributeDescriptor(forKeyword: AEKeyword("why?")!) {
switch (why.typeCodeValue) {
case kAEShutDown:
fallthrough
case kAERestart:
fallthrough
case kAEReallyLogOut:
return .terminateNow
default:
break
}
}
}
// We have some visible window, and all our windows will watch the confirmQuit.
confirmQuit = true
return .terminateLater
}
}

180
macos/Sources/MainMenu.xib Normal file
View File

@@ -0,0 +1,180 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="21701" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21701"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication"/>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
<menu title="Main Menu" systemMenu="main" id="AYu-sK-qS6">
<items>
<menuItem title="NewApplication" id="1Xt-HY-uBw">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="NewApplication" systemMenu="apple" id="uQy-DD-JDr">
<items>
<menuItem title="About Ghostty" id="5kV-Vb-QxS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="orderFrontStandardAboutPanel:" target="-1" id="Exp-CZ-Vem"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="VOq-y0-SEH"/>
<menuItem title="Preferences…" keyEquivalent="," id="BOF-NM-1cW"/>
<menuItem isSeparatorItem="YES" id="4je-JR-u6R"/>
<menuItem title="Hide Ghostty" keyEquivalent="h" id="Olw-nP-bQN">
<connections>
<action selector="hide:" target="-1" id="PnN-Uc-m68"/>
</connections>
</menuItem>
<menuItem title="Hide Others" keyEquivalent="h" id="Vdr-fp-XzO">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="hideOtherApplications:" target="-1" id="VT4-aY-XCT"/>
</connections>
</menuItem>
<menuItem title="Show All" id="Kd2-mp-pUS">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="unhideAllApplications:" target="-1" id="Dhg-Le-xox"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kCx-OE-vgT"/>
<menuItem title="Quit NewApplication" keyEquivalent="q" id="4sb-4s-VLi">
<connections>
<action selector="terminate:" target="-1" id="Te7-pn-YzF"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="File" id="dMs-cI-mzQ">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="File" id="bib-Uj-vzu">
<items>
<menuItem title="New Window" keyEquivalent="n" id="Was-JA-tGl">
<connections>
<action selector="newWindow:" target="IUl-M9-b48" id="hDE-pE-3Ml"/>
</connections>
</menuItem>
<menuItem title="New Tab" keyEquivalent="t" id="uTG-Vz-hJU">
<connections>
<action selector="newTab:" target="IUl-M9-b48" id="MHd-lY-6H5"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="m54-Is-iLE"/>
<menuItem title="Split Horizontally" keyEquivalent="d" id="VUR-Ld-nLx">
<connections>
<action selector="splitHorizontally:" target="IUl-M9-b48" id="0y5-Ge-OF5"/>
</connections>
</menuItem>
<menuItem title="Split Vertically" keyEquivalent="D" id="UDZ-4y-6xL">
<connections>
<action selector="splitVertically:" target="IUl-M9-b48" id="QZ1-5M-OQG"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="sjq-M1-UGS"/>
<menuItem title="Close" keyEquivalent="w" id="DVo-aG-piG">
<connections>
<action selector="close:" target="IUl-M9-b48" id="Cc3-qR-k0Z"/>
</connections>
</menuItem>
<menuItem title="Close Window" keyEquivalent="W" id="W5w-UZ-crk">
<connections>
<action selector="closeWindow:" target="IUl-M9-b48" id="Yaz-fy-DFE"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Window" id="aUF-d1-5bR">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Window" systemMenu="window" id="Td7-aD-5lo">
<items>
<menuItem title="Minimize" keyEquivalent="m" id="OY7-WF-poV">
<connections>
<action selector="performMiniaturize:" target="-1" id="VwT-WD-YPe"/>
</connections>
</menuItem>
<menuItem title="Zoom" id="R4o-n2-Eq4">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="performZoom:" target="-1" id="DIl-cC-cCs"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="eu3-7i-yIM"/>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="arrangeInFront:" target="-1" id="DRN-fu-gQh"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="rlu-tP-x0P"/>
<menuItem title="Select Previous Split" keyEquivalent="[" id="Lic-px-1wg">
<connections>
<action selector="splitMoveFocusPrevious:" target="IUl-M9-b48" id="d37-lc-L2w"/>
</connections>
</menuItem>
<menuItem title="Select Next Split" keyEquivalent="]" id="bD7-ei-wKU">
<connections>
<action selector="splitMoveFocusNext:" target="IUl-M9-b48" id="eJ4-vo-aSM"/>
</connections>
</menuItem>
<menuItem title="Select Split" id="dos-9S-LXC">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Select Split" id="8tg-60-ZSU">
<items>
<menuItem title="Select Split Above" keyEquivalent="" id="0yU-hC-8xF">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="splitMoveFocusAbove:" target="IUl-M9-b48" id="Ngp-ty-rtO"/>
</connections>
</menuItem>
<menuItem title="Select Split Below" keyEquivalent="" id="QDz-d9-CBr">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="splitMoveFocusBelow:" target="IUl-M9-b48" id="NZF-SR-DRF"/>
</connections>
</menuItem>
<menuItem title="Select Split Left" keyEquivalent="" id="cTK-oy-KuV">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="splitMoveFocusLeft:" target="IUl-M9-b48" id="lqR-BO-6Xc"/>
</connections>
</menuItem>
<menuItem title="Select Split Right" keyEquivalent="" id="upj-mc-L7X">
<modifierMask key="keyEquivalentModifierMask" option="YES" command="YES"/>
<connections>
<action selector="splitMoveFocusRight:" target="IUl-M9-b48" id="gjS-dq-5ll"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Help" id="wpr-3q-Mcd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Help" systemMenu="help" id="F2S-fz-NVQ">
<items>
<menuItem title="Ghostty Help" keyEquivalent="?" id="FKE-Sm-Kum">
<connections>
<action selector="showHelp:" target="-1" id="y7X-2Q-9no"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
</items>
<point key="canvasLocation" x="139" y="154"/>
</menu>
<customObject id="IUl-M9-b48" customClass="GhosttyAppController" customModule="Ghostty" customModuleProvider="target">
<connections>
<outlet property="mainMenu" destination="AYu-sK-qS6" id="VpF-hi-cLE"/>
</connections>
</customObject>
</objects>
</document>

View File

@@ -0,0 +1,11 @@
import Cocoa
class WindowController: NSWindowController {
static var lastCascadePoint = NSPoint(x: 0, y: 0)
static func create(ghosttyApp: Ghostty.AppState, appDelegate: AppDelegate) -> WindowController {
let window = CustomWindow.create(ghostty: ghosttyApp, appDelegate: appDelegate)
lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint)
return WindowController(window: window)
}
}

View File

@@ -0,0 +1,80 @@
import Cocoa
import Combine
// WindowService manages the windows and tabs in the application.
// It keeps references to windows and cleans them up when they're cloned.
//
// It is based on the patterns presented in this blog post:
// https://christiantietze.de/posts/2019/07/nswindow-tabbing-multiple-nswindowcontroller/
class WindowService {
struct ManagedWindow {
let windowController: NSWindowController
let window: NSWindow
let closePublisher: AnyCancellable
}
private var ghostty: Ghostty.AppState
private var managedWindows: [ManagedWindow] = []
init(ghostty: Ghostty.AppState) {
self.ghostty = ghostty
addInitialWindow()
}
private func addInitialWindow() {
guard let controller = createWindowController() else { return }
controller.showWindow(self)
let result = addManagedWindow(windowController: controller)
if result == nil {
preconditionFailure("Failed to create initial window")
}
}
func addNewWindow() {
guard let controller = createWindowController() else { return }
guard let newWindow = addManagedWindow(windowController: controller)?.window else { return }
newWindow.makeKeyAndOrderFront(nil)
}
func addNewTab() {
guard let existingWindow = mainWindow() else { return }
guard let controller = createWindowController() else { return }
guard let newWindow = addManagedWindow(windowController: controller)?.window else { return }
existingWindow.addTabbedWindow(newWindow, ordered: .above)
newWindow.makeKeyAndOrderFront(nil)
}
/// Returns the main window of the managed window stack.
/// Falls back the first element if no window is main.
private func mainWindow() -> NSWindow? {
let mainManagedWindow = managedWindows.first { $0.window.isMainWindow }
return (mainManagedWindow ?? managedWindows.first).map { $0.window }
}
private func createWindowController() -> WindowController? {
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil }
return WindowController.create(ghosttyApp: self.ghostty, appDelegate: appDelegate)
}
private func addManagedWindow(windowController: WindowController) -> ManagedWindow? {
guard let window = windowController.window else { return nil }
let pubClose = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window)
.sink { notification in
guard let window = notification.object as? NSWindow else { return }
self.removeWindow(window: window)
}
let managed = ManagedWindow(windowController: windowController, window: window, closePublisher: pubClose)
managedWindows.append(managed)
return managed
}
private func removeWindow(window: NSWindow) {
self.managedWindows.removeAll(where: { $0.window === window })
}
}

7
macos/Sources/main.swift Normal file
View File

@@ -0,0 +1,7 @@
import AppKit
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)