From 850bf3e9456c37b6ab61e2a1832908a5a736d977 Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Fri, 30 Jun 2023 06:33:46 +0200 Subject: [PATCH 1/8] macos: add support for non-native fullscreen mode This adds support for what's commonly referred to as "non-native fullscreen": a fullscreen-mode that doesn't use macOS' native fullscreen mechanism and thus doesn't use animations and a separate space on which to display the fullscreen window. Instead it's really fast and it allows the user to `Cmd+tab` to other windows, with the fullscreen-ed window staying in the background. Another name for it is "traditional fullscreen" since it was the default pre Mac OS X Lion, if I remember correctly. Other applications that offer macOS non-native fullscreen: - Kitty: https://sw.kovidgoyal.net/kitty/conf/#opt-kitty.macos_traditional_fullscreen - wezterm: https://wezfurlong.org/wezterm/config/lua/config/native_macos_fullscreen_mode.html - MacVim - IINA: https://github.com/iina/iina/blob/fc66b27d50d0e98b056205867055f462e87828c9/iina/MainWindowController.swift#L1401-L1423 - mpv: https://mpv.io/manual/stable/#options-native-fs - iTerm2 Adding this wasn't straightforward, as it turned out. Mainly because SwiftUI's app lifecycle management doesn't allow one to use a custom class for the windows it creates. And without custom classes we'd always get a warning when entering/leaving fullscreen mode. So what I did here is the following: - remove SwiftUI app lifecycle management - introduce `MainMenu.xib` to define the main menu via interface builder - add `GhosttyAppController` to handle requests from the app - add a `main.swift` file to boot up the app without a storyboard and without SwiftUI lifecycle management - introduce the `FullScreenHandler` to manage non-native fullscreen - this is where the "magic" is But since removing the SwiftUI lifecycle management also means removing the top-level `App` that means I had to introduce the menu (which I mentioned), but also tab and window management. So I also added the `WindowService` which manages open tabs and windows. It's based on the ideas presented in https://christiantietze.de/posts/2019/07/nswindow-tabbing-multiple-nswindowcontroller/ and essentially keeps tracks of windows. Then there's some auxilliary changes: `CustomWindow` and `WindowController` and so on. Now everything still works, in addition to non-native fullscreen: * opening/closing of tabs * opening/closing of windows * splits * `gotoTab` Worthy of note: when toggling back from non-native fullscreen to non-fullscreen I had to manually implement the logic to re-add the window back to a tabgroup. The only other app that supports tabs with non-native FS is iTerm2 and they have implemented their own tab management to keep the tab bar even in non-native FS -- that's a bit too much for me. Every other app has non-native apps and doesn't have to wory about it. --- macos/Ghostty.xcodeproj/project.pbxproj | 32 ++- .../xcshareddata/xcschemes/Ghostty.xcscheme | 84 ++++++++ macos/Sources/ContentView.swift | 27 ++- macos/Sources/CustomWindow.swift | 39 ++++ macos/Sources/FullScreenHandler.swift | 88 ++++++++ macos/Sources/Ghostty/AppState.swift | 8 +- macos/Sources/GhosttyApp.swift | 197 ------------------ macos/Sources/GhosttyAppController.swift | 138 ++++++++++++ macos/Sources/MainMenu.xib | 180 ++++++++++++++++ macos/Sources/WindowController.swift | 11 + macos/Sources/WindowService.swift | 80 +++++++ macos/Sources/main.swift | 7 + 12 files changed, 682 insertions(+), 209 deletions(-) create mode 100644 macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme create mode 100644 macos/Sources/CustomWindow.swift create mode 100644 macos/Sources/FullScreenHandler.swift delete mode 100644 macos/Sources/GhosttyApp.swift create mode 100644 macos/Sources/GhosttyAppController.swift create mode 100644 macos/Sources/MainMenu.xib create mode 100644 macos/Sources/WindowController.swift create mode 100644 macos/Sources/WindowService.swift create mode 100644 macos/Sources/main.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 2b29f105b..2847bac6c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */; }; + 85102A1A2A6E32720084AB3E /* WindowService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85102A192A6E32720084AB3E /* WindowService.swift */; }; + 85102A1C2A6E32890084AB3E /* WindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85102A1B2A6E32890084AB3E /* WindowController.swift */; }; + 852655222A597CA900E4F7AD /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852655212A597CA900E4F7AD /* main.swift */; }; + 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; + 85DE1C922A6A3DCA00493853 /* CustomWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DE1C912A6A3DCA00493853 /* CustomWindow.swift */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; A545D1A22A5772CE006E0AE4 /* shell-integration in Resources */ = {isa = PBXBuildFile; fileRef = A545D1A12A5772CE006E0AE4 /* shell-integration */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; @@ -17,7 +23,7 @@ A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; - A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyApp.swift */; }; + A5B30535299BEAAA0047F10C /* GhosttyAppController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyAppController.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; @@ -27,6 +33,12 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenHandler.swift; sourceTree = ""; }; + 85102A192A6E32720084AB3E /* WindowService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowService.swift; sourceTree = ""; }; + 85102A1B2A6E32890084AB3E /* WindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowController.swift; sourceTree = ""; }; + 852655212A597CA900E4F7AD /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 857F63802A5E64F200CA4815 /* MainMenu.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = ""; }; + 85DE1C912A6A3DCA00493853 /* CustomWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWindow.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 = ""; }; @@ -38,7 +50,7 @@ A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A5B30534299BEAAA0047F10C /* GhosttyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyApp.swift; sourceTree = ""; }; + A5B30534299BEAAA0047F10C /* GhosttyAppController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAppController.swift; sourceTree = ""; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; @@ -67,13 +79,19 @@ A5D495A0299BEC2200DD1313 /* Preview Content */, A5CEAFDA29B8005900646FDA /* SplitView */, A55B7BB429B6F4410055DE60 /* Ghostty */, - A5B30534299BEAAA0047F10C /* GhosttyApp.swift */, + A5B30534299BEAAA0047F10C /* GhosttyAppController.swift */, + 85DE1C912A6A3DCA00493853 /* CustomWindow.swift */, + 857F63802A5E64F200CA4815 /* MainMenu.xib */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, A59444F629A2ED5200725BBA /* SettingsView.swift */, A5CEAFFE29C2410700646FDA /* Backport.swift */, A5FECBD629D1FC3900022361 /* ContentView.swift */, A5FECBD829D2010400022361 /* WindowAccessor.swift */, + 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, + 852655212A597CA900E4F7AD /* main.swift */, + 85102A192A6E32720084AB3E /* WindowService.swift */, + 85102A1B2A6E32890084AB3E /* WindowController.swift */, ); path = Sources; sourceTree = ""; @@ -204,6 +222,7 @@ A545D1A22A5772CE006E0AE4 /* shell-integration in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */, + 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -214,6 +233,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 85102A1A2A6E32720084AB3E /* WindowService.swift in Sources */, + 85DE1C922A6A3DCA00493853 /* CustomWindow.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A5FECBD729D1FC3900022361 /* ContentView.swift in Sources */, @@ -224,8 +245,11 @@ A55685E029A03A9F004303CE /* AppError.swift in Sources */, A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, - A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */, + A5B30535299BEAAA0047F10C /* GhosttyAppController.swift in Sources */, + 85102A1C2A6E32890084AB3E /* WindowController.swift in Sources */, + 852655222A597CA900E4F7AD /* main.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, + 8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */, A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme new file mode 100644 index 000000000..9fc836f02 --- /dev/null +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/ContentView.swift b/macos/Sources/ContentView.swift index e3161f599..11787bb63 100644 --- a/macos/Sources/ContentView.swift +++ b/macos/Sources/ContentView.swift @@ -5,12 +5,24 @@ struct ContentView: View { let ghostty: Ghostty.AppState // We need access to our app delegate to know if we're quitting or not. - @EnvironmentObject private var appDelegate: AppDelegate + // Make sure to use `@ObservedObject` so we can keep track of `appDelegate.confirmQuit`. + @ObservedObject var appDelegate: AppDelegate + + // We need this to report back up the app controller which surface in this view is focused. + let focusedSurfaceWrapper: FocusedSurfaceWrapper // We need access to our window to know if we're the key window to determine // if we show the quit confirmation or not. @State private var window: NSWindow? + // This handles non-native fullscreen + @State private var fsHandler = FullScreenHandler() + + // This seems like a crutch after switchign from SwiftUI to AppKit lifecycle. + @FocusState private var focused: Bool + + @FocusedValue(\.ghosttySurfaceView) private var focusedSurface + var body: some View { switch ghostty.readiness { case .loading: @@ -35,12 +47,17 @@ struct ContentView: View { }, set: { self.appDelegate.confirmQuit = $0 }) - + Ghostty.TerminalSplit(onClose: Self.closeWindow) .ghosttyApp(ghostty.app!) .background(WindowAccessor(window: $window)) .onReceive(gotoTab) { onGotoTab(notification: $0) } .onReceive(toggleFullscreen) { onToggleFullscreen(notification: $0) } + .focused($focused) + .onAppear { self.focused = true } + .onChange(of: focusedSurface) { newValue in + self.focusedSurfaceWrapper.surface = newValue?.surface + } .confirmationDialog( "Quit Ghostty?", isPresented: confirmQuitting) { @@ -63,7 +80,7 @@ struct ContentView: View { guard let currentWindow = NSApp.keyWindow else { return } currentWindow.close() } - + private func onGotoTab(notification: SwiftUI.Notification) { // Notification center indiscriminately sends to every subscriber (makes sense) // but we only want to process this once. In order to process it once lets only @@ -94,6 +111,8 @@ struct ContentView: View { guard let window = self.window else { return } guard window.isKeyWindow else { return } - window.toggleFullScreen(nil) + self.fsHandler.toggleFullscreen(window: window) + // After toggling fullscreen we need to focus the terminal again. + self.focused = true } } diff --git a/macos/Sources/CustomWindow.swift b/macos/Sources/CustomWindow.swift new file mode 100644 index 000000000..58371b40a --- /dev/null +++ b/macos/Sources/CustomWindow.swift @@ -0,0 +1,39 @@ +import Cocoa +import SwiftUI +import GhosttyKit + +// FocusedSurfaceWrapper is here so that we can pass a reference down +// the view hierarchy and keep track of which surface is focused. +class FocusedSurfaceWrapper { + var surface: ghostty_surface_t? +} + +// CustomWindow exists purely so we can override canBecomeKey and canBecomeMain. +// We need that for the non-native fullscreen. +// If we don't use `CustomWindow` we'll get warning messages in the output to say that +// `makeKeyWindow` was called and returned NO. +class CustomWindow: NSWindow { + var focusedSurfaceWrapper: FocusedSurfaceWrapper = FocusedSurfaceWrapper() + + static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate) -> CustomWindow { + let window = CustomWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, defer: false) + window.center() + window.contentView = NSHostingView(rootView: ContentView( + ghostty: ghostty, + appDelegate: appDelegate, + focusedSurfaceWrapper: window.focusedSurfaceWrapper)) + window.windowController?.shouldCascadeWindows = true + return window + } + + override var canBecomeKey: Bool { + return true + } + + override var canBecomeMain: Bool { + return true + } +} diff --git a/macos/Sources/FullScreenHandler.swift b/macos/Sources/FullScreenHandler.swift new file mode 100644 index 000000000..c3bddaab1 --- /dev/null +++ b/macos/Sources/FullScreenHandler.swift @@ -0,0 +1,88 @@ +import SwiftUI + +class FullScreenHandler { + var previousTabGroup: NSWindowTabGroup? + var previousTabGroupIndex: Int? + var previousContentFrame: NSRect? + var previousStyleMask: NSWindow.StyleMask? + var isInFullscreen: Bool = false + + func toggleFullscreen(window: NSWindow) { + if isInFullscreen { + leaveFullscreen(window: window) + isInFullscreen = false + } else { + enterFullscreen(window: window) + isInFullscreen = true + } + } + + func enterFullscreen(window: NSWindow) { + guard let screen = window.screen else { return } + guard let contentView = window.contentView else { return } + + previousTabGroup = window.tabGroup + previousTabGroupIndex = window.tabGroup?.windows.firstIndex(of: window) + + // Save previous style mask + previousStyleMask = window.styleMask + // Save previous contentViewFrame and screen + previousContentFrame = window.convertToScreen(contentView.frame) + + // Change presentation style to hide menu bar and dock + NSApp.presentationOptions = [.autoHideMenuBar, .autoHideDock] + // Turn it into borderless window + window.styleMask.insert(.borderless) + // This is important: it gives us the full screen, including the + // notch area on MacBooks. + window.styleMask.remove(.titled) + + // Set frame to screen size + window.setFrame(screen.frame, display: true) + + // Focus window + window.makeKeyAndOrderFront(nil) + } + + + func leaveFullscreen(window: NSWindow) { + guard let previousFrame = previousContentFrame else { return } + guard let previousStyleMask = previousStyleMask else { return } + + // Restore previous style + window.styleMask = previousStyleMask + + // Restore previous presentation options + NSApp.presentationOptions = [] + + // Restore frame + window.setFrame(window.frameRect(forContentRect: previousFrame), display: true) + + // If the window was previously in a tab group that isn't empty now, we re-add it + if let group = previousTabGroup, let tabIndex = previousTabGroupIndex, !group.windows.isEmpty { + var tabWindow: NSWindow? + var order: NSWindow.OrderingMode = .below + + // Index of the window before `window` + let tabIndexBefore = tabIndex-1 + if tabIndexBefore < 0 { + // If we were the first tab, we add the window *before* (.below) the first one. + tabWindow = group.windows.first + } else if tabIndexBefore < group.windows.count { + // If we weren't the first tab in the group, we add our window after + // the tab that was before it. + tabWindow = group.windows[tabIndexBefore] + order = .above + } else { + // If index is after group, add it after last window + tabWindow = group.windows.last + } + + // Add the window + tabWindow?.addTabbedWindow(window, ordered: order) + } + + // Focus window + window.makeKeyAndOrderFront(nil) + } +} diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 0d25c8c37..b763444fa 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -38,7 +38,7 @@ extension Ghostty { init() { // Initialize ghostty global state. This happens once per process. guard ghostty_init() == GHOSTTY_SUCCESS else { - GhosttyApp.logger.critical("ghostty_init failed") + GhosttyAppController.logger.critical("ghostty_init failed") readiness = .error return } @@ -68,7 +68,7 @@ extension Ghostty { // Create the ghostty app. guard let app = ghostty_app_new(&runtime_cfg, cfg) else { - GhosttyApp.logger.critical("ghostty_app_new failed") + GhosttyAppController.logger.critical("ghostty_app_new failed") readiness = .error return } @@ -87,7 +87,7 @@ extension Ghostty { static func reloadConfig() -> ghostty_config_t? { // Initialize the global configuration. guard let cfg = ghostty_config_new() else { - GhosttyApp.logger.critical("ghostty_config_new failed") + GhosttyAppController.logger.critical("ghostty_config_new failed") return nil } @@ -189,7 +189,7 @@ extension Ghostty { static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? { guard let newConfig = AppState.reloadConfig() else { - GhosttyApp.logger.warning("failed to reload configuration") + GhosttyAppController.logger.warning("failed to reload configuration") return nil } diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift deleted file mode 100644 index 85845dd02..000000000 --- a/macos/Sources/GhosttyApp.swift +++ /dev/null @@ -1,197 +0,0 @@ -import OSLog -import SwiftUI -import GhosttyKit - -@main -struct GhosttyApp: App { - static let logger = Logger( - subsystem: Bundle.main.bundleIdentifier!, - category: String(describing: GhosttyApp.self) - ) - - /// The ghostty global state. Only one per process. - @StateObject private var ghostty = Ghostty.AppState() - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - - /// The current focused Ghostty surface in this app - @FocusedValue(\.ghosttySurfaceView) private var focusedSurface - - var body: some Scene { - WindowGroup { - ContentView(ghostty: ghostty) - } - - .backport.defaultSize(width: 800, height: 600) - - .commands { - CommandGroup(after: .newItem) { - Button("New Tab", action: Self.newTab).keyboardShortcut("t", modifiers: [.command]) - Divider() - Button("Split Horizontally", action: splitHorizontally).keyboardShortcut("d", modifiers: [.command]) - Button("Split Vertically", action: splitVertically).keyboardShortcut("d", modifiers: [.command, .shift]) - Divider() - Button("Close", action: close).keyboardShortcut("w", modifiers: [.command]) - Button("Close Window", action: Self.closeWindow).keyboardShortcut("w", modifiers: [.command, .shift]) - } - - CommandGroup(before: .windowArrangement) { - Divider() - Button("Select Previous Split") { splitMoveFocus(direction: .previous) } - .keyboardShortcut("[", modifiers: .command) - Button("Select Next Split") { splitMoveFocus(direction: .next) } - .keyboardShortcut("]", modifiers: .command) - Menu("Select Split") { - Button("Select Split Above") { splitMoveFocus(direction: .top) } - .keyboardShortcut(.upArrow, modifiers: [.command, .option]) - Button("Select Split Below") { splitMoveFocus(direction: .bottom) } - .keyboardShortcut(.downArrow, modifiers: [.command, .option]) - Button("Select Split Left") { splitMoveFocus(direction: .left) } - .keyboardShortcut(.leftArrow, modifiers: [.command, .option]) - Button("Select Split Right") { splitMoveFocus(direction: .right)} - .keyboardShortcut(.rightArrow, modifiers: [.command, .option]) - } - - Divider() - } - } - - Settings { - SettingsView() - } - } - - // Create a new tab in the currently active window - static func newTab() { - guard let currentWindow = NSApp.keyWindow else { return } - guard let windowController = currentWindow.windowController else { return } - windowController.newWindowForTab(nil) - if let newWindow = NSApp.keyWindow, currentWindow != newWindow { - currentWindow.addTabbedWindow(newWindow, ordered: .above) - } - } - - static func closeWindow() { - guard let currentWindow = NSApp.keyWindow else { return } - currentWindow.close() - } - - func close() { - guard let surfaceView = focusedSurface else { - Self.closeWindow() - return - } - - guard let surface = surfaceView.surface else { return } - ghostty.requestClose(surface: surface) - } - - func splitHorizontally() { - guard let surfaceView = focusedSurface else { return } - guard let surface = surfaceView.surface else { return } - ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT) - } - - func splitVertically() { - guard let surfaceView = focusedSurface else { return } - guard let surface = surfaceView.surface else { return } - ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN) - } - - func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { - guard let surfaceView = focusedSurface else { return } - guard let surface = surfaceView.surface else { return } - ghostty.splitMoveFocus(surface: surface, direction: direction) - } -} - -class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { - @Published var confirmQuit: Bool = false - - // See CursedMenuManager for more information. - private var menuManager: CursedMenuManager? - - func applicationDidFinishLaunching(_ notification: Notification) { - UserDefaults.standard.register(defaults: [ - // Disable this so that repeated key events make it through to our terminal views. - "ApplePressAndHoldEnabled": false, - ]) - - // Create our menu manager to create some custom menu items that - // we can't create from SwiftUI. - menuManager = CursedMenuManager() - } - - func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - let windows = NSApplication.shared.windows - if (windows.isEmpty) { return .terminateNow } - - // This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't - // quite work with SwiftUI because windows are retained on close. So instead we check - // if there are any that are visible. I'm guessing this breaks under certain scenarios. - if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow } - - // If the user is shutting down, restarting, or logging out, we don't confirm quit. - if let event = NSAppleEventManager.shared().currentAppleEvent { - if let why = event.attributeDescriptor(forKeyword: AEKeyword("why?")!) { - switch (why.typeCodeValue) { - case kAEShutDown: - fallthrough - - case kAERestart: - fallthrough - - case kAEReallyLogOut: - return .terminateNow - - default: - break - } - } - } - - // We have some visible window, and all our windows will watch the confirmQuit. - confirmQuit = true - return .terminateLater - } -} - -/// SwiftUI as of macOS 13.x provides no way to manage the default menu items that are created -/// as part of a WindowGroup. This class is prefixed with "Cursed" because this is a truly cursed -/// solution to the problem and I think its quite brittle. As soon as SwiftUI supports a better option -/// we should conditionally compile for that when supported. -/// -/// The way this works is by setting up KVO on various menu objects and reacting to it. For example, -/// when SwiftUI tries to add a "Close" menu, we intercept it and delete it. Nice try! -private class CursedMenuManager { - var mainToken: NSKeyValueObservation? - var fileToken: NSKeyValueObservation? - - init() { - // If the whole menu changed we want to setup our new KVO - self.mainToken = NSApp.observe(\.mainMenu, options: .new) { app, change in - self.onNewMenu() - } - - // Initial setup - onNewMenu() - } - - private func onNewMenu() { - guard let menu = NSApp.mainMenu else { return } - guard let file = menu.item(withTitle: "File") else { return } - guard let submenu = file.submenu else { return } - fileToken = submenu.observe(\.items) { (_, _) in - let remove = ["Close", "Close All"] - - // We look for the items in reverse since we're removing only the - // ones SwiftUI inserts which are at the end. We make replacements - // which we DON'T want deleted. - let items = submenu.items.reversed() - remove.forEach { title in - if let item = items.first(where: { $0.title.caseInsensitiveCompare(title) == .orderedSame }) { - submenu.removeItem(item) - } - } - } - } -} diff --git a/macos/Sources/GhosttyAppController.swift b/macos/Sources/GhosttyAppController.swift new file mode 100644 index 000000000..cec0fa12a --- /dev/null +++ b/macos/Sources/GhosttyAppController.swift @@ -0,0 +1,138 @@ +import OSLog +import SwiftUI +import AppKit +import GhosttyKit + +class GhosttyAppController: NSObject { + @IBOutlet weak fileprivate var mainMenu: NSMenu! + + static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: AppDelegate.self) + ) + + /// The ghostty global state. Only one per process. + var ghostty: Ghostty.AppState = Ghostty.AppState() + + /// Manages windows and tabs, ensuring they're allocated/deallocated correctly + var windowService: WindowService! + + override init() { + super.init() + + // We're initialized through the MainMenu, because we're a referenced objected. + // So when we're here, we initialize the WindowService, which will open first window. + windowService = WindowService(ghostty: self.ghostty) + } + + @IBAction func newWindow(_ sender: Any?) { + windowService.addNewWindow() + } + + @IBAction func newTab(_ sender: Any?) { + windowService.addNewTab() + } + + @IBAction func closeWindow(_ sender: Any) { + guard let currentWindow = NSApp.keyWindow else { return } + currentWindow.close() + } + + @IBAction func close(_ sender: Any) { + guard let surface = focusedSurface() else { + self.closeWindow(self) + return + } + + ghostty.requestClose(surface: surface) + } + + private func focusedSurface() -> ghostty_surface_t? { + guard let window = NSApp.keyWindow as? CustomWindow else { return nil } + return window.focusedSurfaceWrapper.surface + } + + @IBAction func splitHorizontally(_ sender: Any) { + guard let surface = focusedSurface() else { return } + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT) + } + + @IBAction func splitVertically(_ sender: Any) { + guard let surface = focusedSurface() else { return } + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN) + } + + @IBAction func splitMoveFocusPrevious(_ sender: Any) { + splitMoveFocus(direction: .previous) + } + + @IBAction func splitMoveFocusNext(_ sender: Any) { + splitMoveFocus(direction: .next) + } + + @IBAction func splitMoveFocusAbove(_ sender: Any) { + splitMoveFocus(direction: .top) + } + + @IBAction func splitMoveFocusBelow(_ sender: Any) { + splitMoveFocus(direction: .bottom) + } + + @IBAction func splitMoveFocusLeft(_ sender: Any) { + splitMoveFocus(direction: .left) + } + + @IBAction func splitMoveFocusRight(_ sender: Any) { + splitMoveFocus(direction: .right) + } + + func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { + guard let surface = focusedSurface() else { return } + ghostty.splitMoveFocus(surface: surface, direction: direction) + } +} + +class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { + // confirmQuit published so other views can check whether quit needs to be confirmed. + @Published var confirmQuit: Bool = false + + func applicationDidFinishLaunching(_ notification: Notification) { + UserDefaults.standard.register(defaults: [ + // Disable this so that repeated key events make it through to our terminal views. + "ApplePressAndHoldEnabled": false, + ]) + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + let windows = NSApplication.shared.windows + if (windows.isEmpty) { return .terminateNow } + + // This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't + // quite work with SwiftUI because windows are retained on close. So instead we check + // if there are any that are visible. I'm guessing this breaks under certain scenarios. + if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow } + + // If the user is shutting down, restarting, or logging out, we don't confirm quit. + if let event = NSAppleEventManager.shared().currentAppleEvent { + if let why = event.attributeDescriptor(forKeyword: AEKeyword("why?")!) { + switch (why.typeCodeValue) { + case kAEShutDown: + fallthrough + + case kAERestart: + fallthrough + + case kAEReallyLogOut: + return .terminateNow + + default: + break + } + } + } + + // We have some visible window, and all our windows will watch the confirmQuit. + confirmQuit = true + return .terminateLater + } +} diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib new file mode 100644 index 000000000..2a7d0c998 --- /dev/null +++ b/macos/Sources/MainMenu.xib @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/WindowController.swift b/macos/Sources/WindowController.swift new file mode 100644 index 000000000..1c15158a7 --- /dev/null +++ b/macos/Sources/WindowController.swift @@ -0,0 +1,11 @@ +import Cocoa + +class WindowController: NSWindowController { + static var lastCascadePoint = NSPoint(x: 0, y: 0) + + static func create(ghosttyApp: Ghostty.AppState, appDelegate: AppDelegate) -> WindowController { + let window = CustomWindow.create(ghostty: ghosttyApp, appDelegate: appDelegate) + lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) + return WindowController(window: window) + } +} diff --git a/macos/Sources/WindowService.swift b/macos/Sources/WindowService.swift new file mode 100644 index 000000000..f751c92fb --- /dev/null +++ b/macos/Sources/WindowService.swift @@ -0,0 +1,80 @@ +import Cocoa +import Combine + +// WindowService manages the windows and tabs in the application. +// It keeps references to windows and cleans them up when they're cloned. +// +// It is based on the patterns presented in this blog post: +// https://christiantietze.de/posts/2019/07/nswindow-tabbing-multiple-nswindowcontroller/ +class WindowService { + struct ManagedWindow { + let windowController: NSWindowController + + let window: NSWindow + + let closePublisher: AnyCancellable + } + + private var ghostty: Ghostty.AppState + private var managedWindows: [ManagedWindow] = [] + + init(ghostty: Ghostty.AppState) { + self.ghostty = ghostty + + addInitialWindow() + } + + private func addInitialWindow() { + guard let controller = createWindowController() else { return } + controller.showWindow(self) + let result = addManagedWindow(windowController: controller) + if result == nil { + preconditionFailure("Failed to create initial window") + } + } + + func addNewWindow() { + guard let controller = createWindowController() else { return } + guard let newWindow = addManagedWindow(windowController: controller)?.window else { return } + newWindow.makeKeyAndOrderFront(nil) + } + + func addNewTab() { + guard let existingWindow = mainWindow() else { return } + guard let controller = createWindowController() else { return } + guard let newWindow = addManagedWindow(windowController: controller)?.window else { return } + existingWindow.addTabbedWindow(newWindow, ordered: .above) + newWindow.makeKeyAndOrderFront(nil) + } + + /// Returns the main window of the managed window stack. + /// Falls back the first element if no window is main. + private func mainWindow() -> NSWindow? { + let mainManagedWindow = managedWindows.first { $0.window.isMainWindow } + return (mainManagedWindow ?? managedWindows.first).map { $0.window } + } + + private func createWindowController() -> WindowController? { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } + return WindowController.create(ghosttyApp: self.ghostty, appDelegate: appDelegate) + } + + private func addManagedWindow(windowController: WindowController) -> ManagedWindow? { + guard let window = windowController.window else { return nil } + + let pubClose = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) + .sink { notification in + guard let window = notification.object as? NSWindow else { return } + self.removeWindow(window: window) + } + + let managed = ManagedWindow(windowController: windowController, window: window, closePublisher: pubClose) + managedWindows.append(managed) + + return managed + } + + private func removeWindow(window: NSWindow) { + self.managedWindows.removeAll(where: { $0.window === window }) + } +} diff --git a/macos/Sources/main.swift b/macos/Sources/main.swift new file mode 100644 index 000000000..5d2c0ecaa --- /dev/null +++ b/macos/Sources/main.swift @@ -0,0 +1,7 @@ +import AppKit + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate + +_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) From b56ffa6285c9e79722514c3823ebfa98f7cb716e Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Sat, 29 Jul 2023 21:05:49 +0200 Subject: [PATCH 2/8] Add config setting to turn non-native fullscreen on or off --- include/ghostty.h | 2 +- macos/Sources/ContentView.swift | 8 ++++++-- macos/Sources/FullScreenHandler.swift | 21 ++++++++++++++++++--- macos/Sources/Ghostty/AppState.swift | 9 ++++++--- macos/Sources/Ghostty/Package.swift | 1 + src/Surface.zig | 4 +++- src/apprt/embedded.zig | 6 +++--- src/apprt/gtk.zig | 2 +- src/config.zig | 6 ++++++ 9 files changed, 45 insertions(+), 14 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 2cb915ddd..14d8d2b02 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -239,7 +239,7 @@ typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e); typedef void (*ghostty_runtime_close_surface_cb)(void *, bool); typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e); typedef void (*ghostty_runtime_goto_tab_cb)(void *, int32_t); -typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *); +typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *, bool); typedef struct { void *userdata; diff --git a/macos/Sources/ContentView.swift b/macos/Sources/ContentView.swift index 11787bb63..b0cfaa6e3 100644 --- a/macos/Sources/ContentView.swift +++ b/macos/Sources/ContentView.swift @@ -110,8 +110,12 @@ struct ContentView: View { // currently focused window. guard let window = self.window else { return } guard window.isKeyWindow else { return } - - self.fsHandler.toggleFullscreen(window: window) + + // Check whether we use non-native fullscreen + guard let useNonNativeFullscreenAny = notification.userInfo?[Ghostty.Notification.NonNativeFullscreenKey] else { return } + guard let useNonNativeFullscreen = useNonNativeFullscreenAny as? Bool else { return } + + self.fsHandler.toggleFullscreen(window: window, nonNativeFullscreen: useNonNativeFullscreen) // After toggling fullscreen we need to focus the terminal again. self.focused = true } diff --git a/macos/Sources/FullScreenHandler.swift b/macos/Sources/FullScreenHandler.swift index c3bddaab1..b182d4c9c 100644 --- a/macos/Sources/FullScreenHandler.swift +++ b/macos/Sources/FullScreenHandler.swift @@ -7,12 +7,27 @@ class FullScreenHandler { var previousStyleMask: NSWindow.StyleMask? var isInFullscreen: Bool = false - func toggleFullscreen(window: NSWindow) { + // We keep track of whether we entered non-native fullscreen in case + // a user goes to fullscreen, changes the config to disable non-native fullscreen + // and then wants to toggle it off + var isInNonNativeFullscreen: Bool = false + + func toggleFullscreen(window: NSWindow, nonNativeFullscreen: Bool) { if isInFullscreen { - leaveFullscreen(window: window) + if nonNativeFullscreen || isInNonNativeFullscreen { + leaveFullscreen(window: window) + isInNonNativeFullscreen = false + } else { + window.toggleFullScreen(nil) + } isInFullscreen = false } else { - enterFullscreen(window: window) + if nonNativeFullscreen { + enterFullscreen(window: window) + isInNonNativeFullscreen = true + } else { + window.toggleFullScreen(nil) + } isInFullscreen = true } } diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index b763444fa..f3e8a64c4 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -63,7 +63,7 @@ extension Ghostty { close_surface_cb: { userdata, processAlive in AppState.closeSurface(userdata, processAlive: processAlive) }, focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) }, goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) }, - toggle_fullscreen_cb: { userdata in AppState.toggleFullscreen(userdata) } + toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, useNonNativeFullscreen: nonNativeFullscreen) } ) // Create the ghostty app. @@ -219,12 +219,15 @@ extension Ghostty { } } - static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?) { + static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, useNonNativeFullscreen: Bool) { + // togo: use non-native fullscreen guard let surface = self.surfaceUserdata(from: userdata) else { return } NotificationCenter.default.post( name: Notification.ghosttyToggleFullscreen, object: surface, - userInfo: [:] + userInfo: [ + Notification.NonNativeFullscreenKey: useNonNativeFullscreen, + ] ) } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 6045d6dbb..d4b3a9fa9 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -81,6 +81,7 @@ extension Ghostty.Notification { /// Toggle fullscreen of current window static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen") + static let NonNativeFullscreenKey = ghosttyToggleFullscreen.rawValue } // Make the input enum hashable. diff --git a/src/Surface.zig b/src/Surface.zig index fdf562923..df2612a55 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -146,6 +146,7 @@ const DerivedConfig = struct { clipboard_trim_trailing_spaces: bool, confirm_close_surface: bool, mouse_interval: u64, + macos_non_native_fullscreen: bool, pub fn init(alloc_gpa: Allocator, config: *const configpkg.Config) !DerivedConfig { var arena = ArenaAllocator.init(alloc_gpa); @@ -160,6 +161,7 @@ const DerivedConfig = struct { .clipboard_trim_trailing_spaces = config.@"clipboard-trim-trailing-spaces", .confirm_close_surface = config.@"confirm-close-surface", .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms + .macos_non_native_fullscreen = config.@"macos-non-native-fullscreen", // Assignments happen sequentially so we have to do this last // so that the memory is captured from allocs above. @@ -1213,7 +1215,7 @@ pub fn keyCallback( .toggle_fullscreen => { if (@hasDecl(apprt.Surface, "toggleFullscreen")) { - self.rt_surface.toggleFullscreen(); + self.rt_surface.toggleFullscreen(self.config.macos_non_native_fullscreen); } else log.warn("runtime doesn't implement toggleFullscreen", .{}); }, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 1b5c3c4b8..f39f21c6b 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -66,7 +66,7 @@ pub const App = struct { goto_tab: ?*const fn (SurfaceUD, usize) callconv(.C) void = null, /// Toggle fullscreen for current window. - toggle_fullscreen: ?*const fn (SurfaceUD) callconv(.C) void = null, + toggle_fullscreen: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, }; core_app: *CoreApp, @@ -374,13 +374,13 @@ pub const Surface = struct { func(self.opts.userdata, n); } - pub fn toggleFullscreen(self: *Surface) void { + pub fn toggleFullscreen(self: *Surface, nonNativeFullscreen: bool) void { const func = self.app.opts.toggle_fullscreen orelse { log.info("runtime embedder does not toggle_fullscreen", .{}); return; }; - func(self.opts.userdata); + func(self.opts.userdata, nonNativeFullscreen); } /// The cursor position from the host directly is in screen coordinates but diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index a5fabc577..328141414 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -483,7 +483,7 @@ const Window = struct { } /// Toggle fullscreen for this window. - fn toggleFullscreen(self: *Window) void { + fn toggleFullscreen(self: *Window, _: bool) void { const is_fullscreen = c.gtk_window_is_fullscreen(self.window); if (is_fullscreen == 0) { c.gtk_window_fullscreen(self.window); diff --git a/src/config.zig b/src/config.zig index 86ea3a7ef..7d2a7fc24 100644 --- a/src/config.zig +++ b/src/config.zig @@ -221,6 +221,12 @@ pub const Config = struct { /// The default value is "detect". @"shell-integration": ShellIntegration = .detect, + /// If true, fullscreen mode on macOS will not use the native fullscreen, + /// but make the window fullscreen without animations and using a new space. + /// That's faster than the native fullscreen mode since it doesn't use + /// animations. + @"macos-non-native-fullscreen": bool = false, + /// This is set by the CLI parser for deinit. _arena: ?ArenaAllocator = null, From bf190ba44248cb4f93936b6dc9dd9be12ecdcbae Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 4 Aug 2023 17:05:52 -0700 Subject: [PATCH 3/8] macos: merge AppController and AppDelegate, organize groups --- macos/Ghostty.xcodeproj/project.pbxproj | 70 ++++++----- .../AppDelegate.swift} | 117 +++++++++--------- .../Primary Window/PrimaryWindow.swift} | 18 +-- .../PrimaryWindowController.swift | 14 +++ .../PrimaryWindowManager.swift} | 24 ++-- macos/Sources/Ghostty/AppState.swift | 8 +- macos/Sources/MainMenu.xib | 40 +++--- macos/Sources/Preview Content/.gitkeep | 0 macos/Sources/WindowController.swift | 11 -- macos/Sources/main.swift | 7 -- 10 files changed, 160 insertions(+), 149 deletions(-) rename macos/Sources/{GhosttyAppController.swift => Core/AppDelegate.swift} (85%) rename macos/Sources/{CustomWindow.swift => Features/Primary Window/PrimaryWindow.swift} (70%) create mode 100644 macos/Sources/Features/Primary Window/PrimaryWindowController.swift rename macos/Sources/{WindowService.swift => Features/Primary Window/PrimaryWindowManager.swift} (76%) delete mode 100644 macos/Sources/Preview Content/.gitkeep delete mode 100644 macos/Sources/WindowController.swift delete mode 100644 macos/Sources/main.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 2847bac6c..9fef6281e 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -8,11 +8,11 @@ /* Begin PBXBuildFile section */ 8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */; }; - 85102A1A2A6E32720084AB3E /* WindowService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85102A192A6E32720084AB3E /* WindowService.swift */; }; - 85102A1C2A6E32890084AB3E /* WindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85102A1B2A6E32890084AB3E /* WindowController.swift */; }; - 852655222A597CA900E4F7AD /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852655212A597CA900E4F7AD /* main.swift */; }; + 85102A1C2A6E32890084AB3E /* PrimaryWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85102A1B2A6E32890084AB3E /* PrimaryWindowController.swift */; }; 857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; }; - 85DE1C922A6A3DCA00493853 /* CustomWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DE1C912A6A3DCA00493853 /* CustomWindow.swift */; }; + 85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85DE1C912A6A3DCA00493853 /* PrimaryWindow.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 */; }; @@ -23,7 +23,6 @@ A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; - A5B30535299BEAAA0047F10C /* GhosttyAppController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyAppController.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; @@ -34,11 +33,11 @@ /* Begin PBXFileReference section */ 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenHandler.swift; sourceTree = ""; }; - 85102A192A6E32720084AB3E /* WindowService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowService.swift; sourceTree = ""; }; - 85102A1B2A6E32890084AB3E /* WindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowController.swift; sourceTree = ""; }; - 852655212A597CA900E4F7AD /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.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 /* CustomWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomWindow.swift; sourceTree = ""; }; + 85DE1C912A6A3DCA00493853 /* PrimaryWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryWindow.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 = ""; }; @@ -50,7 +49,6 @@ A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A5B30534299BEAAA0047F10C /* GhosttyAppController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyAppController.swift; sourceTree = ""; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; @@ -73,14 +71,39 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + A53426362A7DC53000EBB7A2 /* Features */ = { + isa = PBXGroup; + children = ( + A53426372A7DC53A00EBB7A2 /* Primary Window */, + ); + path = Features; + sourceTree = ""; + }; + A53426372A7DC53A00EBB7A2 /* Primary Window */ = { + isa = PBXGroup; + children = ( + A53426382A7DC55C00EBB7A2 /* PrimaryWindowManager.swift */, + 85102A1B2A6E32890084AB3E /* PrimaryWindowController.swift */, + 85DE1C912A6A3DCA00493853 /* PrimaryWindow.swift */, + ); + path = "Primary Window"; + sourceTree = ""; + }; + A534263A2A7DC61B00EBB7A2 /* Core */ = { + isa = PBXGroup; + children = ( + A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */, + ); + path = Core; + sourceTree = ""; + }; A54CD6ED299BEB14008C95BB /* Sources */ = { isa = PBXGroup; children = ( - A5D495A0299BEC2200DD1313 /* Preview Content */, + A534263A2A7DC61B00EBB7A2 /* Core */, + A53426362A7DC53000EBB7A2 /* Features */, A5CEAFDA29B8005900646FDA /* SplitView */, A55B7BB429B6F4410055DE60 /* Ghostty */, - A5B30534299BEAAA0047F10C /* GhosttyAppController.swift */, - 85DE1C912A6A3DCA00493853 /* CustomWindow.swift */, 857F63802A5E64F200CA4815 /* MainMenu.xib */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, @@ -89,9 +112,6 @@ A5FECBD629D1FC3900022361 /* ContentView.swift */, A5FECBD829D2010400022361 /* WindowAccessor.swift */, 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, - 852655212A597CA900E4F7AD /* main.swift */, - 85102A192A6E32720084AB3E /* WindowService.swift */, - 85102A1B2A6E32890084AB3E /* WindowController.swift */, ); path = Sources; sourceTree = ""; @@ -146,13 +166,6 @@ path = SplitView; sourceTree = ""; }; - A5D495A0299BEC2200DD1313 /* Preview Content */ = { - isa = PBXGroup; - children = ( - ); - path = "Preview Content"; - sourceTree = ""; - }; A5D495A3299BECBA00DD1313 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -233,8 +246,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 85102A1A2A6E32720084AB3E /* WindowService.swift in Sources */, - 85DE1C922A6A3DCA00493853 /* CustomWindow.swift in Sources */, + A53426392A7DC55C00EBB7A2 /* PrimaryWindowManager.swift in Sources */, + 85DE1C922A6A3DCA00493853 /* PrimaryWindow.swift in Sources */, + A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A5FECBD729D1FC3900022361 /* ContentView.swift in Sources */, @@ -245,9 +259,7 @@ A55685E029A03A9F004303CE /* AppError.swift in Sources */, A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, - A5B30535299BEAAA0047F10C /* GhosttyAppController.swift in Sources */, - 85102A1C2A6E32890084AB3E /* WindowController.swift in Sources */, - 852655222A597CA900E4F7AD /* main.swift in Sources */, + 85102A1C2A6E32890084AB3E /* PrimaryWindowController.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, 8503D7C72A549C66006CFF3D /* FullScreenHandler.swift in Sources */, A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */, @@ -381,7 +393,6 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"Sources/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -415,7 +426,6 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"Sources/Preview Content\""; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/macos/Sources/GhosttyAppController.swift b/macos/Sources/Core/AppDelegate.swift similarity index 85% rename from macos/Sources/GhosttyAppController.swift rename to macos/Sources/Core/AppDelegate.swift index cec0fa12a..a986eb423 100644 --- a/macos/Sources/GhosttyAppController.swift +++ b/macos/Sources/Core/AppDelegate.swift @@ -1,36 +1,82 @@ -import OSLog -import SwiftUI import AppKit +import OSLog import GhosttyKit -class GhosttyAppController: NSObject { - @IBOutlet weak fileprivate var mainMenu: NSMenu! - +@NSApplicationMain +class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { + // The application logger. We should probably move this at some point to a dedicated + // class/struct but for now it lives here! 🤷‍♂️ static let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: AppDelegate.self) ) + // confirmQuit published so other views can check whether quit needs to be confirmed. + @Published var confirmQuit: Bool = false + /// The ghostty global state. Only one per process. - var ghostty: Ghostty.AppState = Ghostty.AppState() + private var ghostty: Ghostty.AppState = Ghostty.AppState() /// Manages windows and tabs, ensuring they're allocated/deallocated correctly - var windowService: WindowService! + private var windowManager: PrimaryWindowManager! override init() { super.init() - // We're initialized through the MainMenu, because we're a referenced objected. - // So when we're here, we initialize the WindowService, which will open first window. - windowService = WindowService(ghostty: self.ghostty) + windowManager = PrimaryWindowManager(ghostty: self.ghostty) + } + + func applicationDidFinishLaunching(_ notification: Notification) { + // System settings overrides + UserDefaults.standard.register(defaults: [ + // Disable this so that repeated key events make it through to our terminal views. + "ApplePressAndHoldEnabled": false, + ]) + + // Let's launch our first window. + // TODO: we should detect if we restored windows and if so not launch a new window. + windowManager.addInitialWindow() + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + let windows = NSApplication.shared.windows + if (windows.isEmpty) { return .terminateNow } + + // This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't + // quite work with SwiftUI because windows are retained on close. So instead we check + // if there are any that are visible. I'm guessing this breaks under certain scenarios. + if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow } + + // If the user is shutting down, restarting, or logging out, we don't confirm quit. + if let event = NSAppleEventManager.shared().currentAppleEvent { + if let why = event.attributeDescriptor(forKeyword: AEKeyword("why?")!) { + switch (why.typeCodeValue) { + case kAEShutDown: + fallthrough + + case kAERestart: + fallthrough + + case kAEReallyLogOut: + return .terminateNow + + default: + break + } + } + } + + // We have some visible window, and all our windows will watch the confirmQuit. + confirmQuit = true + return .terminateLater } @IBAction func newWindow(_ sender: Any?) { - windowService.addNewWindow() + windowManager.addNewWindow() } @IBAction func newTab(_ sender: Any?) { - windowService.addNewTab() + windowManager.addNewTab() } @IBAction func closeWindow(_ sender: Any) { @@ -48,7 +94,7 @@ class GhosttyAppController: NSObject { } private func focusedSurface() -> ghostty_surface_t? { - guard let window = NSApp.keyWindow as? CustomWindow else { return nil } + guard let window = NSApp.keyWindow as? PrimaryWindow else { return nil } return window.focusedSurfaceWrapper.surface } @@ -91,48 +137,3 @@ class GhosttyAppController: NSObject { ghostty.splitMoveFocus(surface: surface, direction: direction) } } - -class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { - // confirmQuit published so other views can check whether quit needs to be confirmed. - @Published var confirmQuit: Bool = false - - func applicationDidFinishLaunching(_ notification: Notification) { - UserDefaults.standard.register(defaults: [ - // Disable this so that repeated key events make it through to our terminal views. - "ApplePressAndHoldEnabled": false, - ]) - } - - func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { - let windows = NSApplication.shared.windows - if (windows.isEmpty) { return .terminateNow } - - // This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't - // quite work with SwiftUI because windows are retained on close. So instead we check - // if there are any that are visible. I'm guessing this breaks under certain scenarios. - if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow } - - // If the user is shutting down, restarting, or logging out, we don't confirm quit. - if let event = NSAppleEventManager.shared().currentAppleEvent { - if let why = event.attributeDescriptor(forKeyword: AEKeyword("why?")!) { - switch (why.typeCodeValue) { - case kAEShutDown: - fallthrough - - case kAERestart: - fallthrough - - case kAEReallyLogOut: - return .terminateNow - - default: - break - } - } - } - - // We have some visible window, and all our windows will watch the confirmQuit. - confirmQuit = true - return .terminateLater - } -} diff --git a/macos/Sources/CustomWindow.swift b/macos/Sources/Features/Primary Window/PrimaryWindow.swift similarity index 70% rename from macos/Sources/CustomWindow.swift rename to macos/Sources/Features/Primary Window/PrimaryWindow.swift index 58371b40a..90ba15a3c 100644 --- a/macos/Sources/CustomWindow.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindow.swift @@ -8,18 +8,20 @@ class FocusedSurfaceWrapper { var surface: ghostty_surface_t? } -// CustomWindow exists purely so we can override canBecomeKey and canBecomeMain. -// We need that for the non-native fullscreen. -// If we don't use `CustomWindow` we'll get warning messages in the output to say that -// `makeKeyWindow` was called and returned NO. -class CustomWindow: NSWindow { +// 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() - static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate) -> CustomWindow { - let window = CustomWindow( + static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate) -> PrimaryWindow { + let window = PrimaryWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), styleMask: [.titled, .closable, .miniaturizable, .resizable], - backing: .buffered, defer: false) + backing: .buffered, + defer: false) window.center() window.contentView = NSHostingView(rootView: ContentView( ghostty: ghostty, diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift new file mode 100644 index 000000000..c0e1961c2 --- /dev/null +++ b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift @@ -0,0 +1,14 @@ +import Cocoa + +class PrimaryWindowController: NSWindowController { + // 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) + + static func create(ghosttyApp: Ghostty.AppState, appDelegate: AppDelegate) -> PrimaryWindowController { + let window = PrimaryWindow.create(ghostty: ghosttyApp, appDelegate: appDelegate) + lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) + return PrimaryWindowController(window: window) + } +} diff --git a/macos/Sources/WindowService.swift b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift similarity index 76% rename from macos/Sources/WindowService.swift rename to macos/Sources/Features/Primary Window/PrimaryWindowManager.swift index f751c92fb..67f32e82f 100644 --- a/macos/Sources/WindowService.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift @@ -1,17 +1,20 @@ import Cocoa import Combine -// WindowService manages the windows and tabs in the application. -// It keeps references to windows and cleans them up when they're cloned. +// 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 WindowService { +class PrimaryWindowManager { struct ManagedWindow { let windowController: NSWindowController - let window: NSWindow - let closePublisher: AnyCancellable } @@ -20,11 +23,10 @@ class WindowService { init(ghostty: Ghostty.AppState) { self.ghostty = ghostty - - addInitialWindow() } - private func addInitialWindow() { + /// 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) @@ -54,12 +56,12 @@ class WindowService { return (mainManagedWindow ?? managedWindows.first).map { $0.window } } - private func createWindowController() -> WindowController? { + private func createWindowController() -> PrimaryWindowController? { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } - return WindowController.create(ghosttyApp: self.ghostty, appDelegate: appDelegate) + return PrimaryWindowController.create(ghosttyApp: self.ghostty, appDelegate: appDelegate) } - private func addManagedWindow(windowController: WindowController) -> ManagedWindow? { + private func addManagedWindow(windowController: PrimaryWindowController) -> ManagedWindow? { guard let window = windowController.window else { return nil } let pubClose = NotificationCenter.default.publisher(for: NSWindow.willCloseNotification, object: window) diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index f3e8a64c4..4f5a1358d 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -38,7 +38,7 @@ extension Ghostty { init() { // Initialize ghostty global state. This happens once per process. guard ghostty_init() == GHOSTTY_SUCCESS else { - GhosttyAppController.logger.critical("ghostty_init failed") + AppDelegate.logger.critical("ghostty_init failed") readiness = .error return } @@ -68,7 +68,7 @@ extension Ghostty { // Create the ghostty app. guard let app = ghostty_app_new(&runtime_cfg, cfg) else { - GhosttyAppController.logger.critical("ghostty_app_new failed") + AppDelegate.logger.critical("ghostty_app_new failed") readiness = .error return } @@ -87,7 +87,7 @@ extension Ghostty { static func reloadConfig() -> ghostty_config_t? { // Initialize the global configuration. guard let cfg = ghostty_config_new() else { - GhosttyAppController.logger.critical("ghostty_config_new failed") + AppDelegate.logger.critical("ghostty_config_new failed") return nil } @@ -189,7 +189,7 @@ extension Ghostty { static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? { guard let newConfig = AppState.reloadConfig() else { - GhosttyAppController.logger.warning("failed to reload configuration") + AppDelegate.logger.warning("failed to reload configuration") return nil } diff --git a/macos/Sources/MainMenu.xib b/macos/Sources/MainMenu.xib index 2a7d0c998..36c752c41 100644 --- a/macos/Sources/MainMenu.xib +++ b/macos/Sources/MainMenu.xib @@ -5,15 +5,20 @@ - + + + + + + - + - + @@ -56,34 +61,34 @@ - + - + - + - + - + - + @@ -114,12 +119,12 @@ - + - + @@ -129,25 +134,25 @@ - + - + - + - + @@ -171,10 +176,5 @@ - - - - - diff --git a/macos/Sources/Preview Content/.gitkeep b/macos/Sources/Preview Content/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/macos/Sources/WindowController.swift b/macos/Sources/WindowController.swift deleted file mode 100644 index 1c15158a7..000000000 --- a/macos/Sources/WindowController.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Cocoa - -class WindowController: NSWindowController { - static var lastCascadePoint = NSPoint(x: 0, y: 0) - - static func create(ghosttyApp: Ghostty.AppState, appDelegate: AppDelegate) -> WindowController { - let window = CustomWindow.create(ghostty: ghosttyApp, appDelegate: appDelegate) - lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) - return WindowController(window: window) - } -} diff --git a/macos/Sources/main.swift b/macos/Sources/main.swift deleted file mode 100644 index 5d2c0ecaa..000000000 --- a/macos/Sources/main.swift +++ /dev/null @@ -1,7 +0,0 @@ -import AppKit - -let app = NSApplication.shared -let delegate = AppDelegate() -app.delegate = delegate - -_ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) From 910563769789017d0ab0b87c3bcced2399a2e15a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 4 Aug 2023 17:17:18 -0700 Subject: [PATCH 4/8] macos: reorganize, rename, put files in groups --- macos/Ghostty.xcodeproj/project.pbxproj | 42 +++++++++++-------- macos/Sources/{Core => }/AppDelegate.swift | 0 .../Primary Window}/ErrorView.swift | 0 .../Primary Window/PrimaryView.swift} | 2 +- .../Primary Window/PrimaryWindow.swift | 2 +- .../Settings}/SettingsView.swift | 0 macos/Sources/{ => Ghostty}/AppError.swift | 0 macos/Sources/{ => Helpers}/Backport.swift | 0 .../{ => Helpers}/FullScreenHandler.swift | 0 .../SplitView/SplitView.Divider.swift | 0 .../{ => Helpers}/SplitView/SplitView.swift | 0 .../{ => Helpers}/WindowAccessor.swift | 0 12 files changed, 27 insertions(+), 19 deletions(-) rename macos/Sources/{Core => }/AppDelegate.swift (100%) rename macos/Sources/{ => Features/Primary Window}/ErrorView.swift (100%) rename macos/Sources/{ContentView.swift => Features/Primary Window/PrimaryView.swift} (99%) rename macos/Sources/{ => Features/Settings}/SettingsView.swift (100%) rename macos/Sources/{ => Ghostty}/AppError.swift (100%) rename macos/Sources/{ => Helpers}/Backport.swift (100%) rename macos/Sources/{ => Helpers}/FullScreenHandler.swift (100%) rename macos/Sources/{ => Helpers}/SplitView/SplitView.Divider.swift (100%) rename macos/Sources/{ => Helpers}/SplitView/SplitView.swift (100%) rename macos/Sources/{ => Helpers}/WindowAccessor.swift (100%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 9fef6281e..b4df4d048 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -27,7 +27,7 @@ A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; - A5FECBD729D1FC3900022361 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FECBD629D1FC3900022361 /* ContentView.swift */; }; + A5FECBD729D1FC3900022361 /* PrimaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FECBD629D1FC3900022361 /* PrimaryView.swift */; }; A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FECBD829D2010400022361 /* WindowAccessor.swift */; }; /* End PBXBuildFile section */ @@ -55,7 +55,7 @@ A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; - A5FECBD629D1FC3900022361 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A5FECBD629D1FC3900022361 /* PrimaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryView.swift; sourceTree = ""; }; A5FECBD829D2010400022361 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -75,6 +75,7 @@ isa = PBXGroup; children = ( A53426372A7DC53A00EBB7A2 /* Primary Window */, + A534263E2A7DCC5800EBB7A2 /* Settings */, ); path = Features; sourceTree = ""; @@ -85,33 +86,39 @@ A53426382A7DC55C00EBB7A2 /* PrimaryWindowManager.swift */, 85102A1B2A6E32890084AB3E /* PrimaryWindowController.swift */, 85DE1C912A6A3DCA00493853 /* PrimaryWindow.swift */, + A5FECBD629D1FC3900022361 /* PrimaryView.swift */, + A535B9D9299C569B0017E2E4 /* ErrorView.swift */, ); path = "Primary Window"; sourceTree = ""; }; - A534263A2A7DC61B00EBB7A2 /* Core */ = { + A534263D2A7DCBB000EBB7A2 /* Helpers */ = { isa = PBXGroup; children = ( - A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */, + A5CEAFFE29C2410700646FDA /* Backport.swift */, + 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, + A5FECBD829D2010400022361 /* WindowAccessor.swift */, + A5CEAFDA29B8005900646FDA /* SplitView */, ); - path = Core; + path = Helpers; + sourceTree = ""; + }; + A534263E2A7DCC5800EBB7A2 /* Settings */ = { + isa = PBXGroup; + children = ( + A59444F629A2ED5200725BBA /* SettingsView.swift */, + ); + path = Settings; sourceTree = ""; }; A54CD6ED299BEB14008C95BB /* Sources */ = { isa = PBXGroup; children = ( - A534263A2A7DC61B00EBB7A2 /* Core */, - A53426362A7DC53000EBB7A2 /* Features */, - A5CEAFDA29B8005900646FDA /* SplitView */, - A55B7BB429B6F4410055DE60 /* Ghostty */, + A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */, 857F63802A5E64F200CA4815 /* MainMenu.xib */, - A535B9D9299C569B0017E2E4 /* ErrorView.swift */, - A55685DF29A03A9F004303CE /* AppError.swift */, - A59444F629A2ED5200725BBA /* SettingsView.swift */, - A5CEAFFE29C2410700646FDA /* Backport.swift */, - A5FECBD629D1FC3900022361 /* ContentView.swift */, - A5FECBD829D2010400022361 /* WindowAccessor.swift */, - 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, + A53426362A7DC53000EBB7A2 /* Features */, + A534263D2A7DCBB000EBB7A2 /* Helpers */, + A55B7BB429B6F4410055DE60 /* Ghostty */, ); path = Sources; sourceTree = ""; @@ -123,6 +130,7 @@ A55B7BB529B6F47F0055DE60 /* AppState.swift */, A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */, A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */, + A55685DF29A03A9F004303CE /* AppError.swift */, ); path = Ghostty; sourceTree = ""; @@ -251,7 +259,7 @@ A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, - A5FECBD729D1FC3900022361 /* ContentView.swift in Sources */, + A5FECBD729D1FC3900022361 /* PrimaryView.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */, A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */, diff --git a/macos/Sources/Core/AppDelegate.swift b/macos/Sources/AppDelegate.swift similarity index 100% rename from macos/Sources/Core/AppDelegate.swift rename to macos/Sources/AppDelegate.swift diff --git a/macos/Sources/ErrorView.swift b/macos/Sources/Features/Primary Window/ErrorView.swift similarity index 100% rename from macos/Sources/ErrorView.swift rename to macos/Sources/Features/Primary Window/ErrorView.swift diff --git a/macos/Sources/ContentView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift similarity index 99% rename from macos/Sources/ContentView.swift rename to macos/Sources/Features/Primary Window/PrimaryView.swift index b0cfaa6e3..adeef5574 100644 --- a/macos/Sources/ContentView.swift +++ b/macos/Sources/Features/Primary Window/PrimaryView.swift @@ -1,7 +1,7 @@ import SwiftUI import GhosttyKit -struct ContentView: View { +struct PrimaryView: View { let ghostty: Ghostty.AppState // We need access to our app delegate to know if we're quitting or not. diff --git a/macos/Sources/Features/Primary Window/PrimaryWindow.swift b/macos/Sources/Features/Primary Window/PrimaryWindow.swift index 90ba15a3c..9eccb52f2 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindow.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindow.swift @@ -23,7 +23,7 @@ class PrimaryWindow: NSWindow { backing: .buffered, defer: false) window.center() - window.contentView = NSHostingView(rootView: ContentView( + window.contentView = NSHostingView(rootView: PrimaryView( ghostty: ghostty, appDelegate: appDelegate, focusedSurfaceWrapper: window.focusedSurfaceWrapper)) diff --git a/macos/Sources/SettingsView.swift b/macos/Sources/Features/Settings/SettingsView.swift similarity index 100% rename from macos/Sources/SettingsView.swift rename to macos/Sources/Features/Settings/SettingsView.swift diff --git a/macos/Sources/AppError.swift b/macos/Sources/Ghostty/AppError.swift similarity index 100% rename from macos/Sources/AppError.swift rename to macos/Sources/Ghostty/AppError.swift diff --git a/macos/Sources/Backport.swift b/macos/Sources/Helpers/Backport.swift similarity index 100% rename from macos/Sources/Backport.swift rename to macos/Sources/Helpers/Backport.swift diff --git a/macos/Sources/FullScreenHandler.swift b/macos/Sources/Helpers/FullScreenHandler.swift similarity index 100% rename from macos/Sources/FullScreenHandler.swift rename to macos/Sources/Helpers/FullScreenHandler.swift diff --git a/macos/Sources/SplitView/SplitView.Divider.swift b/macos/Sources/Helpers/SplitView/SplitView.Divider.swift similarity index 100% rename from macos/Sources/SplitView/SplitView.Divider.swift rename to macos/Sources/Helpers/SplitView/SplitView.Divider.swift diff --git a/macos/Sources/SplitView/SplitView.swift b/macos/Sources/Helpers/SplitView/SplitView.swift similarity index 100% rename from macos/Sources/SplitView/SplitView.swift rename to macos/Sources/Helpers/SplitView/SplitView.swift diff --git a/macos/Sources/WindowAccessor.swift b/macos/Sources/Helpers/WindowAccessor.swift similarity index 100% rename from macos/Sources/WindowAccessor.swift rename to macos/Sources/Helpers/WindowAccessor.swift From 994d456223ffae1fc4e888942b7f9eced2a669a8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 4 Aug 2023 17:24:28 -0700 Subject: [PATCH 5/8] set the title of the window --- .../Features/Primary Window/PrimaryView.swift | 12 ++++++++++-- .../Features/Primary Window/PrimaryWindow.swift | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift index adeef5574..a7843f9ba 100644 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ b/macos/Sources/Features/Primary Window/PrimaryView.swift @@ -16,12 +16,13 @@ struct PrimaryView: View { @State private var window: NSWindow? // This handles non-native fullscreen - @State private var fsHandler = FullScreenHandler() + @State private var fullScreen = FullScreenHandler() // This seems like a crutch after switchign from SwiftUI to AppKit lifecycle. @FocusState private var focused: Bool @FocusedValue(\.ghosttySurfaceView) private var focusedSurface + @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle var body: some View { switch ghostty.readiness { @@ -58,6 +59,13 @@ struct PrimaryView: View { .onChange(of: focusedSurface) { newValue in self.focusedSurfaceWrapper.surface = newValue?.surface } + .onChange(of: surfaceTitle) { newValue in + // We need to handle this manually because we are using AppKit lifecycle + // so navigationTitle no longer works. + guard let window = self.window else { return } + guard let title = newValue else { return } + window.title = title + } .confirmationDialog( "Quit Ghostty?", isPresented: confirmQuitting) { @@ -115,7 +123,7 @@ struct PrimaryView: View { guard let useNonNativeFullscreenAny = notification.userInfo?[Ghostty.Notification.NonNativeFullscreenKey] else { return } guard let useNonNativeFullscreen = useNonNativeFullscreenAny as? Bool else { return } - self.fsHandler.toggleFullscreen(window: window, nonNativeFullscreen: useNonNativeFullscreen) + self.fullScreen.toggleFullscreen(window: window, nonNativeFullscreen: useNonNativeFullscreen) // After toggling fullscreen we need to focus the terminal again. self.focused = true } diff --git a/macos/Sources/Features/Primary Window/PrimaryWindow.swift b/macos/Sources/Features/Primary Window/PrimaryWindow.swift index 9eccb52f2..7b2e7c08e 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindow.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindow.swift @@ -28,6 +28,7 @@ class PrimaryWindow: NSWindow { appDelegate: appDelegate, focusedSurfaceWrapper: window.focusedSurfaceWrapper)) window.windowController?.shouldCascadeWindows = true + window.title = "Ghostty 👻" return window } From bb7c67b967c50a34d4350ebf71df93a06901319f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 4 Aug 2023 17:35:57 -0700 Subject: [PATCH 6/8] macos: implement newWindowForTab for "+" button --- .../Features/Primary Window/PrimaryWindow.swift | 6 ++++++ .../Primary Window/PrimaryWindowController.swift | 15 +++++++++++---- .../Primary Window/PrimaryWindowManager.swift | 11 ++++++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Primary Window/PrimaryWindow.swift b/macos/Sources/Features/Primary Window/PrimaryWindow.swift index 7b2e7c08e..85ca49601 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindow.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindow.swift @@ -23,12 +23,18 @@ class PrimaryWindow: NSWindow { backing: .buffered, defer: false) window.center() + window.contentView = NSHostingView(rootView: PrimaryView( ghostty: ghostty, appDelegate: appDelegate, focusedSurfaceWrapper: window.focusedSurfaceWrapper)) + + // 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 } diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift index c0e1961c2..751bff7a8 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift @@ -6,9 +6,16 @@ class PrimaryWindowController: NSWindowController { // of each other. static var lastCascadePoint = NSPoint(x: 0, y: 0) - static func create(ghosttyApp: Ghostty.AppState, appDelegate: AppDelegate) -> PrimaryWindowController { - let window = PrimaryWindow.create(ghostty: ghosttyApp, appDelegate: appDelegate) - lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) - return PrimaryWindowController(window: window) + // This is used to programmatically control tabs. + weak var windowManager: PrimaryWindowManager? + + // This is required for the "+" button to show up in the tab bar to add a + // new tab. + override func newWindowForTab(_ sender: Any?) { + // TODO: specify our window so the tab is created in the proper window + // guard let window = self.window else { preconditionFailure("Expected window to be loaded") } + + guard let manager = self.windowManager else { return } + manager.addNewTab() } } diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift index 67f32e82f..81b4f1b92 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift @@ -17,6 +17,11 @@ class PrimaryWindowManager { 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) private var ghostty: Ghostty.AppState private var managedWindows: [ManagedWindow] = [] @@ -58,7 +63,11 @@ class PrimaryWindowManager { private func createWindowController() -> PrimaryWindowController? { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } - return PrimaryWindowController.create(ghosttyApp: self.ghostty, appDelegate: appDelegate) + let window = PrimaryWindow.create(ghostty: ghostty, appDelegate: appDelegate) + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + let controller = PrimaryWindowController(window: window) + controller.windowManager = self + return controller } private func addManagedWindow(windowController: PrimaryWindowController) -> ManagedWindow? { From 8c01160afa45a32786fe31a6ba5577116ea249ac Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 4 Aug 2023 17:39:40 -0700 Subject: [PATCH 7/8] macos: "+" button ensures tab is added to same window group --- .../Features/Primary Window/PrimaryWindowController.swift | 6 ++---- .../Features/Primary Window/PrimaryWindowManager.swift | 6 +++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift index 751bff7a8..180fee960 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift @@ -12,10 +12,8 @@ class PrimaryWindowController: NSWindowController { // This is required for the "+" button to show up in the tab bar to add a // new tab. override func newWindowForTab(_ sender: Any?) { - // TODO: specify our window so the tab is created in the proper window - // guard let window = self.window else { preconditionFailure("Expected window to be loaded") } - + guard let window = self.window else { preconditionFailure("Expected window to be loaded") } guard let manager = self.windowManager else { return } - manager.addNewTab() + manager.addNewTab(to: window) } } diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift index 81b4f1b92..c4e6c93e6 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift @@ -48,9 +48,13 @@ class PrimaryWindowManager { func addNewTab() { guard let existingWindow = mainWindow() else { return } + addNewTab(to: existingWindow) + } + + func addNewTab(to window: NSWindow) { guard let controller = createWindowController() else { return } guard let newWindow = addManagedWindow(windowController: controller)?.window else { return } - existingWindow.addTabbedWindow(newWindow, ordered: .above) + window.addTabbedWindow(newWindow, ordered: .above) newWindow.makeKeyAndOrderFront(nil) } From 7c98f991dbb33c58be1fd44845cc190f2671539a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 4 Aug 2023 17:45:57 -0700 Subject: [PATCH 8/8] macos: new tab menu/shortcut works even if no windows are present --- macos/Sources/AppDelegate.swift | 8 ++++-- .../Primary Window/PrimaryWindowManager.swift | 27 ++++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index a986eb423..836ac7467 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -76,9 +76,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } @IBAction func newTab(_ sender: Any?) { - windowManager.addNewTab() + if let existingWindow = windowManager.mainWindow { + windowManager.addNewTab(to: existingWindow) + } else { + windowManager.addNewWindow() + } } - + @IBAction func closeWindow(_ sender: Any) { guard let currentWindow = NSApp.keyWindow else { return } currentWindow.close() diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift index c4e6c93e6..16b241070 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift @@ -22,6 +22,21 @@ class PrimaryWindowManager { // 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 } + + // In case we run into the inconsistency, let it crash in debug mode so we + // can fix our window management setup to prevent this from happening. + assert(mainManagedWindow != nil || managedWindows.isEmpty) + + return (mainManagedWindow ?? managedWindows.first) + .map { $0.window } + } private var ghostty: Ghostty.AppState private var managedWindows: [ManagedWindow] = [] @@ -46,24 +61,12 @@ class PrimaryWindowManager { newWindow.makeKeyAndOrderFront(nil) } - func addNewTab() { - guard let existingWindow = mainWindow() else { return } - addNewTab(to: existingWindow) - } - func addNewTab(to window: NSWindow) { guard let controller = createWindowController() else { return } guard let newWindow = addManagedWindow(windowController: controller)?.window else { return } window.addTabbedWindow(newWindow, ordered: .above) newWindow.makeKeyAndOrderFront(nil) } - - /// Returns the main window of the managed window stack. - /// Falls back the first element if no window is main. - private func mainWindow() -> NSWindow? { - let mainManagedWindow = managedWindows.first { $0.window.isMainWindow } - return (mainManagedWindow ?? managedWindows.first).map { $0.window } - } private func createWindowController() -> PrimaryWindowController? { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil }