Merge pull request #772 from mitchellh/macos-confirm-quit

macos: refactor main terminal window, split state, fix some bugs
This commit is contained in:
Mitchell Hashimoto
2023-10-31 09:42:28 -07:00
committed by GitHub
23 changed files with 1115 additions and 1007 deletions

View File

@ -396,6 +396,7 @@ ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*);
void ghostty_surface_free(ghostty_surface_t);
ghostty_app_t ghostty_surface_app(ghostty_surface_t);
bool ghostty_surface_transparent(ghostty_surface_t);
bool ghostty_surface_needs_confirm_quit(ghostty_surface_t);
void ghostty_surface_refresh(ghostty_surface_t);
void ghostty_surface_set_content_scale(ghostty_surface_t, double, double);
void ghostty_surface_set_focus(ghostty_surface_t, bool);

View File

@ -8,24 +8,27 @@
/* Begin PBXBuildFile section */
8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */; };
85102A1C2A6E32890084AB3E /* PrimaryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85102A1B2A6E32890084AB3E /* PrimaryWindowController.swift */; };
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DE1C912A6A3DCA00493853 /* PrimaryWindow.swift */; };
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */; };
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */; };
A53426392A7DC55C00EBB7A2 /* PrimaryWindowManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426382A7DC55C00EBB7A2 /* PrimaryWindowManager.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 */; };
A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB529B6F47F0055DE60 /* AppState.swift */; };
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; };
A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */; };
A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; };
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; };
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */; };
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
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 */; };
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; };
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; };
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; };
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */; };
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */; };
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; };
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; };
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; };
@ -37,29 +40,31 @@
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; };
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; };
A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; };
A5FECBD729D1FC3900022361 /* PrimaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FECBD629D1FC3900022361 /* PrimaryView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenHandler.swift; sourceTree = "<group>"; };
85102A1B2A6E32890084AB3E /* PrimaryWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindowController.swift; sourceTree = "<group>"; };
857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
85DE1C912A6A3DCA00493853 /* PrimaryWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindow.swift; sourceTree = "<group>"; };
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Input.swift; sourceTree = "<group>"; };
A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
A53426382A7DC55C00EBB7A2 /* PrimaryWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindowManager.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>"; };
A55B7BB529B6F47F0055DE60 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; };
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = "<group>"; };
A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitView.swift; sourceTree = "<group>"; };
A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; };
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Shell.swift; sourceTree = "<group>"; };
A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = "<group>"; };
A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; 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>"; };
A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = "<group>"; };
A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = "<group>"; };
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = "<group>"; };
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = "<group>"; };
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = "<group>"; };
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = "<group>"; };
A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = "<group>"; };
@ -74,7 +79,6 @@
A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
A5FECBD629D1FC3900022361 /* PrimaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -94,29 +98,18 @@
isa = PBXGroup;
children = (
A56D58872ACDE6BE00508D2C /* Services */,
A53426372A7DC53A00EBB7A2 /* Primary Window */,
A59630982AEE1C4400D64628 /* Terminal */,
A534263E2A7DCC5800EBB7A2 /* Settings */,
);
path = Features;
sourceTree = "<group>";
};
A53426372A7DC53A00EBB7A2 /* Primary Window */ = {
isa = PBXGroup;
children = (
A53426382A7DC55C00EBB7A2 /* PrimaryWindowManager.swift */,
85102A1B2A6E32890084AB3E /* PrimaryWindowController.swift */,
85DE1C912A6A3DCA00493853 /* PrimaryWindow.swift */,
A5FECBD629D1FC3900022361 /* PrimaryView.swift */,
A535B9D9299C569B0017E2E4 /* ErrorView.swift */,
);
path = "Primary Window";
sourceTree = "<group>";
};
A534263D2A7DCBB000EBB7A2 /* Helpers */ = {
isa = PBXGroup;
children = (
A5CEAFFE29C2410700646FDA /* Backport.swift */,
8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */,
A59630962AEE163600D64628 /* HostingWindow.swift */,
A59FB5D02AE0DEA7009128F3 /* MetalView.swift */,
A5CEAFDA29B8005900646FDA /* SplitView */,
);
@ -156,7 +149,8 @@
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */,
A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */,
A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */,
A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */,
A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */,
A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */,
A55685DF29A03A9F004303CE /* AppError.swift */,
);
path = Ghostty;
@ -170,6 +164,18 @@
path = Services;
sourceTree = "<group>";
};
A59630982AEE1C4400D64628 /* Terminal */ = {
isa = PBXGroup;
children = (
A59630992AEE1C6400D64628 /* Terminal.xib */,
A596309F2AEF6AEB00D64628 /* TerminalManager.swift */,
A596309B2AEE1C9E00D64628 /* TerminalController.swift */,
A596309D2AEE1D6C00D64628 /* TerminalView.swift */,
A535B9D9299C569B0017E2E4 /* ErrorView.swift */,
);
path = Terminal;
sourceTree = "<group>";
};
A5A1F8862A489D7400D1E8BC /* Resources */ = {
isa = PBXGroup;
children = (
@ -277,6 +283,7 @@
buildActionMask = 2147483647;
files = (
A545D1A22A5772CE006E0AE4 /* shell-integration in Resources */,
A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */,
A5A1F8852A489D6800D1E8BC /* terminfo in Resources */,
A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */,
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */,
@ -291,29 +298,30 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
A53426392A7DC55C00EBB7A2 /* PrimaryWindowManager.swift in Sources */,
85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */,
A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */,
A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */,
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */,
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */,
A5FECBD729D1FC3900022361 /* PrimaryView.swift in Sources */,
A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */,
A5FEB3002ABB69450068369E /* main.swift in Sources */,
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */,
A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */,
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
85102A1C2A6E32890084AB3E /* PrimaryWindowController.swift in Sources */,
A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */,
8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */,
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */,
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@ -42,16 +42,16 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
private var dockMenu: NSMenu = NSMenu()
/// The ghostty global state. Only one per process.
private var ghostty: Ghostty.AppState = Ghostty.AppState()
private let ghostty: Ghostty.AppState = Ghostty.AppState()
/// Manages windows and tabs, ensuring they're allocated/deallocated correctly
var windowManager: PrimaryWindowManager!
/// Manages our terminal windows.
let terminalManager: TerminalManager
override init() {
self.terminalManager = TerminalManager(ghostty)
super.init()
ghostty.delegate = self
windowManager = PrimaryWindowManager(ghostty: self.ghostty)
}
//MARK: - NSApplicationDelegate
@ -73,7 +73,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
// Let's launch our first window.
// TODO: we should detect if we restored windows and if so not launch a new window.
windowManager.addInitialWindow()
terminalManager.newWindow()
// Initial config loading
configDidReload(ghostty)
@ -147,7 +147,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
guard !flag else { return true }
// No visible windows, open a new one.
windowManager.newWindow()
terminalManager.newWindow()
return false
}
@ -170,16 +170,9 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
// Build our config
var config = Ghostty.SurfaceConfiguration()
config.workingDirectory = filename
// If we don't have a window open through the window manager, we launch
// a new window.
guard let mainWindow = windowManager.mainWindow else {
windowManager.addNewWindow(withBaseConfig: config)
return true
}
// Add a new tab
windowManager.addNewTab(to: mainWindow, withBaseConfig: config)
// Add a new tab or create a new window
terminalManager.newTab(withBaseConfig: config)
return true
}
@ -240,13 +233,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
}
private func focusedSurface() -> ghostty_surface_t? {
guard let window = NSApp.keyWindow as? PrimaryWindow else { return nil }
return window.focusedSurfaceWrapper.surface
}
private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
guard let surface = focusedSurface() else { return }
ghostty.splitMoveFocus(surface: surface, direction: direction)
return terminalManager.focusedSurface?.surface
}
//MARK: - GhosttyAppStateDelegate
@ -254,15 +241,15 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
func configDidReload(_ state: Ghostty.AppState) {
// Config could change keybindings, so update everything that depends on that
syncMenuShortcuts()
windowManager.relabelTabs()
terminalManager.relabelAllTabs()
// Config could change window appearance
syncAppearance()
// If we have configuration errors, we need to show them.
let c = ConfigurationErrorsController.sharedInstance
c.model.errors = state.configErrors()
if (c.model.errors.count > 0) {
c.errors = state.configErrors()
if (c.errors.count > 0) {
if (c.window == nil || !c.window!.isVisible) {
c.showWindow(self)
}
@ -304,7 +291,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
}
@IBAction func newWindow(_ sender: Any?) {
windowManager.newWindow()
terminalManager.newWindow()
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
@ -312,93 +299,15 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
}
@IBAction func newTab(_ sender: Any?) {
windowManager.newTab()
terminalManager.newTab()
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
NSApp.activate(ignoringOtherApps: true)
}
@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)
}
@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 splitZoom(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.splitToggleZoom(surface: surface)
}
@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)
}
@IBAction func showHelp(_ sender: Any) {
guard let url = URL(string: "https://github.com/mitchellh/ghostty") else { return }
NSWorkspace.shared.open(url)
}
@IBAction func toggleFullScreen(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.toggleFullscreen(surface: surface)
}
@IBAction func increaseFontSize(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.changeFontSize(surface: surface, .increase(1))
}
@IBAction func decreaseFontSize(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.changeFontSize(surface: surface, .decrease(1))
}
@IBAction func resetFontSize(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.changeFontSize(surface: surface, .reset)
}
@IBAction func toggleTerminalInspector(_ sender: Any) {
guard let surface = focusedSurface() else { return }
ghostty.toggleTerminalInspector(surface: surface)
}
}

View File

@ -1,189 +0,0 @@
import SwiftUI
import GhosttyKit
struct PrimaryView: View {
@ObservedObject var ghostty: Ghostty.AppState
// We need access to our app delegate to know if we're quitting or not.
// 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
// If this is set, this is the base configuration that we build our surface out of.
let baseConfig: Ghostty.SurfaceConfiguration?
// We need access to our window to know if we're the key window and to
// modify window properties in response to events from the surface (e.g.
// updating the window title)
var window: NSWindow
// This handles non-native fullscreen
@State private var fullScreen = FullScreenHandler()
// This seems like a crutch after switching from SwiftUI to AppKit lifecycle.
@FocusState private var focused: Bool
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle
@FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
// The title for our window
private var title: String {
var title = "👻"
if let surfaceTitle = surfaceTitle {
if (surfaceTitle.count > 0) {
title = surfaceTitle
}
}
if let zoomedSplit = zoomedSplit {
if zoomedSplit {
title = "🔍 " + title
}
}
return title
}
var body: some View {
switch ghostty.readiness {
case .loading:
Text("Loading")
case .error:
ErrorView()
case .ready:
let center = NotificationCenter.default
let gotoTab = center.publisher(for: Ghostty.Notification.ghosttyGotoTab)
let toggleFullscreen = center.publisher(for: Ghostty.Notification.ghosttyToggleFullscreen)
VStack(spacing: 0) {
// If we're running in debug mode we show a warning so that users
// know that performance will be degraded.
if (ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG) {
DebugBuildWarningView()
}
Ghostty.TerminalSplit(onClose: Self.closeWindow, baseConfig: self.baseConfig)
.ghosttyApp(ghostty.app!)
.ghosttyConfig(ghostty.config!)
.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
}
.onChange(of: title) { newValue in
// We need to handle this manually because we are using AppKit lifecycle
// so navigationTitle no longer works.
self.window.title = newValue
}
.onChange(of: cellSize) { newValue in
if !ghostty.windowStepResize { return }
guard let size = newValue else { return }
self.window.contentResizeIncrements = size
}
}
}
}
static func closeWindow() {
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
// handle it if we're the focused window.
guard self.window.isKeyWindow else { return }
// Get the tab index from the notification
guard let tabIndexAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
guard let tabIndex = tabIndexAny as? Int32 else { return }
guard let windowController = window.windowController else { return }
guard let tabGroup = windowController.window?.tabGroup else { return }
let tabbedWindows = tabGroup.windows
// This will be the index we want to actual go to
let finalIndex: Int
// An index that is invalid is used to signal some special values.
if (tabIndex <= 0) {
guard let selectedWindow = tabGroup.selectedWindow else { return }
guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return }
if (tabIndex == GHOSTTY_TAB_PREVIOUS.rawValue) {
finalIndex = selectedIndex - 1
} else if (tabIndex == GHOSTTY_TAB_NEXT.rawValue) {
finalIndex = selectedIndex + 1
} else {
return
}
} else {
// Tabs are 0-indexed here, so we subtract one from the key the user hit.
finalIndex = Int(tabIndex - 1)
}
guard finalIndex >= 0 && finalIndex < tabbedWindows.count else { return }
let targetWindow = tabbedWindows[finalIndex]
targetWindow.makeKeyAndOrderFront(nil)
}
private func onToggleFullscreen(notification: SwiftUI.Notification) {
// Just like in `onGotoTab`, we might receive this multiple times. But
// it's fine, because `toggleFullscreen` should only apply to the
// currently focused window.
guard self.window.isKeyWindow else { return }
// Check whether we use non-native fullscreen
guard let useNonNativeFullscreenAny = notification.userInfo?[Ghostty.Notification.NonNativeFullscreenKey] else { return }
guard let useNonNativeFullscreen = useNonNativeFullscreenAny as? ghostty_non_native_fullscreen_e else { return }
self.fullScreen.toggleFullscreen(window: window, nonNativeFullscreen: useNonNativeFullscreen)
// After toggling fullscreen we need to focus the terminal again.
self.focused = true
// For some reason focus always gets moved to the first split when
// toggling fullscreen, so we set it back to the correct one.
if let focusedSurface {
Ghostty.moveFocus(to: focusedSurface)
}
}
}
struct DebugBuildWarningView: View {
@State private var isPopover = false
var body: some View {
HStack {
Spacer()
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.yellow)
Text("You're running a debug build of Ghostty! Performance will be degraded.")
.padding(.all, 8)
.popover(isPresented: $isPopover, arrowEdge: .bottom) {
Text("""
Debug builds of Ghostty are very slow and you may experience
performance problems. Debug builds are only recommended during
development.
""")
.padding(.all)
}
Spacer()
}
.background(Color(.windowBackgroundColor))
.frame(maxWidth: .infinity)
.onTapGesture {
isPopover = true
}
}
}

View File

@ -1,65 +0,0 @@
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?
}
// PrimaryWindow is the primary window you'd associate with a terminal: the window
// that contains one or more terminals (splits, and such).
//
// We need to subclass NSWindow so that we can override some methods for features
// such as non-native fullscreen.
class PrimaryWindow: NSWindow {
var focusedSurfaceWrapper: FocusedSurfaceWrapper = FocusedSurfaceWrapper()
override var canBecomeKey: Bool {
return true
}
override var canBecomeMain: Bool {
return true
}
static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate, baseConfig: Ghostty.SurfaceConfiguration? = nil) -> PrimaryWindow {
let window = PrimaryWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: getStyleMask(renderDecoration: ghostty.windowDecorations),
backing: .buffered,
defer: false)
window.center()
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in thie GitHub issue: https://github.com/mitchellh/ghostty/pull/376
window.colorSpace = NSColorSpace.sRGB
window.contentView = NSHostingView(rootView: PrimaryView(
ghostty: ghostty,
appDelegate: appDelegate,
focusedSurfaceWrapper: window.focusedSurfaceWrapper,
baseConfig: baseConfig,
window: window
))
// We do want to cascade when new windows are created
window.windowController?.shouldCascadeWindows = true
// A default title. This should be overwritten quickly by the Ghostty core.
window.title = "Ghostty 👻"
return window
}
static func getStyleMask(renderDecoration: Bool) -> NSWindow.StyleMask {
var mask: NSWindow.StyleMask = [.resizable, .closable, .miniaturizable]
if renderDecoration {
mask.insert(.titled)
}
return mask
}
}

View File

@ -1,38 +0,0 @@
import Cocoa
class PrimaryWindowController: NSWindowController, NSWindowDelegate {
// This is used to programmatically control tabs.
weak var windowManager: PrimaryWindowManager?
// This should be set to true once a surface has been initialized once.
var didInitializeFromSurface: Bool = false
// This is required for the "+" button to show up in the tab bar to add a
// new tab.
override func newWindowForTab(_ sender: Any?) {
guard let window = self.window as? PrimaryWindow else { preconditionFailure("Expected window to be loaded") }
guard let manager = self.windowManager else { return }
manager.triggerNewTab(for: window)
}
deinit {
// I don't know if this is the right place, but because of WindowAccessor in our
// SwiftUI hierarchy, we have a reference cycle between view and window and windows
// are never freed. When the window is closed, the window controller is deinitialized,
// so we can use this opportunity detach the view from the window and break the cycle.
if let window = self.window {
window.contentView = nil
}
}
func windowDidBecomeKey(_ notification: Notification) {
self.windowManager?.relabelTabs()
}
func windowWillClose(_ notification: Notification) {
// Tabs must be relabeled when a window is closed because this event
// does not fire the "windowDidBecomeKey" event on the newly focused
// window
self.windowManager?.relabelTabs()
}
}

View File

@ -1,223 +0,0 @@
import Cocoa
import Combine
import GhosttyKit
import SwiftUI
// PrimaryWindowManager manages the windows and tabs in the primary window
// of the application. It keeps references to windows and cleans them up when
// they're cloned.
//
// If we ever have multiple tabbed window types we can make this generic but
// right now only our primary window is ever duplicated or tabbed so we're not
// doing that.
//
// It is based on the patterns presented in this blog post:
// https://christiantietze.de/posts/2019/07/nswindow-tabbing-multiple-nswindowcontroller/
class PrimaryWindowManager {
struct ManagedWindow {
let windowController: NSWindowController
let window: NSWindow
let closePublisher: AnyCancellable
}
// Keep track of the last point that our window was launched at so that new
// windows "cascade" over each other and don't just launch directly on top
// of each other.
static var lastCascadePoint = NSPoint(x: 0, y: 0)
/// Returns the main window of the managed window stack.
/// Falls back the first element if no window is main. Note that this would
/// likely be an internal inconsistency we gracefully handle here.
var mainWindow: NSWindow? {
let mainManagedWindow = managedWindows
.first { $0.window.isMainWindow }
return (mainManagedWindow ?? managedWindows.first)
.map { $0.window }
}
private var ghostty: Ghostty.AppState
private var managedWindows: [ManagedWindow] = []
init(ghostty: Ghostty.AppState) {
self.ghostty = ghostty
// Register self as observer for the NewTab/NewWindow notifications that
// are triggered via callback from Zig code.
let center = NotificationCenter.default;
center.addObserver(
self,
selector: #selector(onNewTab),
name: Ghostty.Notification.ghosttyNewTab,
object: nil)
center.addObserver(
self,
selector: #selector(onNewWindow),
name: Ghostty.Notification.ghosttyNewWindow,
object: nil)
}
deinit {
// Clean up the observers.
let center = NotificationCenter.default;
center.removeObserver(
self,
name: Ghostty.Notification.ghosttyNewTab,
object: nil)
center.removeObserver(
self,
name: Ghostty.Notification.ghosttyNewWindow,
object: nil)
}
/// Add the initial window for the application. This should only be called once from the AppDelegate.
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 newWindow() {
if let window = mainWindow as? PrimaryWindow {
// If we already have a window, we go through Zig core code, which calls back into Swift.
self.triggerNewWindow(withParent: window)
} else {
self.addNewWindow()
}
}
func triggerNewWindow(withParent window: PrimaryWindow) {
guard let surface = window.focusedSurfaceWrapper.surface else { return }
ghostty.newWindow(surface: surface)
}
func addNewWindow(withBaseConfig config: Ghostty.SurfaceConfiguration? = nil) {
guard let controller = createWindowController(withBaseConfig: config) else { return }
// For new windows, explicitly disallow tabbing with other windows.
// This overrides the value of userTabbingPreference. Rationale:
// Ghostty explicitly provides both "New Tab" and "New Window"
// functionality, so there's no reason to make "New Window" open in a
// tab.
controller.window?.tabbingMode = .disallowed;
controller.showWindow(self)
guard let newWindow = addManagedWindow(windowController: controller)?.window else { return }
newWindow.makeKeyAndOrderFront(nil)
}
@objc private func onNewWindow(notification: SwiftUI.Notification) {
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
self.addNewWindow(withBaseConfig: config)
}
// triggerNewTab tells the Zig core code to create a new tab, which then calls
// back into Swift code.
func triggerNewTab(for window: PrimaryWindow) {
guard let surface = window.focusedSurfaceWrapper.surface else { return }
ghostty.newTab(surface: surface)
}
func newTab() {
if let window = mainWindow as? PrimaryWindow {
self.triggerNewTab(for: window)
} else {
self.addNewWindow()
}
}
@objc private func onNewTab(notification: SwiftUI.Notification) {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard let window = surfaceView.window else { return }
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
self.addNewTab(to: window, withBaseConfig: config)
}
func addNewTab(to window: NSWindow, withBaseConfig config: Ghostty.SurfaceConfiguration? = nil) {
guard let controller = createWindowController(withBaseConfig: config, cascade: false) else { return }
guard let newWindow = addManagedWindow(windowController: controller)?.window else { return }
window.addTabbedWindow(newWindow, ordered: .above)
newWindow.makeKeyAndOrderFront(nil)
}
private func createWindowController(withBaseConfig config: Ghostty.SurfaceConfiguration? = nil, cascade: Bool = true) -> PrimaryWindowController? {
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil }
let window = PrimaryWindow.create(ghostty: ghostty, appDelegate: appDelegate, baseConfig: config)
if (cascade) {
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
}
let controller = PrimaryWindowController(window: window)
controller.windowManager = self
return controller
}
private func addManagedWindow(windowController: PrimaryWindowController) -> 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)
window.delegate = windowController
return managed
}
private func removeWindow(window: NSWindow) {
self.managedWindows.removeAll(where: { $0.window === window })
// If we remove a window, we reset the cascade point to the key window so that
// the next window cascade's from that one.
if let focusedWindow = NSApplication.shared.keyWindow {
// If we are NOT the focused window, then we are a tabbed window. If we
// are closing a tabbed window, we want to set the cascade point to be
// the next cascade point from this window.
if focusedWindow != window {
Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
return
}
// If we are the focused window, then we set the last cascade point to
// our own frame so that it shows up in the same spot.
let frame = focusedWindow.frame
Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY)
}
}
/// Update the accessory view of each tab according to the keyboard
/// shortcut that activates it (if any). This is called when the key window
/// changes and when a window is closed.
func relabelTabs() {
guard let windows = self.mainWindow?.tabbedWindows else { return }
guard let cfg = ghostty.config else { return }
for (index, window) in windows.enumerated().prefix(9) {
let action = "goto_tab:\(index + 1)"
let trigger = ghostty_config_trigger(cfg, action, UInt(action.count))
guard let equiv = Ghostty.keyEquivalentLabel(key: trigger.key, mods: trigger.mods) else {
continue
}
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.labelFont(ofSize: 0),
.foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
]
let attributedString = NSAttributedString(string: " \(equiv) ", attributes: attributes)
let text = NSTextField(labelWithAttributedString: attributedString)
window.tab.accessoryView = text
}
}
}

View File

@ -39,7 +39,7 @@ class ServiceProvider: NSObject {
private func openTerminal(_ path: String, target: OpenTarget) {
guard let delegateRaw = NSApp.delegate else { return }
guard let delegate = delegateRaw as? AppDelegate else { return }
guard let windowManager = delegate.windowManager else { return }
let terminalManager = delegate.terminalManager
// We only open in directories.
var isDirectory = ObjCBool(true)
@ -49,20 +49,13 @@ class ServiceProvider: NSObject {
// Build our config
var config = Ghostty.SurfaceConfiguration()
config.workingDirectory = path
// If we don't have a window open through the window manager, we launch
// a new window even if they requested a tab.
guard let mainWindow = windowManager.mainWindow else {
windowManager.addNewWindow(withBaseConfig: config)
return
}
switch (target) {
case .window:
windowManager.addNewWindow(withBaseConfig: config)
terminalManager.newWindow(withBaseConfig: config)
case .tab:
windowManager.addNewTab(to: mainWindow, withBaseConfig: config)
terminalManager.newTab(withBaseConfig: config)
}
}
}

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22154" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22155" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22154"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22155"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>

View File

@ -3,43 +3,31 @@ import Cocoa
import SwiftUI
import Combine
class ConfigurationErrorsController: NSWindowController, NSWindowDelegate {
class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, ConfigurationErrorsViewModel {
/// Singleton for the errors view.
static let sharedInstance = ConfigurationErrorsController()
override var windowNibName: NSNib.Name? { "ConfigurationErrors" }
/// The data model for this view. Update this directly and the associated view will be updated, too.
let model = ConfigurationErrorsView.Model()
private var cancellable: AnyCancellable?
@Published var errors: [String] = [] {
didSet {
if (errors.count == 0) {
self.window?.performClose(nil)
}
}
}
//MARK: - NSWindowController
override func windowWillLoad() {
shouldCascadeWindows = false
if let c = cancellable { c.cancel() }
cancellable = model.$errors.sink { newValue in
if (newValue.count == 0) {
self.window?.close()
}
}
}
override func windowDidLoad() {
guard let window = window else { return }
window.center()
window.level = .popUpMenu
window.contentView = NSHostingView(rootView: ConfigurationErrorsView(model: model))
}
//MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
if let cancellable = cancellable {
cancellable.cancel()
self.cancellable = nil
}
window.contentView = NSHostingView(rootView: ConfigurationErrorsView(model: self))
}
}

View File

@ -1,11 +1,11 @@
import SwiftUI
struct ConfigurationErrorsView: View {
class Model: ObservableObject {
@Published var errors: [String] = []
}
@ObservedObject var model: Model
protocol ConfigurationErrorsViewModel: ObservableObject {
var errors: [String] { get set }
}
struct ConfigurationErrorsView<ViewModel: ConfigurationErrorsViewModel>: View {
@ObservedObject var model: ViewModel
var body: some View {
VStack {

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="22155" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="22155"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="TerminalController" customModule="Ghostty" customModuleProvider="target">
<connections>
<outlet property="window" destination="QvC-M9-y7g" id="cg9-Ep-qHg"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<window title="👻 Ghostty" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" animationBehavior="default" id="QvC-M9-y7g">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
<rect key="screenRect" x="0.0" y="0.0" width="3008" height="1667"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
<autoresizingMask key="autoresizingMask"/>
</view>
<connections>
<outlet property="delegate" destination="-2" id="tG2-b7-nb8"/>
</connections>
<point key="canvasLocation" x="132" y="-82"/>
</window>
</objects>
</document>

View File

@ -0,0 +1,321 @@
import Foundation
import Cocoa
import SwiftUI
import GhosttyKit
/// The terminal controller is an NSWindowController that maps 1:1 to a terminal window.
class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel {
override var windowNibName: NSNib.Name? { "Terminal" }
/// The app instance that this terminal view will represent.
let ghostty: Ghostty.AppState
/// The currently focused surface.
var focusedSurface: Ghostty.SurfaceView? = nil
/// The surface tree for this window.
@Published var surfaceTree: Ghostty.SplitNode? = nil {
didSet {
// If our surface tree becomes nil then it means all our surfaces
// have closed, so we also close the window.
if (surfaceTree == nil) { lastSurfaceDidClose() }
}
}
/// Fullscreen state management.
private let fullscreenHandler = FullScreenHandler()
/// True when an alert is active so we don't overlap multiple.
private var alert: NSAlert? = nil
/// The style mask to use for the new window
private var styleMask: NSWindow.StyleMask {
var mask: NSWindow.StyleMask = [.resizable, .closable, .miniaturizable]
if (ghostty.windowDecorations) { mask.insert(.titled) }
return mask
}
init(_ ghostty: Ghostty.AppState, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
self.ghostty = ghostty
super.init(window: nil)
// Initialize our initial surface.
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
self.surfaceTree = .noSplit(.init(ghostty_app, base))
// Setup our notifications for behaviors
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(onToggleFullscreen),
name: Ghostty.Notification.ghosttyToggleFullscreen,
object: nil)
center.addObserver(
self,
selector: #selector(onGotoTab),
name: Ghostty.Notification.ghosttyGotoTab,
object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
}
//MARK: - NSWindowController
override func windowWillLoad() {
// We do NOT want to cascade because we handle this manually from the manager.
shouldCascadeWindows = false
}
override func windowDidLoad() {
guard let window = window else { return }
window.styleMask = self.styleMask
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in thie GitHub issue: https://github.com/mitchellh/ghostty/pull/376
window.colorSpace = NSColorSpace.sRGB
// Center the window to start, we'll move the window frame automatically
// when cascading.
window.center()
// Initialize our content view to the SwiftUI root
window.contentView = NSHostingView(rootView: TerminalView(
ghostty: self.ghostty,
viewModel: self,
delegate: self
))
}
// Shows the "+" button in the tab bar, responds to that click.
override func newWindowForTab(_ sender: Any?) {
// Trigger the ghostty core event logic for a new tab.
guard let surface = self.focusedSurface?.surface else { return }
ghostty.newTab(surface: surface)
}
//MARK: - NSWindowDelegate
// This is called when performClose is called on a window (NOT when close()
// is called directly). performClose is called primarily when UI elements such
// as the "red X" are pressed.
func windowShouldClose(_ sender: NSWindow) -> Bool {
// We must have a window. Is it even possible not to?
guard let window = self.window else { return true }
// If we have no surfaces, close.
guard let node = self.surfaceTree else { return true }
// If we already have an alert, continue with it
guard alert == nil else { return false }
// If our surfaces don't require confirmation, close.
if (!node.needsConfirmQuit()) { return true }
// We require confirmation, so show an alert as long as we aren't already.
let alert = NSAlert()
alert.messageText = "Close Terminal?"
alert.informativeText = "The terminal still has a running process. If you close the " +
"terminal the process will be killed."
alert.addButton(withTitle: "Close the Terminal")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window, completionHandler: { response in
self.alert = nil
switch (response) {
case .alertFirstButtonReturn:
window.close()
default:
break
}
})
self.alert = alert
return false
}
func windowWillClose(_ notification: Notification) {
// I don't know if this is required anymore. We previously had a ref cycle between
// the view and the window so we had to nil this out to break it but I think this
// may now be resolved. We should verify that no memory leaks and we can remove this.
self.window?.contentView = nil
}
//MARK: - First Responder
@IBAction func newWindow(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.newWindow(surface: surface)
}
@IBAction func newTab(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.newTab(surface: surface)
}
@IBAction func close(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.requestClose(surface: surface)
}
@IBAction func closeWindow(_ sender: Any) {
self.window?.performClose(sender)
}
@IBAction func splitHorizontally(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT)
}
@IBAction func splitVertically(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
}
@IBAction func splitZoom(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitToggleZoom(surface: surface)
}
@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)
}
private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) {
guard let surface = focusedSurface?.surface else { return }
ghostty.splitMoveFocus(surface: surface, direction: direction)
}
@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleFullscreen(surface: surface)
}
@IBAction func increaseFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .increase(1))
}
@IBAction func decreaseFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .decrease(1))
}
@IBAction func resetFontSize(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.changeFontSize(surface: surface, .reset)
}
@IBAction func toggleTerminalInspector(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.toggleTerminalInspector(surface: surface)
}
//MARK: - TerminalViewDelegate
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
self.focusedSurface = to
}
func titleDidChange(to: String) {
self.window?.title = to
}
func cellSizeDidChange(to: NSSize) {
guard ghostty.windowStepResize else { return }
self.window?.contentResizeIncrements = to
}
func lastSurfaceDidClose() {
self.window?.close()
}
//MARK: - Notifications
@objc private func onGotoTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
guard let window = self.window else { return }
// Get the tab index from the notification
guard let tabIndexAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
guard let tabIndex = tabIndexAny as? Int32 else { return }
guard let windowController = window.windowController else { return }
guard let tabGroup = windowController.window?.tabGroup else { return }
let tabbedWindows = tabGroup.windows
// This will be the index we want to actual go to
let finalIndex: Int
// An index that is invalid is used to signal some special values.
if (tabIndex <= 0) {
guard let selectedWindow = tabGroup.selectedWindow else { return }
guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return }
if (tabIndex == GHOSTTY_TAB_PREVIOUS.rawValue) {
finalIndex = selectedIndex - 1
} else if (tabIndex == GHOSTTY_TAB_NEXT.rawValue) {
finalIndex = selectedIndex + 1
} else {
return
}
} else {
// Tabs are 0-indexed here, so we subtract one from the key the user hit.
finalIndex = Int(tabIndex - 1)
}
guard finalIndex >= 0 && finalIndex < tabbedWindows.count else { return }
let targetWindow = tabbedWindows[finalIndex]
targetWindow.makeKeyAndOrderFront(nil)
}
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
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 useNonNativeFullscreenAny = notification.userInfo?[Ghostty.Notification.NonNativeFullscreenKey] else { return }
guard let useNonNativeFullscreen = useNonNativeFullscreenAny as? ghostty_non_native_fullscreen_e else { return }
self.fullscreenHandler.toggleFullscreen(window: window, nonNativeFullscreen: useNonNativeFullscreen)
// For some reason focus always gets lost when we toggle fullscreen, so we set it back.
if let focusedSurface {
Ghostty.moveFocus(to: focusedSurface)
}
}
}

View File

@ -0,0 +1,200 @@
import Cocoa
import SwiftUI
import GhosttyKit
import Combine
/// Manages a set of terminal windows. This is effectively an array of TerminalControllers.
/// This abstraction helps manage tabs and multi-window scenarios.
class TerminalManager {
struct Window {
let controller: TerminalController
let closePublisher: AnyCancellable
}
let ghostty: Ghostty.AppState
/// The currently focused surface of the main window.
var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface }
/// The set of windows we currently have.
private var windows: [Window] = []
// Keep track of the last point that our window was launched at so that new
// windows "cascade" over each other and don't just launch directly on top
// of each other.
private static var lastCascadePoint = NSPoint(x: 0, y: 0)
/// Returns the main window of the managed window stack. If there is no window
/// then an arbitrary window will be chosen.
private var mainWindow: Window? {
for window in windows {
if (window.controller.window?.isMainWindow ?? false) {
return window
}
}
// If we have no main window, just use the first window.
return windows.first
}
init(_ ghostty: Ghostty.AppState) {
self.ghostty = ghostty
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(onNewTab),
name: Ghostty.Notification.ghosttyNewTab,
object: nil)
center.addObserver(
self,
selector: #selector(onNewWindow),
name: Ghostty.Notification.ghosttyNewWindow,
object: nil)
}
deinit {
let center = NotificationCenter.default
center.removeObserver(self)
}
// MARK: - Window Management
/// Create a new terminal window.
func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
let c = createWindow(withBaseConfig: base)
if let window = c.window {
Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint)
}
c.showWindow(self)
}
/// Creates a new tab in the current main window. If there are no windows, a window
/// is created.
func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) {
// If there is no main window, just create a new window
guard let parent = mainWindow?.controller.window else {
newWindow(withBaseConfig: base)
return
}
// Create a new window and add it to the parent
newTab(to: parent, withBaseConfig: base)
}
private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) {
// Create a new window and add it to the parent
let window = createWindow(withBaseConfig: base).window!
parent.addTabbedWindow(window, ordered: .above)
relabelTabs(parent)
window.makeKeyAndOrderFront(self)
}
/// Creates a window controller, adds it to our managed list, and returns it.
private func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration?) -> TerminalController {
// Initialize our controller to load the window
let c = TerminalController(ghostty, withBaseConfig: base)
// Create a listener for when the window is closed so we can remove it.
let pubClose = NotificationCenter.default.publisher(
for: NSWindow.willCloseNotification,
object: c.window!
).sink { notification in
guard let window = notification.object as? NSWindow else { return }
guard let c = window.windowController as? TerminalController else { return }
self.removeWindow(c)
}
// Keep track of every window we manage
windows.append(Window(
controller: c,
closePublisher: pubClose
))
return c
}
private func removeWindow(_ controller: TerminalController) {
// Remove it from our managed set
guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return }
let w = self.windows[idx]
self.windows.remove(at: idx)
// Ensure any publishers we have are cancelled
w.closePublisher.cancel()
// Removing the window can change tabs, so we need to relabel all tabs.
// At this point, the window is already removed from the tab bar so
// I don't know a way to only relabel the active tab bar, so just relabel
// all of them.
relabelAllTabs()
// If we remove a window, we reset the cascade point to the key window so that
// the next window cascade's from that one.
if let focusedWindow = NSApplication.shared.keyWindow {
// If we are NOT the focused window, then we are a tabbed window. If we
// are closing a tabbed window, we want to set the cascade point to be
// the next cascade point from this window.
if focusedWindow != controller.window {
Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint)
return
}
// If we are the focused window, then we set the last cascade point to
// our own frame so that it shows up in the same spot.
let frame = focusedWindow.frame
Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY)
}
}
/// Relabels all the tabs with the proper keyboard shortcut.
func relabelAllTabs() {
for w in windows {
if let window = w.controller.window {
relabelTabs(window)
}
}
}
/// Update the accessory view of each tab according to the keyboard
/// shortcut that activates it (if any). This is called when the key window
/// changes and when a window is closed.
private func relabelTabs(_ window: NSWindow) {
guard let windows = window.tabbedWindows else { return }
guard let cfg = ghostty.config else { return }
for (index, window) in windows.enumerated().prefix(9) {
let action = "goto_tab:\(index + 1)"
let trigger = ghostty_config_trigger(cfg, action, UInt(action.count))
guard let equiv = Ghostty.keyEquivalentLabel(key: trigger.key, mods: trigger.mods) else {
continue
}
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.labelFont(ofSize: 0),
.foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
]
let attributedString = NSAttributedString(string: " \(equiv) ", attributes: attributes)
let text = NSTextField(labelWithAttributedString: attributedString)
window.tab.accessoryView = text
}
}
// MARK: - Notifications
@objc private func onNewWindow(notification: SwiftUI.Notification) {
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
self.newWindow(withBaseConfig: config)
}
@objc private func onNewTab(notification: SwiftUI.Notification) {
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
guard let window = surfaceView.window else { return }
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
let config = configAny as? Ghostty.SurfaceConfiguration
self.newTab(to: window, withBaseConfig: config)
}
}

View File

@ -0,0 +1,135 @@
import SwiftUI
import GhosttyKit
/// This delegate is notified of actions and property changes regarding the terminal view. This
/// delegate is optional and can be used by a TerminalView caller to react to changes such as
/// titles being set, cell sizes being changed, etc.
protocol TerminalViewDelegate: AnyObject, ObservableObject {
/// Called when the currently focused surface changed. This can be nil.
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?)
/// The title of the terminal should change.
func titleDidChange(to: String)
/// The cell size changed.
func cellSizeDidChange(to: NSSize)
}
// Default all the functions so they're optional
extension TerminalViewDelegate {
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {}
func titleDidChange(to: String) {}
func cellSizeDidChange(to: NSSize) {}
}
/// The view model is a required implementation for TerminalView callers. This contains
/// the main state between the TerminalView caller and SwiftUI. This abstraction is what
/// allows AppKit to own most of the data in SwiftUI.
protocol TerminalViewModel: ObservableObject {
/// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView
/// and children. This should be @Published.
var surfaceTree: Ghostty.SplitNode? { get set }
}
/// The main terminal view. This terminal view supports splits.
struct TerminalView<ViewModel: TerminalViewModel>: View {
@ObservedObject var ghostty: Ghostty.AppState
// The required view model
@ObservedObject var viewModel: ViewModel
// An optional delegate to receive information about terminal changes.
weak var delegate: (any TerminalViewDelegate)? = nil
// This seems like a crutch after switching from SwiftUI to AppKit lifecycle.
@FocusState private var focused: Bool
// Various state values sent back up from the currently focused terminals.
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle
@FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
// The title for our window
private var title: String {
var title = "👻"
if let surfaceTitle = surfaceTitle {
if (surfaceTitle.count > 0) {
title = surfaceTitle
}
}
if let zoomedSplit = zoomedSplit {
if zoomedSplit {
title = "🔍 " + title
}
}
return title
}
var body: some View {
switch ghostty.readiness {
case .loading:
Text("Loading")
case .error:
ErrorView()
case .ready:
VStack(spacing: 0) {
// If we're running in debug mode we show a warning so that users
// know that performance will be degraded.
if (ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG) {
DebugBuildWarningView()
}
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
.ghosttyApp(ghostty.app!)
.ghosttyConfig(ghostty.config!)
.focused($focused)
.onAppear { self.focused = true }
.onChange(of: focusedSurface) { newValue in
self.delegate?.focusedSurfaceDidChange(to: newValue)
}
.onChange(of: title) { newValue in
self.delegate?.titleDidChange(to: newValue)
}
.onChange(of: cellSize) { newValue in
guard let size = newValue else { return }
self.delegate?.cellSizeDidChange(to: size)
}
}
}
}
}
struct DebugBuildWarningView: View {
@State private var isPopover = false
var body: some View {
HStack {
Spacer()
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.yellow)
Text("You're running a debug build of Ghostty! Performance will be degraded.")
.padding(.all, 8)
.popover(isPresented: $isPopover, arrowEdge: .bottom) {
Text("""
Debug builds of Ghostty are very slow and you may experience
performance problems. Debug builds are only recommended during
development.
""")
.padding(.all)
}
Spacer()
}
.background(Color(.windowBackgroundColor))
.frame(maxWidth: .infinity)
.onTapGesture {
isPopover = true
}
}
}

View File

@ -0,0 +1,217 @@
import SwiftUI
import GhosttyKit
extension Ghostty {
/// This enum represents the possible states that a node in the split tree can be in. It is either:
///
/// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single
/// terminal surface to render.
/// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a
/// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These
/// values can further be split infinitely.
///
enum SplitNode: Equatable, Hashable {
case noSplit(Leaf)
case horizontal(Container)
case vertical(Container)
/// Returns the view that would prefer receiving focus in this tree. This is always the
/// top-left-most view. This is used when creating a split or closing a split to find the
/// next view to send focus to.
func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView {
let container: Container
switch (self) {
case .noSplit(let leaf):
// noSplit is easy because there is only one thing to focus
return leaf.surface
case .horizontal(let c):
container = c
case .vertical(let c):
container = c
}
let node: SplitNode
switch (direction) {
case .previous, .bottom, .left:
node = container.bottomRight
case .next, .top, .right:
node = container.topLeft
}
return node.preferredFocus(direction)
}
/// Close the surface associated with this node. This will likely deinitialize the
/// surface. At this point, the surface view in this node tree can never be used again.
func close() {
switch (self) {
case .noSplit(let leaf):
leaf.surface.close()
case .horizontal(let container):
container.topLeft.close()
container.bottomRight.close()
case .vertical(let container):
container.topLeft.close()
container.bottomRight.close()
}
}
/// Returns true if any surface in the split stack requires quit confirmation.
func needsConfirmQuit() -> Bool {
switch (self) {
case .noSplit(let leaf):
return leaf.surface.needsConfirmQuit
case .horizontal(let container):
return container.topLeft.needsConfirmQuit() ||
container.bottomRight.needsConfirmQuit()
case .vertical(let container):
return container.topLeft.needsConfirmQuit() ||
container.bottomRight.needsConfirmQuit()
}
}
/// Returns true if the split tree contains the given view.
func contains(view: SurfaceView) -> Bool {
switch (self) {
case .noSplit(let leaf):
return leaf.surface == view
case .horizontal(let container):
return container.topLeft.contains(view: view) ||
container.bottomRight.contains(view: view)
case .vertical(let container):
return container.topLeft.contains(view: view) ||
container.bottomRight.contains(view: view)
}
}
// MARK: - Equatable
static func == (lhs: SplitNode, rhs: SplitNode) -> Bool {
switch (lhs, rhs) {
case (.noSplit(let lhs_v), .noSplit(let rhs_v)):
return lhs_v === rhs_v
case (.horizontal(let lhs_v), .horizontal(let rhs_v)):
return lhs_v === rhs_v
case (.vertical(let lhs_v), .vertical(let rhs_v)):
return lhs_v === rhs_v
default:
return false
}
}
class Leaf: ObservableObject, Equatable, Hashable {
let app: ghostty_app_t
@Published var surface: SurfaceView
/// Initialize a new leaf which creates a new terminal surface.
init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) {
self.app = app
self.surface = SurfaceView(app, baseConfig)
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(app)
hasher.combine(surface)
}
// MARK: - Equatable
static func == (lhs: Leaf, rhs: Leaf) -> Bool {
return lhs.app == rhs.app && lhs.surface === rhs.surface
}
}
class Container: ObservableObject, Equatable, Hashable {
let app: ghostty_app_t
@Published var topLeft: SplitNode
@Published var bottomRight: SplitNode
/// A container is always initialized from some prior leaf because a split has to originate
/// from a non-split value. When initializing, we inherit the leaf's surface and then
/// initialize a new surface for the new pane.
init(from: Leaf, baseConfig: SurfaceConfiguration? = nil) {
self.app = from.app
// Initially, both topLeft and bottomRight are in the "nosplit"
// state since this is a new split.
self.topLeft = .noSplit(from)
self.bottomRight = .noSplit(.init(app, baseConfig))
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(app)
hasher.combine(topLeft)
hasher.combine(bottomRight)
}
// MARK: - Equatable
static func == (lhs: Container, rhs: Container) -> Bool {
return lhs.app == rhs.app &&
lhs.topLeft == rhs.topLeft &&
lhs.bottomRight == rhs.bottomRight
}
}
/// This keeps track of the "neighbors" of a split: the immediately above/below/left/right
/// nodes. This is purposely weak so we don't have to worry about memory management
/// with this (although, it should always be correct).
struct Neighbors {
var left: SplitNode?
var right: SplitNode?
var top: SplitNode?
var bottom: SplitNode?
/// These are the previous/next nodes. It will certainly be one of the above as well
/// but we keep track of these separately because depending on the split direction
/// of the containing node, previous may be left OR top (same for next).
var previous: SplitNode?
var next: SplitNode?
/// No neighbors, used by the root node.
static let empty: Self = .init()
/// Get the node for a given direction.
func get(direction: SplitFocusDirection) -> SplitNode? {
let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
.previous: \.previous,
.next: \.next,
.top: \.top,
.bottom: \.bottom,
.left: \.left,
.right: \.right,
]
guard let path = map[direction] else { return nil }
return self[keyPath: path]
}
/// Update multiple keys and return a new copy.
func update(_ attrs: [WritableKeyPath<Self, SplitNode?>: SplitNode?]) -> Self {
var clone = self
attrs.forEach { (key, value) in
clone[keyPath: key] = value
}
return clone
}
/// True if there are no neighbors
func isEmpty() -> Bool {
return self.previous == nil && self.next == nil
}
}
}
}

View File

@ -5,243 +5,40 @@ extension Ghostty {
/// A spittable terminal view is one where the terminal allows for "splits" (vertical and horizontal) within the
/// view. The terminal starts in the unsplit state (a plain ol' TerminalView) but responds to changes to the
/// split direction by splitting the terminal.
///
/// This also allows one split to be "zoomed" at any time.
struct TerminalSplit: View {
let onClose: (() -> Void)?
let baseConfig: SurfaceConfiguration?
@Environment(\.ghosttyApp) private var app
/// The current state of the root node. This can be set to nil when all surfaces are closed.
@Binding var node: SplitNode?
/// Non-nil if one of the surfaces in the split tree is currently "zoomed." A zoomed surface
/// becomes "full screen" on the split tree.
@State private var zoomedSurface: SurfaceView? = nil
var body: some View {
if let app = app {
ZStack {
TerminalSplitRoot(
app: app,
zoomedSurface: $zoomedSurface,
onClose: onClose,
baseConfig: baseConfig
)
ZStack {
TerminalSplitRoot(
node: $node,
zoomedSurface: $zoomedSurface
)
// If we have a zoomed surface, we overlay that on top of our split
// root. Our split root will become clear when there is a zoomed
// surface. We need to keep the split root around so that we don't
// lose all of the surface state so this must be a ZStack.
if let surfaceView = zoomedSurface {
InspectableSurface(surfaceView: surfaceView)
}
// If we have a zoomed surface, we overlay that on top of our split
// root. Our split root will become clear when there is a zoomed
// surface. We need to keep the split root around so that we don't
// lose all of the surface state so this must be a ZStack.
if let surfaceView = zoomedSurface {
InspectableSurface(surfaceView: surfaceView)
}
.focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil)
}
.focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != nil)
}
}
/// This enum represents the possible states that a node in the split tree can be in. It is either:
///
/// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single
/// terminal surface to render.
/// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a
/// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These
/// values can further be split infinitely.
///
enum SplitNode: Equatable, Hashable {
case noSplit(Leaf)
case horizontal(Container)
case vertical(Container)
/// Returns the view that would prefer receiving focus in this tree. This is always the
/// top-left-most view. This is used when creating a split or closing a split to find the
/// next view to send focus to.
func preferredFocus(_ direction: SplitFocusDirection = .top) -> SurfaceView {
let container: Container
switch (self) {
case .noSplit(let leaf):
// noSplit is easy because there is only one thing to focus
return leaf.surface
case .horizontal(let c):
container = c
case .vertical(let c):
container = c
}
let node: SplitNode
switch (direction) {
case .previous, .bottom, .left:
node = container.bottomRight
case .next, .top, .right:
node = container.topLeft
}
return node.preferredFocus(direction)
}
/// Close the surface associated with this node. This will likely deinitialize the
/// surface. At this point, the surface view in this node tree can never be used again.
func close() {
switch (self) {
case .noSplit(let leaf):
leaf.surface.close()
case .horizontal(let container):
container.topLeft.close()
container.bottomRight.close()
case .vertical(let container):
container.topLeft.close()
container.bottomRight.close()
}
}
/// Returns true if the split tree contains the given view.
func contains(view: SurfaceView) -> Bool {
switch (self) {
case .noSplit(let leaf):
return leaf.surface == view
case .horizontal(let container):
return container.topLeft.contains(view: view) ||
container.bottomRight.contains(view: view)
case .vertical(let container):
return container.topLeft.contains(view: view) ||
container.bottomRight.contains(view: view)
}
}
// MARK: - Equatable
static func == (lhs: SplitNode, rhs: SplitNode) -> Bool {
switch (lhs, rhs) {
case (.noSplit(let lhs_v), .noSplit(let rhs_v)):
return lhs_v === rhs_v
case (.horizontal(let lhs_v), .horizontal(let rhs_v)):
return lhs_v === rhs_v
case (.vertical(let lhs_v), .vertical(let rhs_v)):
return lhs_v === rhs_v
default:
return false
}
}
class Leaf: ObservableObject, Equatable, Hashable {
let app: ghostty_app_t
@Published var surface: SurfaceView
/// Initialize a new leaf which creates a new terminal surface.
init(_ app: ghostty_app_t, _ baseConfig: SurfaceConfiguration?) {
self.app = app
self.surface = SurfaceView(app, baseConfig)
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(app)
hasher.combine(surface)
}
// MARK: - Equatable
static func == (lhs: Leaf, rhs: Leaf) -> Bool {
return lhs.app == rhs.app && lhs.surface === rhs.surface
}
}
class Container: ObservableObject, Equatable, Hashable {
let app: ghostty_app_t
@Published var topLeft: SplitNode
@Published var bottomRight: SplitNode
/// A container is always initialized from some prior leaf because a split has to originate
/// from a non-split value. When initializing, we inherit the leaf's surface and then
/// initialize a new surface for the new pane.
init(from: Leaf, baseConfig: SurfaceConfiguration? = nil) {
self.app = from.app
// Initially, both topLeft and bottomRight are in the "nosplit"
// state since this is a new split.
self.topLeft = .noSplit(from)
self.bottomRight = .noSplit(.init(app, baseConfig))
}
// MARK: - Hashable
func hash(into hasher: inout Hasher) {
hasher.combine(app)
hasher.combine(topLeft)
hasher.combine(bottomRight)
}
// MARK: - Equatable
static func == (lhs: Container, rhs: Container) -> Bool {
return lhs.app == rhs.app &&
lhs.topLeft == rhs.topLeft &&
lhs.bottomRight == rhs.bottomRight
}
}
/// This keeps track of the "neighbors" of a split: the immediately above/below/left/right
/// nodes. This is purposely weak so we don't have to worry about memory management
/// with this (although, it should always be correct).
struct Neighbors {
var left: SplitNode?
var right: SplitNode?
var top: SplitNode?
var bottom: SplitNode?
/// These are the previous/next nodes. It will certainly be one of the above as well
/// but we keep track of these separately because depending on the split direction
/// of the containing node, previous may be left OR top (same for next).
var previous: SplitNode?
var next: SplitNode?
/// No neighbors, used by the root node.
static let empty: Self = .init()
/// Get the node for a given direction.
func get(direction: SplitFocusDirection) -> SplitNode? {
let map: [SplitFocusDirection : KeyPath<Self, SplitNode?>] = [
.previous: \.previous,
.next: \.next,
.top: \.top,
.bottom: \.bottom,
.left: \.left,
.right: \.right,
]
guard let path = map[direction] else { return nil }
return self[keyPath: path]
}
/// Update multiple keys and return a new copy.
func update(_ attrs: [WritableKeyPath<Self, SplitNode?>: SplitNode?]) -> Self {
var clone = self
attrs.forEach { (key, value) in
clone[keyPath: key] = value
}
return clone
}
/// True if there are no neighbors
func isEmpty() -> Bool {
return self.previous == nil && self.next == nil
}
}
}
/// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever
/// one of these in a split tree.
private struct TerminalSplitRoot: View {
@State private var node: SplitNode
@State private var requestClose: Bool = false
let onClose: (() -> Void)?
let baseConfig: SurfaceConfiguration?
/// The root node that we're rendering. This will be set to nil if all the surfaces in this tree close.
@Binding var node: SplitNode?
/// Keeps track of whether we're in a zoomed split state or not. If one of the splits we own
/// is in the zoomed state, we clear our body since we expect a zoomed split to overlay
@ -250,16 +47,6 @@ extension Ghostty {
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
init(app: ghostty_app_t,
zoomedSurface: Binding<SurfaceView?>,
onClose: (() ->Void)? = nil,
baseConfig: SurfaceConfiguration? = nil) {
self.onClose = onClose
self.baseConfig = baseConfig
self._zoomedSurface = zoomedSurface
_node = State(wrappedValue: SplitNode.noSplit(.init(app, baseConfig)))
}
var body: some View {
let center = NotificationCenter.default
let pubZoom = center.publisher(for: Notification.didToggleSplitZoom)
@ -271,23 +58,15 @@ extension Ghostty {
if (zoomedSurface == nil) {
ZStack {
switch (node) {
case nil:
Color(.clear)
case .noSplit(let leaf):
TerminalSplitLeaf(
leaf: leaf,
neighbors: .empty,
node: $node,
requestClose: $requestClose
node: $node
)
.onChange(of: requestClose) { value in
guard value else { return }
// Free any resources associated with this root, we're closing.
node.close()
// Call our callback
guard let onClose = self.onClose else { return }
onClose()
}
case .horizontal(let container):
TerminalSplitContainer(
@ -309,7 +88,7 @@ extension Ghostty {
}
}
.navigationTitle(surfaceTitle ?? "Ghostty")
.id(node) // Required to detect node changes
.id(node) // Needed for change detection on node
} else {
// On these events we want to reset the split state and call it.
let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!)
@ -323,7 +102,7 @@ extension Ghostty {
.onReceive(pubFocus) { onZoomReset(notification: $0) }
}
}
func onZoom(notification: SwiftUI.Notification) {
// Our node must be split to receive zooms. You can't zoom an unsplit terminal.
if case .noSplit = node {
@ -332,7 +111,7 @@ extension Ghostty {
// Make sure the notification has a surface and that this window owns the surface.
guard let surfaceView = notification.object as? SurfaceView else { return }
guard node.contains(view: surfaceView) else { return }
guard node?.contains(view: surfaceView) ?? false else { return }
// We are in the zoomed state.
zoomedSurface = surfaceView
@ -367,7 +146,7 @@ extension Ghostty {
}
}
}
/// A noSplit leaf node of a split tree.
private struct TerminalSplitLeaf: View {
/// The leaf to draw the surface for.
@ -376,11 +155,8 @@ extension Ghostty {
/// The neighbors, used for navigation.
let neighbors: SplitNode.Neighbors
/// The SplitNode that the leaf belongs to.
@Binding var node: SplitNode
/// This will be set to true when the split requests that is become closed.
@Binding var requestClose: Bool
/// The SplitNode that the leaf belongs to. This will be set to nil but when leaf is closed.
@Binding var node: SplitNode?
var body: some View {
let center = NotificationCenter.default
@ -404,14 +180,14 @@ extension Ghostty {
// If the child process is not alive, then we exit immediately
guard processAlive else {
requestClose = true
node = nil
return
}
// If we don't have a window to attach our modal to, we also exit immediately.
// This should NOT happen.
guard let window = leaf.surface.window else {
requestClose = true
node = nil
return
}
@ -430,7 +206,7 @@ extension Ghostty {
alert.beginSheetModal(for: window, completionHandler: { response in
switch (response) {
case .alertFirstButtonReturn:
requestClose = true
node = nil
default:
break
@ -470,7 +246,7 @@ extension Ghostty {
}
// See moveFocus comment, we have to run this whenever split changes.
Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node.preferredFocus())
Ghostty.moveFocus(to: container.bottomRight.preferredFocus(), from: node!.preferredFocus())
}
/// This handles the event to move the split focus (i.e. previous/next) from a keyboard event.
@ -480,84 +256,104 @@ extension Ghostty {
guard let direction = directionAny as? SplitFocusDirection else { return }
guard let next = neighbors.get(direction: direction) else { return }
Ghostty.moveFocus(
to: next.preferredFocus(direction),
from: node.preferredFocus()
to: next.preferredFocus(direction)
)
}
}
/// This represents a split view that is in the horizontal or vertical split state.
private struct TerminalSplitContainer: View {
let direction: SplitViewDirection
let neighbors: SplitNode.Neighbors
@Binding var node: SplitNode
@Binding var node: SplitNode?
@StateObject var container: SplitNode.Container
@State private var closeTopLeft: Bool = false
@State private var closeBottomRight: Bool = false
var body: some View {
SplitView(direction, left: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.right : \.bottom
TerminalSplitNested(
node: $container.topLeft,
node: closeableTopLeft(),
neighbors: neighbors.update([
neighborKey: container.bottomRight,
\.next: container.bottomRight,
]),
requestClose: $closeTopLeft
])
)
.onChange(of: closeTopLeft) { value in
guard value else { return }
// Close the top left and release all resources
container.topLeft.close()
// When closing the topLeft, our parent becomes the bottomRight.
node = container.bottomRight
Ghostty.moveFocus(to: node.preferredFocus(), from: container.topLeft.preferredFocus())
}
}, right: {
let neighborKey: WritableKeyPath<SplitNode.Neighbors, SplitNode?> = direction == .horizontal ? \.left : \.top
TerminalSplitNested(
node: $container.bottomRight,
node: closeableBottomRight(),
neighbors: neighbors.update([
neighborKey: container.topLeft,
\.previous: container.topLeft,
]),
requestClose: $closeBottomRight
])
)
.onChange(of: closeBottomRight) { value in
guard value else { return }
// Close the node and release all resources
container.bottomRight.close()
// When closing the bottomRight, our parent becomes the topLeft.
node = container.topLeft
Ghostty.moveFocus(to: node.preferredFocus(), from: container.bottomRight.preferredFocus())
})
}
private func closeableTopLeft() -> Binding<SplitNode?> {
return .init(get: {
container.topLeft
}, set: { newValue in
if let newValue {
container.topLeft = newValue
return
}
// Closing
container.topLeft.close()
node = container.bottomRight
DispatchQueue.main.async {
Ghostty.moveFocus(
to: container.bottomRight.preferredFocus(),
from: container.topLeft.preferredFocus()
)
}
})
}
private func closeableBottomRight() -> Binding<SplitNode?> {
return .init(get: {
container.bottomRight
}, set: { newValue in
if let newValue {
container.bottomRight = newValue
return
}
// Closing
container.bottomRight.close()
node = container.topLeft
DispatchQueue.main.async {
Ghostty.moveFocus(
to: container.topLeft.preferredFocus(),
from: container.bottomRight.preferredFocus()
)
}
})
}
}
/// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but
/// requires there be a binding to the parent node.
private struct TerminalSplitNested: View {
@Binding var node: SplitNode
@Binding var node: SplitNode?
let neighbors: SplitNode.Neighbors
@Binding var requestClose: Bool
var body: some View {
switch (node) {
case nil:
Color(.clear)
case .noSplit(let leaf):
TerminalSplitLeaf(
leaf: leaf,
neighbors: neighbors,
node: $node,
requestClose: $requestClose
node: $node
)
case .horizontal(let container):
@ -578,7 +374,7 @@ extension Ghostty {
}
}
}
/// When changing the split state, or going full screen (native or non), the terminal view
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal

View File

@ -252,6 +252,13 @@ extension Ghostty {
// then the view is moved to a new window.
var initialSize: NSSize? = nil
// Returns true if quit confirmation is required for this surface to
// exit safely.
var needsConfirmQuit: Bool {
guard let surface = self.surface else { return false }
return ghostty_surface_needs_confirm_quit(surface)
}
// Returns the inspector instance for this surface, or nil if the
// surface has been closed.
var inspector: ghostty_inspector_t? {
@ -500,8 +507,8 @@ extension Ghostty {
// If we have tabs, then do not change the window size
guard let window = self.window else { return }
guard let windowControllerRaw = window.windowController else { return }
guard let windowController = windowControllerRaw as? PrimaryWindowController else { return }
guard !windowController.didInitializeFromSurface else { return }
guard let windowController = windowControllerRaw as? TerminalController else { return }
guard case .noSplit = windowController.surfaceTree else { return }
// Setup our frame. We need to first subtract the views frame so that we can
// just get the chrome frame so that we only affect the surface view size.
@ -513,9 +520,6 @@ extension Ghostty {
// We have no tabs and we are not a split, so set the initial size of the window.
window.setFrame(frame, display: true)
// Note that we did initialize
windowController.didInitializeFromSurface = true
}
override func becomeFirstResponder() -> Bool {

View File

@ -1,7 +1,8 @@
import SwiftUI
import GhosttyKit
class FullScreenHandler { var previousTabGroup: NSWindowTabGroup?
class FullScreenHandler {
var previousTabGroup: NSWindowTabGroup?
var previousTabGroupIndex: Int?
var previousContentFrame: NSRect?
var previousStyleMask: NSWindow.StyleMask? = nil

View File

@ -0,0 +1,14 @@
import SwiftUI
struct HostingWindowKey: EnvironmentKey {
typealias Value = () -> NSWindow? // needed for weak link
static let defaultValue: Self.Value = { nil }
}
extension EnvironmentValues {
/// This can be used to set the hosting NSWindow to a NSHostingView
var hostingWindow: HostingWindowKey.Value {
get { return self[HostingWindowKey.self] }
set { self[HostingWindowKey.self] = newValue }
}
}

View File

@ -94,39 +94,39 @@
<menuItem title="New Window" id="Was-JA-tGl">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="newWindow:" target="bbz-4X-AYv" id="NnC-l5-DUY"/>
<action selector="newWindow:" target="-1" id="pnf-fE-gHi"/>
</connections>
</menuItem>
<menuItem title="New Tab" id="uTG-Vz-hJU">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="newTab:" target="bbz-4X-AYv" id="cxO-CS-TJq"/>
<action selector="newTab:" target="-1" id="KoW-K7-hw5"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="m54-Is-iLE"/>
<menuItem title="Split Horizontally" id="VUR-Ld-nLx">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="splitHorizontally:" target="bbz-4X-AYv" id="QT1-Yt-gYJ"/>
<action selector="splitHorizontally:" target="-1" id="cv2-Xg-FR4"/>
</connections>
</menuItem>
<menuItem title="Split Vertically" id="UDZ-4y-6xL">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="splitVertically:" target="bbz-4X-AYv" id="ZZF-3f-OwW"/>
<action selector="splitVertically:" target="-1" id="c6x-CF-u52"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="sjq-M1-UGS"/>
<menuItem title="Close" id="DVo-aG-piG">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="close:" target="bbz-4X-AYv" id="Szc-Fu-9yk"/>
<action selector="close:" target="-1" id="tTZ-2b-Mbm"/>
</connections>
</menuItem>
<menuItem title="Close Window" id="W5w-UZ-crk">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="closeWindow:" target="bbz-4X-AYv" id="j4w-Nd-9bO"/>
<action selector="closeWindow:" target="-1" id="ovs-xn-3ju"/>
</connections>
</menuItem>
</items>
@ -139,26 +139,26 @@
<menuItem title="Increase Font Size" id="CIH-ey-Z6x" userLabel="Increase Font Size">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="increaseFontSize:" target="bbz-4X-AYv" id="qbI-YJ-xuW"/>
<action selector="increaseFontSize:" target="-1" id="361-5E-7PY"/>
</connections>
</menuItem>
<menuItem title="Reset Font Size" id="Jah-MY-aLX">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="resetFontSize:" target="bbz-4X-AYv" id="2qT-E9-Qt1"/>
<action selector="resetFontSize:" target="-1" id="3dh-T9-IkH"/>
</connections>
</menuItem>
<menuItem title="Decrease Font Size" id="kzb-SZ-dOA">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="decreaseFontSize:" target="bbz-4X-AYv" id="rlw-0o-kA2"/>
<action selector="decreaseFontSize:" target="-1" id="Zlz-QZ-t8K"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="L3L-I8-sqk"/>
<menuItem title="Terminal Inspector" id="QwP-M5-fvh">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleTerminalInspector:" target="bbz-4X-AYv" id="DON-fR-wyr"/>
<action selector="toggleTerminalInspector:" target="-1" id="87m-3R-fQl"/>
</connections>
</menuItem>
</items>
@ -203,7 +203,7 @@
<menuItem title="Toggle Full Screen" id="8kY-Pi-KaY">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleFullScreen:" target="bbz-4X-AYv" id="PQq-1F-kpT"/>
<action selector="toggleGhosttyFullScreen:" target="-1" id="QB9-7R-xyc"/>
</connections>
</menuItem>
<menuItem title="Bring All to Front" id="LE2-aR-0XJ">
@ -216,19 +216,19 @@
<menuItem title="Zoom Split" id="oPd-mn-IEH">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="splitZoom:" target="bbz-4X-AYv" id="h3L-kI-kTJ"/>
<action selector="splitZoom:" target="-1" id="g0e-Ls-D7L"/>
</connections>
</menuItem>
<menuItem title="Select Previous Split" id="Lic-px-1wg">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="splitMoveFocusPrevious:" target="bbz-4X-AYv" id="mOs-gG-dAC"/>
<action selector="splitMoveFocusPrevious:" target="-1" id="ip5-6M-66n"/>
</connections>
</menuItem>
<menuItem title="Select Next Split" id="bD7-ei-wKU">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="splitMoveFocusNext:" target="bbz-4X-AYv" id="rU6-Vw-DoW"/>
<action selector="splitMoveFocusNext:" target="-1" id="FSV-0l-3VR"/>
</connections>
</menuItem>
<menuItem title="Select Split" id="dos-9S-LXC">
@ -238,25 +238,25 @@
<menuItem title="Select Split Above" id="0yU-hC-8xF">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="splitMoveFocusAbove:" target="bbz-4X-AYv" id="HDw-f2-RJY"/>
<action selector="splitMoveFocusAbove:" target="-1" id="0Y7-7I-yQO"/>
</connections>
</menuItem>
<menuItem title="Select Split Below" id="QDz-d9-CBr">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="splitMoveFocusBelow:" target="bbz-4X-AYv" id="fmW-hZ-uOA"/>
<action selector="splitMoveFocusBelow:" target="-1" id="9yp-du-tEq"/>
</connections>
</menuItem>
<menuItem title="Select Split Left" id="cTK-oy-KuV">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="splitMoveFocusLeft:" target="bbz-4X-AYv" id="N1i-a2-7N5"/>
<action selector="splitMoveFocusLeft:" target="-1" id="BZI-DX-ly3"/>
</connections>
</menuItem>
<menuItem title="Select Split Right" id="upj-mc-L7X">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="splitMoveFocusRight:" target="bbz-4X-AYv" id="Pgi-df-84r"/>
<action selector="splitMoveFocusRight:" target="-1" id="ELo-QZ-O6q"/>
</connections>
</menuItem>
</items>

View File

@ -1206,6 +1206,11 @@ pub const CAPI = struct {
return surface.app.config.@"background-opacity" < 1.0;
}
/// Returns true if the surface needs to confirm quitting.
export fn ghostty_surface_needs_confirm_quit(surface: *Surface) bool {
return surface.core_surface.needsConfirmQuit();
}
/// Tell the surface that it needs to schedule a render
export fn ghostty_surface_refresh(surface: *Surface) void {
surface.refresh();