diff --git a/include/ghostty.h b/include/ghostty.h index d28dcd095..001ffb249 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -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); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index f493b2072..98fd5e58d 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; - 85102A1B2A6E32890084AB3E /* PrimaryWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindowController.swift; sourceTree = ""; }; 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; - 85DE1C912A6A3DCA00493853 /* PrimaryWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindow.swift; sourceTree = ""; }; A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Input.swift; sourceTree = ""; }; A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - A53426382A7DC55C00EBB7A2 /* PrimaryWindowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindowManager.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; A545D1A12A5772CE006E0AE4 /* shell-integration */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "shell-integration"; path = "../zig-out/share/shell-integration"; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A55B7BB529B6F47F0055DE60 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; - A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitView.swift; sourceTree = ""; }; 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 = ""; }; A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = ""; }; A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = ""; }; + A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; }; + A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; + A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; + A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; + A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.TerminalSplit.swift; sourceTree = ""; }; + A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitNode.swift; sourceTree = ""; }; A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = ""; }; A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; @@ -74,7 +79,6 @@ A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; - A5FECBD629D1FC3900022361 /* PrimaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryView.swift; sourceTree = ""; }; /* 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 = ""; }; - 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 = ""; - }; 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 = ""; }; + A59630982AEE1C4400D64628 /* Terminal */ = { + isa = PBXGroup; + children = ( + A59630992AEE1C6400D64628 /* Terminal.xib */, + A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, + A596309B2AEE1C9E00D64628 /* TerminalController.swift */, + A596309D2AEE1D6C00D64628 /* TerminalView.swift */, + A535B9D9299C569B0017E2E4 /* ErrorView.swift */, + ); + path = Terminal; + sourceTree = ""; + }; 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; diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index cf9b53427..a9ea30eeb 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -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) - } } diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift deleted file mode 100644 index 9cff1c93f..000000000 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ /dev/null @@ -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 - } - } -} diff --git a/macos/Sources/Features/Primary Window/PrimaryWindow.swift b/macos/Sources/Features/Primary Window/PrimaryWindow.swift deleted file mode 100644 index 7a8be5471..000000000 --- a/macos/Sources/Features/Primary Window/PrimaryWindow.swift +++ /dev/null @@ -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 - } -} diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift deleted file mode 100644 index 6add783e3..000000000 --- a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift +++ /dev/null @@ -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() - } -} diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift deleted file mode 100644 index eef81f449..000000000 --- a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift +++ /dev/null @@ -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 - } - } -} diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index a95dd4923..74ca6d9bc 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -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) } } } diff --git a/macos/Sources/Features/Settings/ConfigurationErrors.xib b/macos/Sources/Features/Settings/ConfigurationErrors.xib index dcd40ad21..7a8f1207c 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrors.xib +++ b/macos/Sources/Features/Settings/ConfigurationErrors.xib @@ -1,8 +1,8 @@ - + - + diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift index fc74a2aad..b17ce5aab 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrorsController.swift +++ b/macos/Sources/Features/Settings/ConfigurationErrorsController.swift @@ -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)) } } diff --git a/macos/Sources/Features/Settings/ConfigurationErrorsView.swift b/macos/Sources/Features/Settings/ConfigurationErrorsView.swift index 4d016d4e2..dabfd1a3b 100644 --- a/macos/Sources/Features/Settings/ConfigurationErrorsView.swift +++ b/macos/Sources/Features/Settings/ConfigurationErrorsView.swift @@ -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: View { + @ObservedObject var model: ViewModel var body: some View { VStack { diff --git a/macos/Sources/Features/Primary Window/ErrorView.swift b/macos/Sources/Features/Terminal/ErrorView.swift similarity index 100% rename from macos/Sources/Features/Primary Window/ErrorView.swift rename to macos/Sources/Features/Terminal/ErrorView.swift diff --git a/macos/Sources/Features/Terminal/Terminal.xib b/macos/Sources/Features/Terminal/Terminal.xib new file mode 100644 index 000000000..f0f0937db --- /dev/null +++ b/macos/Sources/Features/Terminal/Terminal.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift new file mode 100644 index 000000000..6a4ae7afb --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -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) + } + } +} diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift new file mode 100644 index 000000000..e904630be --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -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) + } +} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift new file mode 100644 index 000000000..d3b1a66dc --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -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: 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 + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.SplitNode.swift b/macos/Sources/Ghostty/Ghostty.SplitNode.swift new file mode 100644 index 000000000..beba0ed8d --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.SplitNode.swift @@ -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] = [ + .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: 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 + } + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift similarity index 55% rename from macos/Sources/Ghostty/Ghostty.SplitView.swift rename to macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index a7529c7bb..2ca66f6d9 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -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] = [ - .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: 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, - 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 = 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 = 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 { + 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 { + 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 diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 0252b5a42..818520aa3 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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 { diff --git a/macos/Sources/Helpers/FullScreenHandler.swift b/macos/Sources/Helpers/FullScreenHandler.swift index 28bf83149..a1caf8069 100644 --- a/macos/Sources/Helpers/FullScreenHandler.swift +++ b/macos/Sources/Helpers/FullScreenHandler.swift @@ -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 diff --git a/macos/Sources/Helpers/HostingWindow.swift b/macos/Sources/Helpers/HostingWindow.swift new file mode 100644 index 000000000..a23687439 --- /dev/null +++ b/macos/Sources/Helpers/HostingWindow.swift @@ -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 } + } +} diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index 910430b95..f62121009 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -94,39 +94,39 @@ - + - + - + - + - + - + @@ -139,26 +139,26 @@ - + - + - + - + @@ -203,7 +203,7 @@ - + @@ -216,19 +216,19 @@ - + - + - + @@ -238,25 +238,25 @@ - + - + - + - + diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index d66682528..c4ae49e88 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -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();