From 9b134a60c1cf4c7996e4a03390bf3079e1240d39 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Oct 2023 21:42:09 -0700 Subject: [PATCH 01/33] apprt/embedded: add API to detect if a single surface needs quit confirm --- include/ghostty.h | 1 + src/apprt/embedded.zig | 5 +++++ 2 files changed, 6 insertions(+) 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/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(); From 981efb11dbed864060db1646609e7406ee942017 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Oct 2023 21:42:55 -0700 Subject: [PATCH 02/33] macos: add HostingWindow helper --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++++ macos/Sources/Helpers/HostingWindow.swift | 14 ++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 macos/Sources/Helpers/HostingWindow.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index f493b2072..e20b635aa 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ 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 */; }; 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 */; }; @@ -60,6 +61,7 @@ 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 = ""; }; 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 = ""; }; @@ -117,6 +119,7 @@ children = ( A5CEAFFE29C2410700646FDA /* Backport.swift */, 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, + A59630962AEE163600D64628 /* HostingWindow.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, A5CEAFDA29B8005900646FDA /* SplitView */, ); @@ -296,6 +299,7 @@ 85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, + A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, 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 } + } +} From cd18f3455bcbfcee920ed2dda3330586b1539605 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Oct 2023 21:43:32 -0700 Subject: [PATCH 03/33] macos: add needsConfirmQuit helper to surface and split structs --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 16 ++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 7 +++++++ 2 files changed, 23 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index a7529c7bb..3d675ff35 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -96,6 +96,22 @@ extension Ghostty { 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 { diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 0252b5a42..5ad74a8b5 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? { From 0ca69cbc983b85840905429ee10a706879df4fd4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Oct 2023 21:50:59 -0700 Subject: [PATCH 04/33] macos: update xib version --- macos/Sources/Features/Settings/ConfigurationErrors.xib | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 @@ - + - + From 301837738904af52b621356a7fea5ed490f8e714 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Oct 2023 22:22:19 -0700 Subject: [PATCH 05/33] macos: wip --- macos/Ghostty.xcodeproj/project.pbxproj | 20 ++++++ macos/Sources/AppDelegate.swift | 6 +- macos/Sources/Features/Terminal/Terminal.xib | 31 +++++++++ .../Terminal/TerminalController.swift | 50 ++++++++++++++ .../Features/Terminal/TerminalView.swift | 65 +++++++++++++++++++ 5 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Features/Terminal/Terminal.xib create mode 100644 macos/Sources/Features/Terminal/TerminalController.swift create mode 100644 macos/Sources/Features/Terminal/TerminalView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index e20b635aa..a525abd94 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -27,6 +27,9 @@ 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 */; }; 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 */; }; @@ -62,6 +65,9 @@ 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 = ""; }; 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 = ""; }; @@ -97,6 +103,7 @@ children = ( A56D58872ACDE6BE00508D2C /* Services */, A53426372A7DC53A00EBB7A2 /* Primary Window */, + A59630982AEE1C4400D64628 /* Terminal */, A534263E2A7DCC5800EBB7A2 /* Settings */, ); path = Features; @@ -173,6 +180,16 @@ path = Services; sourceTree = ""; }; + A59630982AEE1C4400D64628 /* Terminal */ = { + isa = PBXGroup; + children = ( + A59630992AEE1C6400D64628 /* Terminal.xib */, + A596309B2AEE1C9E00D64628 /* TerminalController.swift */, + A596309D2AEE1D6C00D64628 /* TerminalView.swift */, + ); + path = Terminal; + sourceTree = ""; + }; A5A1F8862A489D7400D1E8BC /* Resources */ = { isa = PBXGroup; children = ( @@ -280,6 +297,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 */, @@ -295,6 +313,7 @@ buildActionMask = 2147483647; files = ( A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, + A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A53426392A7DC55C00EBB7A2 /* PrimaryWindowManager.swift in Sources */, 85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, @@ -318,6 +337,7 @@ 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..9c3afac13 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -73,11 +73,15 @@ 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() + // TODO: remove when TerminalController is done + // windowManager.addInitialWindow() // Initial config loading configDidReload(ghostty) + let c = TerminalController(ghostty) + c.showWindow(self) + // Register our service provider. This must happen after everything // else is initialized. NSApp.servicesProvider = ServiceProvider() 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..6fb3c6af6 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -0,0 +1,50 @@ +import Foundation +import Cocoa +import SwiftUI +import Combine + +class TerminalController: NSWindowController, NSWindowDelegate { + override var windowNibName: NSNib.Name? { "Terminal" } + + /// The app instance that this terminal view will represent. + let ghostty: Ghostty.AppState + + init(_ ghostty: Ghostty.AppState) { + self.ghostty = ghostty + super.init(window: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported for this view") + } + + //MARK: - NSWindowController + + override func windowWillLoad() { + // We want every new terminal window to cascade so they don't directly overlap. + shouldCascadeWindows = true + } + + override func windowDidLoad() { + guard let window = window else { return } + + // 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 + )) + } + + //MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + } +} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift new file mode 100644 index 000000000..1edbb3ef9 --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import GhosttyKit + +struct TerminalView: View { + @ObservedObject var ghostty: Ghostty.AppState + + // 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: nil) + .ghosttyApp(ghostty.app!) + .ghosttyConfig(ghostty.config!) + .focused($focused) + .onAppear { self.focused = true } + } + } + } + + static func closeWindow() { + guard let currentWindow = NSApp.keyWindow else { return } + currentWindow.close() + } +} From 704c303cd140f598b3ae5f78db2bb7d7e697df35 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 29 Oct 2023 22:06:32 -0700 Subject: [PATCH 06/33] macos: working on the new terminalmanager --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ macos/Sources/AppDelegate.swift | 33 ++++------ .../Features/Services/ServiceProvider.swift | 13 +--- .../Terminal/TerminalController.swift | 21 ++++++- .../Features/Terminal/TerminalManager.swift | 62 +++++++++++++++++++ .../Features/Terminal/TerminalView.swift | 35 +++++++++-- 6 files changed, 130 insertions(+), 38 deletions(-) create mode 100644 macos/Sources/Features/Terminal/TerminalManager.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a525abd94..d6d85d1ac 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 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 */; }; 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 */; }; @@ -68,6 +69,7 @@ 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 = ""; }; 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 = ""; }; @@ -184,6 +186,7 @@ isa = PBXGroup; children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, + A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, ); @@ -319,6 +322,7 @@ 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 */, diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 9c3afac13..26afa3f21 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,15 +73,11 @@ 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. - // TODO: remove when TerminalController is done - // windowManager.addInitialWindow() + terminalManager.newWindow() // Initial config loading configDidReload(ghostty) - let c = TerminalController(ghostty) - c.showWindow(self) - // Register our service provider. This must happen after everything // else is initialized. NSApp.servicesProvider = ServiceProvider() @@ -151,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 } @@ -174,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 } @@ -258,7 +247,7 @@ 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() + //windowManager.relabelTabs() // Config could change window appearance syncAppearance() @@ -308,7 +297,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. @@ -316,7 +305,7 @@ 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. 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/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 6fb3c6af6..d8a0c0d64 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -3,7 +3,7 @@ import Cocoa import SwiftUI import Combine -class TerminalController: NSWindowController, NSWindowDelegate { +class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate { override var windowNibName: NSNib.Name? { "Terminal" } /// The app instance that this terminal view will represent. @@ -12,6 +12,11 @@ class TerminalController: NSWindowController, NSWindowDelegate { init(_ ghostty: Ghostty.AppState) { self.ghostty = ghostty super.init(window: nil) + + // Register as observer for window-level manipulations that are best handled + // here at the controller layer rather than in the SwiftUI stack. + let center = NotificationCenter.default + } required init?(coder: NSCoder) { @@ -39,7 +44,8 @@ class TerminalController: NSWindowController, NSWindowDelegate { // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( - ghostty: self.ghostty + ghostty: self.ghostty, + delegate: self )) } @@ -47,4 +53,15 @@ class TerminalController: NSWindowController, NSWindowDelegate { func windowWillClose(_ notification: Notification) { } + + //MARK: - TerminalViewDelegate + + func titleDidChange(to: String) { + self.window?.title = to + } + + func cellSizeDidChange(to: NSSize) { + guard ghostty.windowStepResize else { return } + self.window?.contentResizeIncrements = to + } } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift new file mode 100644 index 000000000..35eb054bb --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -0,0 +1,62 @@ +import Cocoa + +/// Manages a set of terminal windows. +class TerminalManager { + struct Window { + let controller: TerminalController + } + + let ghostty: Ghostty.AppState + + /// The set of windows we currently have. + private var windows: [Window] = [] + + /// 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 + } + + /// Create a new terminal window. + func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { + let c = createWindow(withBaseConfig: base) + 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 + let window = createWindow(withBaseConfig: base).window! + parent.addTabbedWindow(window, ordered: .above) + window.makeKeyAndOrderFront(self) + } + + /// Creates a window controller, adds it to our managed list, and returns it. + func createWindow(withBaseConfig: Ghostty.SurfaceConfiguration?) -> TerminalController { + // Initialize our controller to load the window + let c = TerminalController(ghostty) + + // Keep track of every window we manage + windows.append(Window(controller: c)) + + return c + } +} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 1edbb3ef9..e10bf0b29 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -1,12 +1,33 @@ import SwiftUI import GhosttyKit +protocol TerminalViewDelegate: AnyObject { + /// 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) +} + +extension TerminalViewDelegate { + func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {} + func titleDidChange(to: String) {} + func cellSizeDidChange(to: NSSize) {} +} + struct TerminalView: View { @ObservedObject var ghostty: Ghostty.AppState + // An optional delegate to receive information about terminal changes. + weak var delegate: 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 @@ -38,10 +59,6 @@ struct TerminalView: View { 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. @@ -54,6 +71,16 @@ struct TerminalView: View { .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) + } } } } From bee0f25d5446e5a26b9f3e267f4ca6c8ddac7fe2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 29 Oct 2023 22:22:20 -0700 Subject: [PATCH 07/33] macos: new terminal view new tab/window --- macos/Sources/AppDelegate.swift | 3 +- .../Terminal/TerminalController.swift | 14 +++++ .../Features/Terminal/TerminalManager.swift | 51 ++++++++++++++++++- 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 26afa3f21..a508622df 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -233,8 +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 + return terminalManager.focusedSurface?.surface } private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index d8a0c0d64..80bb0a7f0 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -9,6 +9,9 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele /// The app instance that this terminal view will represent. let ghostty: Ghostty.AppState + /// The currently focused surface. + var focusedSurface: Ghostty.SurfaceView? = nil + init(_ ghostty: Ghostty.AppState) { self.ghostty = ghostty super.init(window: nil) @@ -49,6 +52,13 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele )) } + // 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 func windowWillClose(_ notification: Notification) { @@ -56,6 +66,10 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele //MARK: - TerminalViewDelegate + func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { + self.focusedSurface = to + } + func titleDidChange(to: String) { self.window?.title = to } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 35eb054bb..ca0c0d3fe 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -1,4 +1,5 @@ import Cocoa +import SwiftUI /// Manages a set of terminal windows. class TerminalManager { @@ -8,6 +9,9 @@ class TerminalManager { 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] = [] @@ -26,6 +30,30 @@ class TerminalManager { 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, + name: Ghostty.Notification.ghosttyNewTab, + object: nil) + center.removeObserver( + self, + name: Ghostty.Notification.ghosttyNewWindow, + object: nil) } /// Create a new terminal window. @@ -43,6 +71,11 @@ class TerminalManager { 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) @@ -50,7 +83,7 @@ class TerminalManager { } /// Creates a window controller, adds it to our managed list, and returns it. - func createWindow(withBaseConfig: Ghostty.SurfaceConfiguration?) -> TerminalController { + private func createWindow(withBaseConfig: Ghostty.SurfaceConfiguration?) -> TerminalController { // Initialize our controller to load the window let c = TerminalController(ghostty) @@ -59,4 +92,20 @@ class TerminalManager { return c } + + @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) + } } From b8a24e8bba97f288a4368ac7ccb0c0b664db5255 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 09:19:13 -0700 Subject: [PATCH 08/33] macos: toggle fullscreen --- .../Terminal/TerminalController.swift | 39 +++++++++++++++++-- macos/Sources/Helpers/FullScreenHandler.swift | 3 +- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 80bb0a7f0..7dbe98313 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1,7 +1,7 @@ import Foundation import Cocoa import SwiftUI -import Combine +import GhosttyKit class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate { override var windowNibName: NSNib.Name? { "Terminal" } @@ -12,20 +12,31 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele /// The currently focused surface. var focusedSurface: Ghostty.SurfaceView? = nil + /// Fullscreen state management. + private let fullscreenHandler = FullScreenHandler() + init(_ ghostty: Ghostty.AppState) { self.ghostty = ghostty super.init(window: nil) - // Register as observer for window-level manipulations that are best handled - // here at the controller layer rather than in the SwiftUI stack. let center = NotificationCenter.default - + center.addObserver( + self, + selector: #selector(onToggleFullscreen), + name: Ghostty.Notification.ghosttyToggleFullscreen, + 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() { @@ -78,4 +89,24 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele guard ghostty.windowStepResize else { return } self.window?.contentResizeIncrements = to } + + //MARK: - Notifications + + @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/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 From 8a03dcbb5bdf8aff49e36c7d0e8fca6e184e4137 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 09:24:06 -0700 Subject: [PATCH 09/33] macos: goto tab works --- .../Terminal/TerminalController.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 7dbe98313..664e7ce38 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -25,6 +25,11 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele 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) { @@ -92,6 +97,45 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele //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 } From 481d12840575a90aaee256b31b31b93859bea7e6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 09:50:52 -0700 Subject: [PATCH 10/33] macos: tab labels, detect window close --- macos/Sources/AppDelegate.swift | 2 +- .../Features/Terminal/TerminalManager.swift | 71 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index a508622df..9fcd9ee5e 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -246,7 +246,7 @@ 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() diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index ca0c0d3fe..77b7677eb 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -1,10 +1,13 @@ import Cocoa import SwiftUI +import GhosttyKit +import Combine /// Manages a set of terminal windows. class TerminalManager { struct Window { let controller: TerminalController + let closePublisher: AnyCancellable } let ghostty: Ghostty.AppState @@ -56,6 +59,8 @@ class TerminalManager { object: nil) } + // MARK: - Window Management + /// Create a new terminal window. func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { let c = createWindow(withBaseConfig: base) @@ -79,6 +84,7 @@ class TerminalManager { // 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) } @@ -87,12 +93,75 @@ class TerminalManager { // Initialize our controller to load the window let c = TerminalController(ghostty) + // 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)) + 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() + } + + /// 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 From 61451942e83e6bdef4910d0bfa7d76b381ef3bd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 09:53:59 -0700 Subject: [PATCH 11/33] macos: implement last surface close to close window --- .../Features/Terminal/TerminalController.swift | 4 ++++ macos/Sources/Features/Terminal/TerminalView.swift | 11 +++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 664e7ce38..95bb035cb 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -95,6 +95,10 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele self.window?.contentResizeIncrements = to } + func lastSurfaceDidClose() { + self.window?.close() + } + //MARK: - Notifications @objc private func onGotoTab(notification: SwiftUI.Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index e10bf0b29..727a99a83 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -10,12 +10,16 @@ protocol TerminalViewDelegate: AnyObject { /// The cell size changed. func cellSizeDidChange(to: NSSize) + + /// The last surface closed so there are no active surfaces. + func lastSurfaceDidClose() } extension TerminalViewDelegate { func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {} func titleDidChange(to: String) {} func cellSizeDidChange(to: NSSize) {} + func lastSurfaceDidClose() {} } struct TerminalView: View { @@ -66,7 +70,7 @@ struct TerminalView: View { DebugBuildWarningView() } - Ghostty.TerminalSplit(onClose: Self.closeWindow, baseConfig: nil) + Ghostty.TerminalSplit(onClose: onClose, baseConfig: nil) .ghosttyApp(ghostty.app!) .ghosttyConfig(ghostty.config!) .focused($focused) @@ -85,8 +89,7 @@ struct TerminalView: View { } } - static func closeWindow() { - guard let currentWindow = NSApp.keyWindow else { return } - currentWindow.close() + func onClose() { + self.delegate?.lastSurfaceDidClose() } } From ed1741730e7551ecea8ba9137485f01d1bd2e074 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 11:53:05 -0700 Subject: [PATCH 12/33] macos: base config plumbed through --- macos/Sources/Features/Terminal/TerminalController.swift | 9 +++++++-- macos/Sources/Features/Terminal/TerminalManager.swift | 4 ++-- macos/Sources/Features/Terminal/TerminalView.swift | 5 ++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 95bb035cb..482946f69 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -9,14 +9,18 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele /// The app instance that this terminal view will represent. let ghostty: Ghostty.AppState + /// The base configuration for the new window + let baseConfig: Ghostty.SurfaceConfiguration? + /// The currently focused surface. var focusedSurface: Ghostty.SurfaceView? = nil /// Fullscreen state management. private let fullscreenHandler = FullScreenHandler() - init(_ ghostty: Ghostty.AppState) { + init(_ ghostty: Ghostty.AppState, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { self.ghostty = ghostty + self.baseConfig = base super.init(window: nil) let center = NotificationCenter.default @@ -64,7 +68,8 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, - delegate: self + delegate: self, + baseConfig: baseConfig )) } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 77b7677eb..0613abcb9 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -89,9 +89,9 @@ class TerminalManager { } /// Creates a window controller, adds it to our managed list, and returns it. - private func createWindow(withBaseConfig: Ghostty.SurfaceConfiguration?) -> TerminalController { + private func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration?) -> TerminalController { // Initialize our controller to load the window - let c = TerminalController(ghostty) + 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( diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 727a99a83..576e394af 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -28,6 +28,9 @@ struct TerminalView: View { // An optional delegate to receive information about terminal changes. weak var delegate: TerminalViewDelegate? = nil + // If this is set, this is the base configuration that we build our surface out of. + let baseConfig: Ghostty.SurfaceConfiguration? + // This seems like a crutch after switching from SwiftUI to AppKit lifecycle. @FocusState private var focused: Bool @@ -70,7 +73,7 @@ struct TerminalView: View { DebugBuildWarningView() } - Ghostty.TerminalSplit(onClose: onClose, baseConfig: nil) + Ghostty.TerminalSplit(onClose: onClose, baseConfig: baseConfig) .ghosttyApp(ghostty.app!) .ghosttyConfig(ghostty.config!) .focused($focused) From 05fd3f25b3a7d9a9124e629cb111d73f4b397f1a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 11:58:23 -0700 Subject: [PATCH 13/33] macos: clear content view on window close --- macos/Sources/Features/Terminal/TerminalController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 482946f69..6a26dbb04 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -83,6 +83,10 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele //MARK: - NSWindowDelegate 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: - TerminalViewDelegate From 1b0fea044f4c3407d6db2035c184b0a041bbc6df Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 12:01:10 -0700 Subject: [PATCH 14/33] macos: move debug view --- .../Features/Primary Window/PrimaryView.swift | 31 ------------------- .../Features/Terminal/TerminalView.swift | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift index 9cff1c93f..f58a168f3 100644 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ b/macos/Sources/Features/Primary Window/PrimaryView.swift @@ -156,34 +156,3 @@ struct PrimaryView: View { } } } - -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/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 576e394af..9a2f429c1 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -96,3 +96,34 @@ struct TerminalView: View { self.delegate?.lastSurfaceDidClose() } } + +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 + } + } +} From c86faa37c296e87575a25d5050c182b9aa7c2d3e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 14:31:04 -0700 Subject: [PATCH 15/33] macos: terminal controller owns split data --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + .../Terminal/TerminalController.swift | 14 +- .../Features/Terminal/TerminalView.swift | 18 +- macos/Sources/Ghostty/Ghostty.SplitView.swift | 2 +- .../Ghostty/Ghostty.TerminalSplit.swift | 366 ++++++++++++++++++ 5 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 macos/Sources/Ghostty/Ghostty.TerminalSplit.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index d6d85d1ac..f7a7cd625 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 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 */; }; 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 */; }; @@ -70,6 +71,7 @@ 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 = ""; }; 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 = ""; }; @@ -170,6 +172,7 @@ A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, + A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, ); path = Ghostty; sourceTree = ""; @@ -328,6 +331,7 @@ A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, + A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, A5FECBD729D1FC3900022361 /* PrimaryView.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 6a26dbb04..b462e97a5 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -3,7 +3,7 @@ import Cocoa import SwiftUI import GhosttyKit -class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate { +class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel { override var windowNibName: NSNib.Name? { "Terminal" } /// The app instance that this terminal view will represent. @@ -14,6 +14,9 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele /// The currently focused surface. var focusedSurface: Ghostty.SurfaceView? = nil + + /// The surface tree for this window. + @Published var surfaceTree: Ghostty.SplitNode? = nil /// Fullscreen state management. private let fullscreenHandler = FullScreenHandler() @@ -23,6 +26,11 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele self.baseConfig = base 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, @@ -68,8 +76,8 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, - delegate: self, - baseConfig: baseConfig + viewModel: self, + delegate: self )) } diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 9a2f429c1..f6777458b 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -1,7 +1,7 @@ import SwiftUI import GhosttyKit -protocol TerminalViewDelegate: AnyObject { +protocol TerminalViewDelegate: AnyObject, ObservableObject { /// Called when the currently focused surface changed. This can be nil. func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) @@ -15,6 +15,10 @@ protocol TerminalViewDelegate: AnyObject { func lastSurfaceDidClose() } +protocol TerminalViewModel: ObservableObject { + var surfaceTree: Ghostty.SplitNode? { get set } +} + extension TerminalViewDelegate { func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {} func titleDidChange(to: String) {} @@ -22,14 +26,14 @@ extension TerminalViewDelegate { func lastSurfaceDidClose() {} } -struct TerminalView: View { +struct TerminalView: View { @ObservedObject var ghostty: Ghostty.AppState - // An optional delegate to receive information about terminal changes. - weak var delegate: TerminalViewDelegate? = nil + // The required view model + @ObservedObject var viewModel: ViewModel - // If this is set, this is the base configuration that we build our surface out of. - let baseConfig: Ghostty.SurfaceConfiguration? + // 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 @@ -73,7 +77,7 @@ struct TerminalView: View { DebugBuildWarningView() } - Ghostty.TerminalSplit(onClose: onClose, baseConfig: baseConfig) + Ghostty.TerminalSplit2(node: $viewModel.surfaceTree) .ghosttyApp(ghostty.app!) .ghosttyConfig(ghostty.config!) .focused($focused) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 3d675ff35..3f0b96b61 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -8,7 +8,7 @@ extension Ghostty { struct TerminalSplit: View { let onClose: (() -> Void)? let baseConfig: SurfaceConfiguration? - + @Environment(\.ghosttyApp) private var app /// Non-nil if one of the surfaces in the split tree is currently "zoomed." A zoomed surface diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift new file mode 100644 index 000000000..941bceb78 --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -0,0 +1,366 @@ +import SwiftUI +import GhosttyKit + +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 TerminalSplit2: View { + /// 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 { + ZStack { + TerminalSplitRoot2( + 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) + } + } + .focusedValue(\.ghosttySurfaceZoomed, zoomedSurface != 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 TerminalSplitRoot2: View { + /// 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 + /// this one. + @Binding var zoomedSurface: SurfaceView? + + @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? + + var body: some View { + let center = NotificationCenter.default + let pubZoom = center.publisher(for: Notification.didToggleSplitZoom) + + // If we're zoomed, we don't render anything, we are transparent. This + // ensures that the View stays around so we don't lose our state, but + // also that the zoomed view on top can see through if background transparency + // is enabled. + if (zoomedSurface == nil) { + ZStack { + switch (node) { + case nil: + Color(.clear) + + case .noSplit(let leaf): + TerminalSplitLeaf2( + leaf: leaf, + neighbors: .empty, + node: $node + ) + + case .horizontal(let container): + TerminalSplitContainer2( + direction: .horizontal, + neighbors: .empty, + node: $node, + container: container + ) + .onReceive(pubZoom) { onZoom(notification: $0) } + + case .vertical(let container): + TerminalSplitContainer2( + direction: .vertical, + neighbors: .empty, + node: $node, + container: container + ) + .onReceive(pubZoom) { onZoom(notification: $0) } + } + } + .navigationTitle(surfaceTitle ?? "Ghostty") + } else { + // On these events we want to reset the split state and call it. + let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!) + let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: zoomedSurface!) + let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: zoomedSurface!) + + ZStack {} + .onReceive(pubZoom) { onZoomReset(notification: $0) } + .onReceive(pubSplit) { onZoomReset(notification: $0) } + .onReceive(pubClose) { onZoomReset(notification: $0) } + .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 { + preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist") + } + + // 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) ?? false else { return } + + // We are in the zoomed state. + zoomedSurface = surfaceView + + // See onZoomReset, same logic. + DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) } + } + + func onZoomReset(notification: SwiftUI.Notification) { + // Make sure the notification has a surface and that this window owns the surface. + guard let surfaceView = notification.object as? SurfaceView else { return } + guard zoomedSurface == surfaceView else { return } + + // We are now unzoomed + zoomedSurface = nil + + // We need to stay focused on this view, but the view is going to change + // superviews. We need to do this async so it happens on the next event loop + // tick. + DispatchQueue.main.async { + Ghostty.moveFocus(to: surfaceView) + + // If the notification is not a toggle zoom notification, we want to re-publish + // it after a short delay so that the split tree has a chance to re-establish + // so the proper view gets this notification. + if (notification.name != Notification.didToggleSplitZoom) { + // We have to wait ANOTHER tick since we just established. + DispatchQueue.main.async { + NotificationCenter.default.post(notification) + } + } + } + } + } + + /// A noSplit leaf node of a split tree. + private struct TerminalSplitLeaf2: View { + /// The leaf to draw the surface for. + let leaf: SplitNode.Leaf + + /// The neighbors, used for navigation. + let neighbors: SplitNode.Neighbors + + /// 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 + let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface) + let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface) + let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface) + + InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty()) + .onReceive(pub) { onNewSplit(notification: $0) } + .onReceive(pubClose) { onClose(notification: $0) } + .onReceive(pubFocus) { onMoveFocus(notification: $0) } + } + + private func onClose(notification: SwiftUI.Notification) { + var processAlive = false + if let valueAny = notification.userInfo?["process_alive"] { + if let value = valueAny as? Bool { + processAlive = value + } + } + + // If the child process is not alive, then we exit immediately + guard processAlive else { + 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 { + node = nil + return + } + + // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog + // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that + // confirmationDialog allows the user to Cmd-W close the alert, but when doing + // so SwiftUI does not update any of the bindings to note that window is no longer + // being shown, and provides no callback to detect this. + 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 + switch (response) { + case .alertFirstButtonReturn: + node = nil + + default: + break + } + }) + } + + private func onNewSplit(notification: SwiftUI.Notification) { + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? SurfaceConfiguration + + // Determine our desired direction + guard let directionAny = notification.userInfo?["direction"] else { return } + guard let direction = directionAny as? ghostty_split_direction_e else { return } + var splitDirection: SplitViewDirection + switch (direction) { + case GHOSTTY_SPLIT_RIGHT: + splitDirection = .horizontal + + case GHOSTTY_SPLIT_DOWN: + splitDirection = .vertical + + default: + return + } + + // Setup our new container since we are now split + let container = SplitNode.Container(from: leaf, baseConfig: config) + + // Depending on the direction, change the parent node. This will trigger + // the parent to relayout our views. + switch (splitDirection) { + case .horizontal: + node = .horizontal(container) + case .vertical: + node = .vertical(container) + } + + // See moveFocus comment, we have to run this whenever split changes. + 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. + private func onMoveFocus(notification: SwiftUI.Notification) { + // Determine our desired direction + guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return } + guard let direction = directionAny as? SplitFocusDirection else { return } + guard let next = neighbors.get(direction: direction) else { return } + Ghostty.moveFocus( + to: next.preferredFocus(direction) + ) + } + } + + /// This represents a split view that is in the horizontal or vertical split state. + private struct TerminalSplitContainer2: View { + let direction: SplitViewDirection + let neighbors: SplitNode.Neighbors + @Binding var node: SplitNode? + @StateObject var container: SplitNode.Container + + var body: some View { + SplitView(direction, left: { + let neighborKey: WritableKeyPath = direction == .horizontal ? \.right : \.bottom + + TerminalSplitNested2( + node: closeableTopLeft(), + neighbors: neighbors.update([ + neighborKey: container.bottomRight, + \.next: container.bottomRight, + ]) + ) + }, right: { + let neighborKey: WritableKeyPath = direction == .horizontal ? \.left : \.top + + TerminalSplitNested2( + node: closeableBottomRight(), + neighbors: neighbors.update([ + neighborKey: container.topLeft, + \.previous: container.topLeft, + ]) + ) + }) + } + + 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 + + Ghostty.moveFocus(to: node!.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 + + Ghostty.moveFocus(to: node!.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 TerminalSplitNested2: View { + @Binding var node: SplitNode? + let neighbors: SplitNode.Neighbors + + var body: some View { + switch (node) { + case nil: + Color(.clear) + + case .noSplit(let leaf): + TerminalSplitLeaf2( + leaf: leaf, + neighbors: neighbors, + node: $node + ) + + case .horizontal(let container): + TerminalSplitContainer2( + direction: .horizontal, + neighbors: neighbors, + node: $node, + container: container + ) + + case .vertical(let container): + TerminalSplitContainer2( + direction: .vertical, + neighbors: neighbors, + node: $node, + container: container + ) + } + } + } +} From b40245f01deb760233568912b3279359a34fec83 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 14:36:20 -0700 Subject: [PATCH 16/33] macos: remove old primary window stuff --- macos/Ghostty.xcodeproj/project.pbxproj | 26 +- .../Features/Primary Window/PrimaryView.swift | 158 ------------- .../Primary Window/PrimaryWindow.swift | 65 ----- .../PrimaryWindowController.swift | 38 --- .../Primary Window/PrimaryWindowManager.swift | 223 ------------------ .../ErrorView.swift | 0 .../Terminal/TerminalController.swift | 12 +- macos/Sources/Ghostty/SurfaceView.swift | 7 +- 8 files changed, 14 insertions(+), 515 deletions(-) delete mode 100644 macos/Sources/Features/Primary Window/PrimaryView.swift delete mode 100644 macos/Sources/Features/Primary Window/PrimaryWindow.swift delete mode 100644 macos/Sources/Features/Primary Window/PrimaryWindowController.swift delete mode 100644 macos/Sources/Features/Primary Window/PrimaryWindowManager.swift rename macos/Sources/Features/{Primary Window => Terminal}/ErrorView.swift (100%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index f7a7cd625..012b1631d 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -8,12 +8,9 @@ /* 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 */; }; @@ -43,17 +40,13 @@ 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 = ""; }; @@ -86,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 */ @@ -106,25 +98,12 @@ 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 = ( @@ -192,6 +171,7 @@ A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, + A535B9D9299C569B0017E2E4 /* ErrorView.swift */, ); path = Terminal; sourceTree = ""; @@ -320,8 +300,6 @@ files = ( A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, - A53426392A7DC55C00EBB7A2 /* PrimaryWindowManager.swift in Sources */, - 85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, @@ -332,7 +310,6 @@ A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, - A5FECBD729D1FC3900022361 /* PrimaryView.swift in Sources */, A5FEB3002ABB69450068369E /* main.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */, @@ -342,7 +319,6 @@ 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 */, diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift deleted file mode 100644 index f58a168f3..000000000 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ /dev/null @@ -1,158 +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) - } - } -} 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/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/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index b462e97a5..00a57997e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -21,6 +21,13 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele /// Fullscreen state management. private let fullscreenHandler = FullScreenHandler() + /// 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 self.baseConfig = base @@ -59,11 +66,14 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele override func windowWillLoad() { // We want every new terminal window to cascade so they don't directly overlap. shouldCascadeWindows = true + + // TODO: The cascade is messed up with tabs. } 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 diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 5ad74a8b5..818520aa3 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -507,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. @@ -520,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 { From f6d98ae40f9276471e48fe8005461895b41bbd09 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 14:39:26 -0700 Subject: [PATCH 17/33] macos: delete Ghostty.SplitView --- macos/Ghostty.xcodeproj/project.pbxproj | 10 +- macos/Sources/Ghostty/Ghostty.SplitNode.swift | 217 ++++++ macos/Sources/Ghostty/Ghostty.SplitView.swift | 635 ------------------ .../Ghostty/Ghostty.TerminalSplit.swift | 38 ++ 4 files changed, 260 insertions(+), 640 deletions(-) create mode 100644 macos/Sources/Ghostty/Ghostty.SplitNode.swift delete mode 100644 macos/Sources/Ghostty/Ghostty.SplitView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 012b1631d..98fd5e58d 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -17,7 +17,6 @@ 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 */; }; @@ -29,6 +28,7 @@ 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 */; }; @@ -53,7 +53,6 @@ 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 = ""; }; @@ -65,6 +64,7 @@ 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 = ""; }; @@ -149,9 +149,9 @@ A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */, A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */, A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */, - A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */, - A55685DF29A03A9F004303CE /* AppError.swift */, + A59630A32AF059BB00D64628 /* Ghostty.SplitNode.swift */, A59630A12AF0415000D64628 /* Ghostty.TerminalSplit.swift */, + A55685DF29A03A9F004303CE /* AppError.swift */, ); path = Ghostty; sourceTree = ""; @@ -298,6 +298,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, @@ -312,7 +313,6 @@ 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 */, 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.SplitView.swift deleted file mode 100644 index 3f0b96b61..000000000 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ /dev/null @@ -1,635 +0,0 @@ -import SwiftUI -import GhosttyKit - -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. - struct TerminalSplit: View { - let onClose: (() -> Void)? - let baseConfig: SurfaceConfiguration? - - @Environment(\.ghosttyApp) private var app - - /// 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 - ) - - // 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) - } - } - } - - /// 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 - } - } - } - - /// 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? - - /// 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 - /// this one. - @Binding var zoomedSurface: SurfaceView? - - @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) - - // If we're zoomed, we don't render anything, we are transparent. This - // ensures that the View stays around so we don't lose our state, but - // also that the zoomed view on top can see through if background transparency - // is enabled. - if (zoomedSurface == nil) { - ZStack { - switch (node) { - case .noSplit(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: .empty, - node: $node, - requestClose: $requestClose - ) - .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( - direction: .horizontal, - neighbors: .empty, - node: $node, - container: container - ) - .onReceive(pubZoom) { onZoom(notification: $0) } - - case .vertical(let container): - TerminalSplitContainer( - direction: .vertical, - neighbors: .empty, - node: $node, - container: container - ) - .onReceive(pubZoom) { onZoom(notification: $0) } - } - } - .navigationTitle(surfaceTitle ?? "Ghostty") - .id(node) // Required to detect node changes - } else { - // On these events we want to reset the split state and call it. - let pubSplit = center.publisher(for: Notification.ghosttyNewSplit, object: zoomedSurface!) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: zoomedSurface!) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: zoomedSurface!) - - ZStack {} - .onReceive(pubZoom) { onZoomReset(notification: $0) } - .onReceive(pubSplit) { onZoomReset(notification: $0) } - .onReceive(pubClose) { onZoomReset(notification: $0) } - .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 { - preconditionFailure("TerminalSplitRoom must not be zoom-able if no splits exist") - } - - // 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 } - - // We are in the zoomed state. - zoomedSurface = surfaceView - - // See onZoomReset, same logic. - DispatchQueue.main.async { Ghostty.moveFocus(to: surfaceView) } - } - - func onZoomReset(notification: SwiftUI.Notification) { - // Make sure the notification has a surface and that this window owns the surface. - guard let surfaceView = notification.object as? SurfaceView else { return } - guard zoomedSurface == surfaceView else { return } - - // We are now unzoomed - zoomedSurface = nil - - // We need to stay focused on this view, but the view is going to change - // superviews. We need to do this async so it happens on the next event loop - // tick. - DispatchQueue.main.async { - Ghostty.moveFocus(to: surfaceView) - - // If the notification is not a toggle zoom notification, we want to re-publish - // it after a short delay so that the split tree has a chance to re-establish - // so the proper view gets this notification. - if (notification.name != Notification.didToggleSplitZoom) { - // We have to wait ANOTHER tick since we just established. - DispatchQueue.main.async { - NotificationCenter.default.post(notification) - } - } - } - } - } - - /// A noSplit leaf node of a split tree. - private struct TerminalSplitLeaf: View { - /// The leaf to draw the surface for. - let leaf: SplitNode.Leaf - - /// 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 - - var body: some View { - let center = NotificationCenter.default - let pub = center.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface) - let pubClose = center.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface) - let pubFocus = center.publisher(for: Notification.ghosttyFocusSplit, object: leaf.surface) - - InspectableSurface(surfaceView: leaf.surface, isSplit: !neighbors.isEmpty()) - .onReceive(pub) { onNewSplit(notification: $0) } - .onReceive(pubClose) { onClose(notification: $0) } - .onReceive(pubFocus) { onMoveFocus(notification: $0) } - } - - private func onClose(notification: SwiftUI.Notification) { - var processAlive = false - if let valueAny = notification.userInfo?["process_alive"] { - if let value = valueAny as? Bool { - processAlive = value - } - } - - // If the child process is not alive, then we exit immediately - guard processAlive else { - requestClose = true - 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 - return - } - - // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog - // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that - // confirmationDialog allows the user to Cmd-W close the alert, but when doing - // so SwiftUI does not update any of the bindings to note that window is no longer - // being shown, and provides no callback to detect this. - 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 - switch (response) { - case .alertFirstButtonReturn: - requestClose = true - - default: - break - } - }) - } - - private func onNewSplit(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? SurfaceConfiguration - - // Determine our desired direction - guard let directionAny = notification.userInfo?["direction"] else { return } - guard let direction = directionAny as? ghostty_split_direction_e else { return } - var splitDirection: SplitViewDirection - switch (direction) { - case GHOSTTY_SPLIT_RIGHT: - splitDirection = .horizontal - - case GHOSTTY_SPLIT_DOWN: - splitDirection = .vertical - - default: - return - } - - // Setup our new container since we are now split - let container = SplitNode.Container(from: leaf, baseConfig: config) - - // Depending on the direction, change the parent node. This will trigger - // the parent to relayout our views. - switch (splitDirection) { - case .horizontal: - node = .horizontal(container) - case .vertical: - node = .vertical(container) - } - - // See moveFocus comment, we have to run this whenever split changes. - 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. - private func onMoveFocus(notification: SwiftUI.Notification) { - // Determine our desired direction - guard let directionAny = notification.userInfo?[Notification.SplitDirectionKey] else { return } - 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() - ) - } - } - - /// 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 - @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, - 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, - 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()) - } - }) - } - } - - /// 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 - let neighbors: SplitNode.Neighbors - @Binding var requestClose: Bool - - var body: some View { - switch (node) { - case .noSplit(let leaf): - TerminalSplitLeaf( - leaf: leaf, - neighbors: neighbors, - node: $node, - requestClose: $requestClose - ) - - case .horizontal(let container): - TerminalSplitContainer( - direction: .horizontal, - neighbors: neighbors, - node: $node, - container: container - ) - - case .vertical(let container): - TerminalSplitContainer( - direction: .vertical, - neighbors: neighbors, - node: $node, - container: container - ) - } - } - } - - /// 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 - /// that should have it. - static func moveFocus(to: SurfaceView, from: SurfaceView? = nil) { - DispatchQueue.main.async { - // If the callback runs before the surface is attached to a view - // then the window will be nil. We just reschedule in that case. - guard let window = to.window else { - moveFocus(to: to, from: from) - return - } - - // If we had a previously focused node and its not where we're sending - // focus, make sure that we explicitly tell it to lose focus. In theory - // we should NOT have to do this but the focus callback isn't getting - // called for some reason. - if let from = from { - _ = from.resignFirstResponder() - } - - window.makeFirstResponder(to) - - // On newer versions of macOS everything above works great so we're done. - if #available(macOS 13, *) { return } - - // On macOS 12, splits do not properly gain focus. I don't know why, but - // it seems like the `focused` SwiftUI method doesn't work. We use - // NotificationCenter as a blunt force instrument to make it work. - if #available(macOS 12, *) { - NotificationCenter.default.post( - name: Notification.didBecomeFocusedSurface, - object: to - ) - } - } - } -} diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 941bceb78..f662346a4 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -363,4 +363,42 @@ 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 + /// that should have it. + static func moveFocus(to: SurfaceView, from: SurfaceView? = nil) { + DispatchQueue.main.async { + // If the callback runs before the surface is attached to a view + // then the window will be nil. We just reschedule in that case. + guard let window = to.window else { + moveFocus(to: to, from: from) + return + } + + // If we had a previously focused node and its not where we're sending + // focus, make sure that we explicitly tell it to lose focus. In theory + // we should NOT have to do this but the focus callback isn't getting + // called for some reason. + if let from = from { + _ = from.resignFirstResponder() + } + + window.makeFirstResponder(to) + + // On newer versions of macOS everything above works great so we're done. + if #available(macOS 13, *) { return } + + // On macOS 12, splits do not properly gain focus. I don't know why, but + // it seems like the `focused` SwiftUI method doesn't work. We use + // NotificationCenter as a blunt force instrument to make it work. + if #available(macOS 12, *) { + NotificationCenter.default.post( + name: Notification.didBecomeFocusedSurface, + object: to + ) + } + } + } } From c18cf207d66ddd81a9a7aaddbbab37d56f351bb8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 14:40:45 -0700 Subject: [PATCH 18/33] macos: rename all the 2-suffix --- .../Features/Terminal/TerminalView.swift | 2 +- .../Ghostty/Ghostty.TerminalSplit.swift | 28 +++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index f6777458b..c7505af33 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -77,7 +77,7 @@ struct TerminalView: View { DebugBuildWarningView() } - Ghostty.TerminalSplit2(node: $viewModel.surfaceTree) + Ghostty.TerminalSplit(node: $viewModel.surfaceTree) .ghosttyApp(ghostty.app!) .ghosttyConfig(ghostty.config!) .focused($focused) diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index f662346a4..5a2115692 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -7,7 +7,7 @@ extension Ghostty { /// split direction by splitting the terminal. /// /// This also allows one split to be "zoomed" at any time. - struct TerminalSplit2: View { + struct TerminalSplit: View { /// The current state of the root node. This can be set to nil when all surfaces are closed. @Binding var node: SplitNode? @@ -17,7 +17,7 @@ extension Ghostty { var body: some View { ZStack { - TerminalSplitRoot2( + TerminalSplitRoot( node: $node, zoomedSurface: $zoomedSurface ) @@ -36,7 +36,7 @@ extension Ghostty { /// 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 TerminalSplitRoot2: View { + private struct TerminalSplitRoot: View { /// 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? @@ -62,14 +62,14 @@ extension Ghostty { Color(.clear) case .noSplit(let leaf): - TerminalSplitLeaf2( + TerminalSplitLeaf( leaf: leaf, neighbors: .empty, node: $node ) case .horizontal(let container): - TerminalSplitContainer2( + TerminalSplitContainer( direction: .horizontal, neighbors: .empty, node: $node, @@ -78,7 +78,7 @@ extension Ghostty { .onReceive(pubZoom) { onZoom(notification: $0) } case .vertical(let container): - TerminalSplitContainer2( + TerminalSplitContainer( direction: .vertical, neighbors: .empty, node: $node, @@ -147,7 +147,7 @@ extension Ghostty { } /// A noSplit leaf node of a split tree. - private struct TerminalSplitLeaf2: View { + private struct TerminalSplitLeaf: View { /// The leaf to draw the surface for. let leaf: SplitNode.Leaf @@ -261,7 +261,7 @@ extension Ghostty { } /// This represents a split view that is in the horizontal or vertical split state. - private struct TerminalSplitContainer2: View { + private struct TerminalSplitContainer: View { let direction: SplitViewDirection let neighbors: SplitNode.Neighbors @Binding var node: SplitNode? @@ -271,7 +271,7 @@ extension Ghostty { SplitView(direction, left: { let neighborKey: WritableKeyPath = direction == .horizontal ? \.right : \.bottom - TerminalSplitNested2( + TerminalSplitNested( node: closeableTopLeft(), neighbors: neighbors.update([ neighborKey: container.bottomRight, @@ -281,7 +281,7 @@ extension Ghostty { }, right: { let neighborKey: WritableKeyPath = direction == .horizontal ? \.left : \.top - TerminalSplitNested2( + TerminalSplitNested( node: closeableBottomRight(), neighbors: neighbors.update([ neighborKey: container.topLeft, @@ -329,7 +329,7 @@ extension Ghostty { /// 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 TerminalSplitNested2: View { + private struct TerminalSplitNested: View { @Binding var node: SplitNode? let neighbors: SplitNode.Neighbors @@ -339,14 +339,14 @@ extension Ghostty { Color(.clear) case .noSplit(let leaf): - TerminalSplitLeaf2( + TerminalSplitLeaf( leaf: leaf, neighbors: neighbors, node: $node ) case .horizontal(let container): - TerminalSplitContainer2( + TerminalSplitContainer( direction: .horizontal, neighbors: neighbors, node: $node, @@ -354,7 +354,7 @@ extension Ghostty { ) case .vertical(let container): - TerminalSplitContainer2( + TerminalSplitContainer( direction: .vertical, neighbors: neighbors, node: $node, From 1fcc5812be4017c5b01ea77793c730de4ebbecd7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 14:45:48 -0700 Subject: [PATCH 19/33] macos: detect when surface tree becomes empty --- macos/Sources/Features/Terminal/TerminalController.swift | 8 +++++++- macos/Sources/Features/Terminal/TerminalView.swift | 8 -------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 00a57997e..fc0ebc7d0 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -16,7 +16,13 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele var focusedSurface: Ghostty.SurfaceView? = nil /// The surface tree for this window. - @Published var surfaceTree: Ghostty.SplitNode? = nil + @Published var surfaceTree: Ghostty.SplitNode? = nil { + didSet { + // If our surface tree becomes nil then it means all our surfaces + // have closed, so we also cloud the window. + if (surfaceTree == nil) { lastSurfaceDidClose() } + } + } /// Fullscreen state management. private let fullscreenHandler = FullScreenHandler() diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index c7505af33..163d4e575 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -10,9 +10,6 @@ protocol TerminalViewDelegate: AnyObject, ObservableObject { /// The cell size changed. func cellSizeDidChange(to: NSSize) - - /// The last surface closed so there are no active surfaces. - func lastSurfaceDidClose() } protocol TerminalViewModel: ObservableObject { @@ -23,7 +20,6 @@ extension TerminalViewDelegate { func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {} func titleDidChange(to: String) {} func cellSizeDidChange(to: NSSize) {} - func lastSurfaceDidClose() {} } struct TerminalView: View { @@ -95,10 +91,6 @@ struct TerminalView: View { } } } - - func onClose() { - self.delegate?.lastSurfaceDidClose() - } } struct DebugBuildWarningView: View { From 350a9da68b769071d73e5aacd2c8b8cf3400a92a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 14:50:54 -0700 Subject: [PATCH 20/33] macos: confirm quit with splits, tab --- .../Terminal/TerminalController.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index fc0ebc7d0..50c78dbd9 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -106,6 +106,40 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele //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 our surfaces don't require confirmation, close. + if (!node.needsConfirmQuit()) { return true } + + // We require confirmation, so show an alert. + 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 + switch (response) { + case .alertFirstButtonReturn: + window.close() + + default: + break + } + }) + + 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 From 7fe6f15d2dae8cf1cf240eaa5bb2baca81e76924 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 14:56:59 -0700 Subject: [PATCH 21/33] macos: only show alert once --- .../Terminal/TerminalController.swift | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 50c78dbd9..d6d5fd6ac 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -27,6 +27,9 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele /// 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] @@ -119,23 +122,28 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele // If our surfaces don't require confirmation, close. if (!node.needsConfirmQuit()) { return true } - // We require confirmation, so show an alert. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + + // We require confirmation, so show an alert as long as we aren't already. + if (alert == nil) { + 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 - switch (response) { - case .alertFirstButtonReturn: - window.close() - - default: - break - } - }) + 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 } From b010875176b0c121c4edd3ac74d5d8dc76e7cb4b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 14:59:19 -0700 Subject: [PATCH 22/33] macos: do not need base config --- macos/Sources/Features/Terminal/TerminalController.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index d6d5fd6ac..376e05276 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -9,9 +9,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele /// The app instance that this terminal view will represent. let ghostty: Ghostty.AppState - /// The base configuration for the new window - let baseConfig: Ghostty.SurfaceConfiguration? - /// The currently focused surface. var focusedSurface: Ghostty.SurfaceView? = nil @@ -39,7 +36,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele init(_ ghostty: Ghostty.AppState, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { self.ghostty = ghostty - self.baseConfig = base super.init(window: nil) // Initialize our initial surface. From b4c973cd2334a398d47318d1f8c7ca6b82bc245f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 15:14:20 -0700 Subject: [PATCH 23/33] macos: improved comments --- .../Features/Terminal/TerminalController.swift | 1 + .../Features/Terminal/TerminalManager.swift | 14 ++++---------- .../Features/Terminal/TerminalView.swift | 18 ++++++++++++++---- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 376e05276..2789bb4c4 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -3,6 +3,7 @@ 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" } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 0613abcb9..34acc0ec5 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -3,7 +3,8 @@ import SwiftUI import GhosttyKit import Combine -/// Manages a set of terminal windows. +/// 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 @@ -48,15 +49,8 @@ class TerminalManager { } deinit { - let center = NotificationCenter.default; - center.removeObserver( - self, - name: Ghostty.Notification.ghosttyNewTab, - object: nil) - center.removeObserver( - self, - name: Ghostty.Notification.ghosttyNewWindow, - object: nil) + let center = NotificationCenter.default + center.removeObserver(self) } // MARK: - Window Management diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 163d4e575..d3b1a66dc 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -1,6 +1,9 @@ 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?) @@ -12,16 +15,23 @@ protocol TerminalViewDelegate: AnyObject, ObservableObject { func cellSizeDidChange(to: NSSize) } -protocol TerminalViewModel: ObservableObject { - var surfaceTree: Ghostty.SplitNode? { get set } -} - +// 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 From db309a0b61e28a3e3533f1a9eb09fa2a26bb340a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 15:28:14 -0700 Subject: [PATCH 24/33] macos: fix cascade points --- .../Terminal/TerminalController.swift | 6 ++--- .../Features/Terminal/TerminalManager.swift | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 2789bb4c4..039616bb3 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -70,10 +70,8 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele //MARK: - NSWindowController override func windowWillLoad() { - // We want every new terminal window to cascade so they don't directly overlap. - shouldCascadeWindows = true - - // TODO: The cascade is messed up with tabs. + // We do NOT want to cascade because we handle this manually from the manager. + shouldCascadeWindows = false } override func windowDidLoad() { diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 34acc0ec5..e904630be 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -19,6 +19,11 @@ class TerminalManager { /// 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? { @@ -58,6 +63,10 @@ class TerminalManager { /// 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) } @@ -120,6 +129,23 @@ class TerminalManager { // 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. From 9d3a4d2a582b91deba3fbb9a507285243567e9fd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 18:26:44 -0700 Subject: [PATCH 25/33] Update macos/Sources/Features/Terminal/TerminalController.swift Co-authored-by: Gregory Anders <8965202+gpanders@users.noreply.github.com> --- macos/Sources/Features/Terminal/TerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 039616bb3..4115692f5 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -17,7 +17,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele @Published var surfaceTree: Ghostty.SplitNode? = nil { didSet { // If our surface tree becomes nil then it means all our surfaces - // have closed, so we also cloud the window. + // have closed, so we also close the window. if (surfaceTree == nil) { lastSurfaceDidClose() } } } From 3cb0846d8c1fea2cd0c2fd3a4b25d538a5d1a440 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 19:25:02 -0700 Subject: [PATCH 26/33] macos: regressed #761 --- macos/Sources/Ghostty/Ghostty.TerminalSplit.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 5a2115692..72ab873c4 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -88,6 +88,7 @@ extension Ghostty { } } .navigationTitle(surfaceTitle ?? "Ghostty") + .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!) From 09ad0f6b7b5da204aae153cb36721fe78a1e3de7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 22:06:55 -0700 Subject: [PATCH 27/33] macos: attach various menu items to first responder, terminal Fixes #758 --- macos/Sources/AppDelegate.swift | 83 ------------------- .../Terminal/TerminalController.swift | 80 ++++++++++++++++++ macos/Sources/MainMenu.xib | 30 +++---- 3 files changed, 95 insertions(+), 98 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 9fcd9ee5e..edda1eaba 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -236,11 +236,6 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp return terminalManager.focusedSurface?.surface } - private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { - guard let surface = focusedSurface() else { return } - ghostty.splitMoveFocus(surface: surface, direction: direction) - } - //MARK: - GhosttyAppStateDelegate func configDidReload(_ state: Ghostty.AppState) { @@ -311,86 +306,8 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp 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/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 4115692f5..8c1cd2b2c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -150,6 +150,86 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele self.window?.contentView = nil } + //MARK: - First Responder + + @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 toggleFullScreen(_ 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?) { diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index 910430b95..1bb51de68 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -107,26 +107,26 @@ - + - + - + - + @@ -139,26 +139,26 @@ - + - + - + - + @@ -216,19 +216,19 @@ - + - + - + @@ -238,25 +238,25 @@ - + - + - + - + From 07c4692799880f605baa78f05b9a9bf491fde596 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 22:18:20 -0700 Subject: [PATCH 28/33] macos: forgot the toggle fullscreen binding --- macos/Sources/Features/Terminal/TerminalController.swift | 2 +- macos/Sources/MainMenu.xib | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 8c1cd2b2c..adf255082 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -205,7 +205,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele ghostty.splitMoveFocus(surface: surface, direction: direction) } - @IBAction func toggleFullScreen(_ sender: Any) { + @IBAction func toggleGhosttyFullScreen(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleFullscreen(surface: surface) } diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index 1bb51de68..2c6dd6177 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -203,7 +203,7 @@ - + From 0fbb5c8c70827554b17615c0b0f74e5ac0285cf1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 22:25:39 -0700 Subject: [PATCH 29/33] macos: simpler mechanism to detect no more config errors Fixes #702 --- macos/Sources/AppDelegate.swift | 4 +-- .../ConfigurationErrorsController.swift | 30 ++++++------------- .../Settings/ConfigurationErrorsView.swift | 12 ++++---- 3 files changed, 17 insertions(+), 29 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index edda1eaba..a9ea30eeb 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -248,8 +248,8 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp // 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) } 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 { From ae104111d74b6f74f774cdb54fb65978324774a5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 22:48:35 -0700 Subject: [PATCH 30/33] macos: fix some split focus issues --- macos/Sources/Ghostty/Ghostty.TerminalSplit.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index 72ab873c4..2ca66f6d9 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -305,7 +305,12 @@ extension Ghostty { container.topLeft.close() node = container.bottomRight - Ghostty.moveFocus(to: node!.preferredFocus(), from: container.topLeft.preferredFocus()) + DispatchQueue.main.async { + Ghostty.moveFocus( + to: container.bottomRight.preferredFocus(), + from: container.topLeft.preferredFocus() + ) + } }) } @@ -322,7 +327,12 @@ extension Ghostty { container.bottomRight.close() node = container.topLeft - Ghostty.moveFocus(to: node!.preferredFocus(), from: container.bottomRight.preferredFocus()) + DispatchQueue.main.async { + Ghostty.moveFocus( + to: container.topLeft.preferredFocus(), + from: container.bottomRight.preferredFocus() + ) + } }) } } From ecd7a173846ac79f249e8893e0270b14174bce13 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 22:55:57 -0700 Subject: [PATCH 31/33] macos: new tab while a window is focused inherits --- macos/Sources/Features/Terminal/TerminalController.swift | 5 +++++ macos/Sources/MainMenu.xib | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index adf255082..830058129 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -152,6 +152,11 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele //MARK: - First Responder + @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) diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index 2c6dd6177..e569b97fe 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -100,7 +100,7 @@ - + From 6a024897a67d862e27ba5fbbbb458a3392ce3424 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 30 Oct 2023 22:57:46 -0700 Subject: [PATCH 32/33] macos: new window on existing terminal inherits properly --- macos/Sources/Features/Terminal/TerminalController.swift | 5 +++++ macos/Sources/MainMenu.xib | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 830058129..f2b9e832e 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -152,6 +152,11 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele //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) diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index e569b97fe..f62121009 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -94,7 +94,7 @@ - + From 60e1ca81f070a557e89fd0756077b9d812141fda Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 31 Oct 2023 09:41:40 -0700 Subject: [PATCH 33/33] macos: if alert is already showing, don't check if need confirm --- .../Terminal/TerminalController.swift | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index f2b9e832e..6a4ae7afb 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -114,31 +114,32 @@ class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDele // 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. - if (alert == nil) { - 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 - } - }) + 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 - } + self.alert = alert return false }