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)