mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-24 12:46:10 +03:00
Merge pull request #772 from mitchellh/macos-confirm-quit
macos: refactor main terminal window, split state, fix some bugs
This commit is contained in:
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
31
macos/Sources/Features/Terminal/Terminal.xib
Normal file
31
macos/Sources/Features/Terminal/Terminal.xib
Normal 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>
|
321
macos/Sources/Features/Terminal/TerminalController.swift
Normal file
321
macos/Sources/Features/Terminal/TerminalController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
200
macos/Sources/Features/Terminal/TerminalManager.swift
Normal file
200
macos/Sources/Features/Terminal/TerminalManager.swift
Normal 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)
|
||||
}
|
||||
}
|
135
macos/Sources/Features/Terminal/TerminalView.swift
Normal file
135
macos/Sources/Features/Terminal/TerminalView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
217
macos/Sources/Ghostty/Ghostty.SplitNode.swift
Normal file
217
macos/Sources/Ghostty/Ghostty.SplitNode.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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 {
|
||||
|
@ -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
|
||||
|
14
macos/Sources/Helpers/HostingWindow.swift
Normal file
14
macos/Sources/Helpers/HostingWindow.swift
Normal 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 }
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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();
|
||||
|
Reference in New Issue
Block a user