diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 67f1784ac..7da727fbb 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -71,7 +71,6 @@ A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; }; A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; }; A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; }; - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; }; A5985CD72C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CD82C320C4500C57AD3 /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5985CD62C320C4500C57AD3 /* String+Extension.swift */; }; A5985CE62C33060F00C57AD3 /* man in Resources */ = {isa = PBXBuildFile; fileRef = A5985CE52C33060F00C57AD3 /* man */; }; @@ -179,7 +178,6 @@ A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; }; A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; A5985CD62C320C4500C57AD3 /* String+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; A5985CE52C33060F00C57AD3 /* man */ = {isa = PBXFileReference; lastKnownFileType = folder; name = man; path = "../zig-out/share/man"; sourceTree = ""; }; A599CDAF2CF103F20049FA26 /* NSAppearance+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAppearance+Extension.swift"; sourceTree = ""; }; @@ -467,7 +465,6 @@ isa = PBXGroup; children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, - A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A5D0AF3A2B36A1DE00D21823 /* TerminalRestorable.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, @@ -710,7 +707,6 @@ C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, A58636712DF298FB00E04A10 /* ExpiringUndoManager.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, - A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index db332813f..aacf8f651 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -87,9 +87,6 @@ class AppDelegate: NSObject, /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() - /// Manages our terminal windows. - let terminalManager: TerminalManager - /// The global undo manager for app-level state such as window restoration. lazy var undoManager = ExpiringUndoManager() @@ -119,7 +116,6 @@ class AppDelegate: NSObject, } override init() { - terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( // Important: we must not start the updater here because we need to read our configuration // first to determine whether we're automatically checking, downloading, etc. The updater @@ -202,6 +198,16 @@ class AppDelegate: NSObject, name: .ghosttyBellDidRing, object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewWindow(_:)), + name: Ghostty.Notification.ghosttyNewWindow, + object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(ghosttyNewTab(_:)), + name: Ghostty.Notification.ghosttyNewTab, + object: nil) // Configure user notifications let actions = [ @@ -253,8 +259,8 @@ class AppDelegate: NSObject, // is possible to have other windows in a few scenarios: // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && derivedConfig.initialWindow { - terminalManager.newWindow() + if TerminalController.all.isEmpty && derivedConfig.initialWindow { + _ = TerminalController.newWindow(ghostty) } } } @@ -339,10 +345,10 @@ class AppDelegate: NSObject, // This is possible with flag set to false if there a race where the // window is still initializing and is not visible but the user clicked // the dock icon. - guard terminalManager.windows.count == 0 else { return true } + guard TerminalController.all.isEmpty else { return true } // No visible windows, open a new one. - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) return false } @@ -358,16 +364,17 @@ class AppDelegate: NSObject, var config = Ghostty.SurfaceConfiguration() if (isDirectory.boolValue) { - // When opening a directory, create a new tab in the main window with that as the working directory. + // When opening a directory, create a new tab in the main + // window with that as the working directory. // If no windows exist, a new one will be created. config.workingDirectory = filename - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(ghostty, withBaseConfig: config) } else { // When opening a file, open a new window with that file as the command, // and its parent directory as the working directory. config.command = filename config.workingDirectory = (filename as NSString).deletingLastPathComponent - terminalManager.newWindow(withBaseConfig: config) + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) } return true @@ -456,10 +463,6 @@ class AppDelegate: NSObject, menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers) } - private func focusedSurface() -> ghostty_surface_t? { - return terminalManager.focusedSurface?.surface - } - // MARK: Notifications and Events /// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get @@ -592,6 +595,22 @@ class AppDelegate: NSObject, } } + @objc private func ghosttyNewWindow(_ notification: Notification) { + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + _ = TerminalController.newWindow(ghostty, withBaseConfig: config) + } + + @objc private func ghosttyNewTab(_ notification: Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard let window = surfaceView.window else { return } + + let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] + let config = configAny as? Ghostty.SurfaceConfiguration + + _ = TerminalController.newTab(ghostty, from: window, withBaseConfig: config) + } + private func setDockBadge(_ label: String? = "•") { NSApp.dockTile.badgeLabel = label NSApp.dockTile.display() @@ -627,7 +646,7 @@ class AppDelegate: NSObject, // Config could change keybindings, so update everything that depends on that syncMenuShortcuts(config) - terminalManager.relabelAllTabs() + TerminalController.all.forEach { $0.relabelTabs() } // Config could change window appearance. We wrap this in an async queue because when // this is called as part of application launch it can deadlock with an internal @@ -756,8 +775,8 @@ class AppDelegate: NSObject, //MARK: - GhosttyAppDelegate func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? { - for c in terminalManager.windows { - for view in c.controller.surfaceTree { + for c in TerminalController.all { + for view in c.surfaceTree { if view.uuid == uuid { return view } @@ -811,7 +830,7 @@ class AppDelegate: NSObject, } @IBAction func newWindow(_ sender: Any?) { - terminalManager.newWindow() + _ = TerminalController.newWindow(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -819,7 +838,7 @@ class AppDelegate: NSObject, } @IBAction func newTab(_ sender: Any?) { - terminalManager.newTab() + _ = TerminalController.newTab(ghostty) // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -827,7 +846,7 @@ class AppDelegate: NSObject, } @IBAction func closeAllWindows(_ sender: Any?) { - terminalManager.closeAllWindows() + TerminalController.closeAllWindows() AboutController.shared.hide() } diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index 043f5d704..f60f94211 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -32,7 +32,6 @@ class ServiceProvider: NSObject { error: AutoreleasingUnsafeMutablePointer ) { guard let delegate = NSApp.delegate as? AppDelegate else { return } - let terminalManager = delegate.terminalManager guard let pathURLs = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL] else { error.pointee = Self.errorNoString @@ -53,10 +52,10 @@ class ServiceProvider: NSObject { switch (target) { case .window: - terminalManager.newWindow(withBaseConfig: config) + _ = TerminalController.newWindow(delegate.ghostty, withBaseConfig: config) case .tab: - terminalManager.newTab(withBaseConfig: config) + _ = TerminalController.newTab(delegate.ghostty, withBaseConfig: config) } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 129aeb1e2..e4b42c3a1 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -412,7 +412,7 @@ class BaseTerminalController: NSWindowController, /// This will also insert the proper undo stack information in. func closeSurfaceNode( _ node: SplitTree.Node, - withConfirmation: Bool = true + withConfirmation: Bool = true, ) { // This node must be part of our tree guard surfaceTree.contains(node) else { return } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index ddeb3dada..3210eda0c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -32,7 +32,8 @@ class TerminalController: BaseTerminalController { init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: SplitTree? = nil + withSurfaceTree tree: SplitTree? = nil, + parent: NSWindow? = nil ) { // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the @@ -137,6 +138,159 @@ class TerminalController: BaseTerminalController { syncAppearance(focusedSurface.derivedConfig) } + // MARK: Terminal Creation + + /// Returns all the available terminal controllers present in the app currently. + static var all: [TerminalController] { + return NSApplication.shared.windows.compactMap { + $0.windowController as? TerminalController + } + } + + // Keep track of the last point that our window was launched at so that new + // windows "cascade" over each other and don't just launch directly on top + // of each other. + private static var lastCascadePoint = NSPoint(x: 0, y: 0) + + // The preferred parent terminal controller. + private static var preferredParent: TerminalController? { + all.first { + $0.window?.isMainWindow ?? false + } ?? all.last + } + + /// The "new window" action. + static func newWindow( + _ ghostty: Ghostty.App, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil, + withParent explicitParent: NSWindow? = nil + ) -> TerminalController { + let c = TerminalController.init(ghostty, withBaseConfig: baseConfig) + + // Get our parent. Our parent is the one explicitly given to us, + // otherwise the focused terminal, otherwise an arbitrary one. + let parent: NSWindow? = explicitParent ?? preferredParent?.window + + if let parent { + if parent.styleMask.contains(.fullScreen) { + parent.toggleFullScreen(nil) + } else if ghostty.config.windowFullscreen { + switch (ghostty.config.windowFullscreenMode) { + case .native: + // Native has to be done immediately so that our stylemask contains + // fullscreen for the logic later in this method. + c.toggleFullscreen(mode: .native) + + case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: + // If we're non-native then we have to do it on a later loop + // so that the content view is setup. + DispatchQueue.main.async { + c.toggleFullscreen(mode: ghostty.config.windowFullscreenMode) + } + } + } + } + + // We're dispatching this async because otherwise the lastCascadePoint doesn't + // take effect. Our best theory is there is some next-event-loop-tick logic + // that Cocoa is doing that we need to be after. + DispatchQueue.main.async { + // Only cascade if we aren't fullscreen. + if let window = c.window { + if (!window.styleMask.contains(.fullScreen)) { + Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) + } + } + + c.showWindow(self) + } + + return c + } + + static func newTab( + _ ghostty: Ghostty.App, + from parent: NSWindow? = nil, + withBaseConfig baseConfig: Ghostty.SurfaceConfiguration? = nil + ) -> TerminalController? { + // Making sure that we're dealing with a TerminalController. If not, + // then we just create a new window. + guard let parent, + let parentController = parent.windowController as? TerminalController else { + return newWindow(ghostty, withBaseConfig: baseConfig, withParent: parent) + } + + // If our parent is in non-native fullscreen, then new tabs do not work. + // See: https://github.com/mitchellh/ghostty/issues/392 + if let fullscreenStyle = parentController.fullscreenStyle, + fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { + let alert = NSAlert() + alert.messageText = "Cannot Create New Tab" + alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.beginSheetModal(for: parent) + return nil + } + + // Create a new window and add it to the parent + let controller = TerminalController.init(ghostty, withBaseConfig: baseConfig) + guard let window = controller.window else { return controller } + + // If the parent is miniaturized, then macOS exhibits really strange behaviors + // so we have to bring it back out. + if (parent.isMiniaturized) { parent.deminiaturize(self) } + + // If our parent tab group already has this window, macOS added it and + // we need to remove it so we can set the correct order in the next line. + // If we don't do this, macOS gets really confused and the tabbedWindows + // state becomes incorrect. + // + // At the time of writing this code, the only known case this happens + // is when the "+" button is clicked in the tab bar. + if let tg = parent.tabGroup, + tg.windows.firstIndex(of: window) != nil { + tg.removeWindow(window) + } + + // Our windows start out invisible. We need to make it visible. If we + // don't do this then various features such as window blur won't work because + // the macOS APIs only work on a visible window. + controller.showWindow(self) + + // If we have the "hidden" titlebar style we want to create new + // tabs as windows instead, so just skip adding it to the parent. + if (ghostty.config.macosTitlebarStyle != "hidden") { + // Add the window to the tab group and show it. + switch ghostty.config.windowNewTabPosition { + case "end": + // If we already have a tab group and we want the new tab to open at the end, + // then we use the last window in the tab group as the parent. + if let last = parent.tabGroup?.windows.last { + last.addTabbedWindow(window, ordered: .above) + } else { + fallthrough + } + + case "current": fallthrough + default: + parent.addTabbedWindow(window, ordered: .above) + } + } + + window.makeKeyAndOrderFront(self) + + // It takes an event loop cycle until the macOS tabGroup state becomes + // consistent which causes our tab labeling to be off when the "+" button + // is used in the tab bar. This fixes that. If we can find a more robust + // solution we should do that. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + controller.relabelTabs() + } + + return controller + } + //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { @@ -479,6 +633,44 @@ class TerminalController: BaseTerminalController { tabGroup.windows.forEach { $0.close() } } + /// Close all windows, asking for confirmation if necessary. + static func closeAllWindows() { + let needsConfirm: Bool = all.contains { + $0.surfaceTree.contains { $0.needsConfirmQuit } + } + + if (!needsConfirm) { + closeAllWindowsImmediately() + return + } + + // If we don't have a main window, we just close all windows because + // we have no window to show the modal on top of. I'm sure there's a way + // to do an app-level alert but I don't know how and this case should never + // really happen. + guard let alertWindow = preferredParent?.window else { + closeAllWindowsImmediately() + return + } + + // If we need confirmation by any, show one confirmation for all windows + let alert = NSAlert() + alert.messageText = "Close All Windows?" + alert.informativeText = "All terminal sessions will be terminated." + alert.addButton(withTitle: "Close All Windows") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: alertWindow, completionHandler: { response in + if (response == .alertFirstButtonReturn) { + closeAllWindowsImmediately() + } + }) + } + + static private func closeAllWindowsImmediately() { + all.forEach { $0.close() } + } + // MARK: Undo/Redo /// The state that we require to recreate a TerminalController from an undo. @@ -709,6 +901,35 @@ class TerminalController: BaseTerminalController { override func windowWillClose(_ notification: Notification) { super.windowWillClose(notification) self.relabelTabs() + + // If we remove a window, we reset the cascade point to the key window so that + // the next window cascade's from that one. + if let focusedWindow = NSApplication.shared.keyWindow { + // If we are NOT the focused window, then we are a tabbed window. If we + // are closing a tabbed window, we want to set the cascade point to be + // the next cascade point from this window. + if focusedWindow != window { + // The cascadeTopLeft call below should NOT move the window. Starting with + // macOS 15, we found that specifically when used with the new window snapping + // features of macOS 15, this WOULD move the frame. So we keep track of the + // old frame and restore it if necessary. Issue: + // https://github.com/ghostty-org/ghostty/issues/2565 + let oldFrame = focusedWindow.frame + + Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) + + if focusedWindow.frame != oldFrame { + focusedWindow.setFrame(oldFrame, display: true) + } + + return + } + + // If we are the focused window, then we set the last cascade point to + // our own frame so that it shows up in the same spot. + let frame = focusedWindow.frame + Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) + } } override func windowDidBecomeKey(_ notification: Notification) { diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift deleted file mode 100644 index 050bc5563..000000000 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ /dev/null @@ -1,372 +0,0 @@ -import Cocoa -import SwiftUI -import GhosttyKit -import Combine - -/// Manages a set of terminal windows. This is effectively an array of TerminalControllers. -/// This abstraction helps manage tabs and multi-window scenarios. -class TerminalManager { - struct Window { - let controller: TerminalController - let closePublisher: AnyCancellable - } - - let ghostty: Ghostty.App - - /// The currently focused surface of the main window. - var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface } - - /// The set of windows we currently have. - var windows: [Window] = [] - - // Keep track of the last point that our window was launched at so that new - // windows "cascade" over each other and don't just launch directly on top - // of each other. - private static var lastCascadePoint = NSPoint(x: 0, y: 0) - - /// Returns the main window of the managed window stack. If there is no window - /// then an arbitrary window will be chosen. - private var mainWindow: Window? { - for window in windows { - if (window.controller.window?.isMainWindow ?? false) { - return window - } - } - - // If we have no main window, just use the last window. - return windows.last - } - - /// The configuration derived from the Ghostty config so we don't need to rely on references. - private var derivedConfig: DerivedConfig - - init(_ ghostty: Ghostty.App) { - self.ghostty = ghostty - self.derivedConfig = DerivedConfig(ghostty.config) - - let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(onNewTab), - name: Ghostty.Notification.ghosttyNewTab, - object: nil) - center.addObserver( - self, - selector: #selector(onNewWindow), - name: Ghostty.Notification.ghosttyNewWindow, - object: nil) - center.addObserver( - self, - selector: #selector(ghosttyConfigDidChange(_:)), - name: .ghosttyConfigDidChange, - object: nil) - } - - deinit { - let center = NotificationCenter.default - center.removeObserver(self) - } - - // MARK: - Window Management - - /// Create a new terminal window. - func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - let c = createWindow(withBaseConfig: base) - let window = c.window! - - // If the previous focused window was native fullscreen, the new window also - // becomes native fullscreen. - if let parent = focusedSurface?.window, - parent.styleMask.contains(.fullScreen) { - window.toggleFullScreen(nil) - } else if derivedConfig.windowFullscreen { - switch (derivedConfig.windowFullscreenMode) { - case .native: - // Native has to be done immediately so that our stylemask contains - // fullscreen for the logic later in this method. - c.toggleFullscreen(mode: .native) - - case .nonNative, .nonNativeVisibleMenu, .nonNativePaddedNotch: - // If we're non-native then we have to do it on a later loop - // so that the content view is setup. - DispatchQueue.main.async { - c.toggleFullscreen(mode: self.derivedConfig.windowFullscreenMode) - } - } - } - - // All new_window actions force our app to be active. - NSApp.activate(ignoringOtherApps: true) - - // We're dispatching this async because otherwise the lastCascadePoint doesn't - // take effect. Our best theory is there is some next-event-loop-tick logic - // that Cocoa is doing that we need to be after. - DispatchQueue.main.async { - // Only cascade if we aren't fullscreen. - if (!window.styleMask.contains(.fullScreen)) { - Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) - } - - c.showWindow(self) - } - } - - /// Creates a new tab in the current main window. If there are no windows, a window - /// is created. - func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { - // If there is no main window, just create a new window - guard let parent = mainWindow?.controller.window else { - newWindow(withBaseConfig: base) - return - } - - // Create a new window and add it to the parent - newTab(to: parent, withBaseConfig: base) - } - - private func newTab(to parent: NSWindow, withBaseConfig base: Ghostty.SurfaceConfiguration?) { - // Making sure that we're dealing with a TerminalController - guard parent.windowController is TerminalController else { return } - - // If our parent is in non-native fullscreen, then new tabs do not work. - // See: https://github.com/mitchellh/ghostty/issues/392 - if let controller = parent.windowController as? TerminalController, - let fullscreenStyle = controller.fullscreenStyle, - fullscreenStyle.isFullscreen && !fullscreenStyle.supportsTabs { - let alert = NSAlert() - alert.messageText = "Cannot Create New Tab" - alert.informativeText = "New tabs are unsupported while in non-native fullscreen. Exit fullscreen and try again." - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - alert.beginSheetModal(for: parent) - return - } - - // Create a new window and add it to the parent - let controller = createWindow(withBaseConfig: base) - let window = controller.window! - - // If the parent is miniaturized, then macOS exhibits really strange behaviors - // so we have to bring it back out. - if (parent.isMiniaturized) { parent.deminiaturize(self) } - - // If our parent tab group already has this window, macOS added it and - // we need to remove it so we can set the correct order in the next line. - // If we don't do this, macOS gets really confused and the tabbedWindows - // state becomes incorrect. - // - // At the time of writing this code, the only known case this happens - // is when the "+" button is clicked in the tab bar. - if let tg = parent.tabGroup, tg.windows.firstIndex(of: window) != nil { - tg.removeWindow(window) - } - - // Our windows start out invisible. We need to make it visible. If we - // don't do this then various features such as window blur won't work because - // the macOS APIs only work on a visible window. - controller.showWindow(self) - - // If we have the "hidden" titlebar style we want to create new - // tabs as windows instead, so just skip adding it to the parent. - if (derivedConfig.macosTitlebarStyle != "hidden") { - // Add the window to the tab group and show it. - switch derivedConfig.windowNewTabPosition { - case "end": - // If we already have a tab group and we want the new tab to open at the end, - // then we use the last window in the tab group as the parent. - if let last = parent.tabGroup?.windows.last { - last.addTabbedWindow(window, ordered: .above) - } else { - fallthrough - } - case "current": fallthrough - default: - parent.addTabbedWindow(window, ordered: .above) - - } - } - - window.makeKeyAndOrderFront(self) - - // It takes an event loop cycle until the macOS tabGroup state becomes - // consistent which causes our tab labeling to be off when the "+" button - // is used in the tab bar. This fixes that. If we can find a more robust - // solution we should do that. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { controller.relabelTabs() } - } - - /// Creates a window controller, adds it to our managed list, and returns it. - func createWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, - withSurfaceTree tree: SplitTree? = nil) -> TerminalController { - // Initialize our controller to load the window - let c = TerminalController(ghostty, withBaseConfig: base, withSurfaceTree: tree) - - // Create a listener for when the window is closed so we can remove it. - let pubClose = NotificationCenter.default.publisher( - for: NSWindow.willCloseNotification, - object: c.window! - ).sink { notification in - guard let window = notification.object as? NSWindow else { return } - guard let c = window.windowController as? TerminalController else { return } - self.removeWindow(c) - } - - // Keep track of every window we manage - windows.append(Window( - controller: c, - closePublisher: pubClose - )) - - return c - } - - func removeWindow(_ controller: TerminalController) { - // Remove it from our managed set - guard let idx = self.windows.firstIndex(where: { $0.controller == controller }) else { return } - let w = self.windows[idx] - self.windows.remove(at: idx) - - // Ensure any publishers we have are cancelled - w.closePublisher.cancel() - - // If we remove a window, we reset the cascade point to the key window so that - // the next window cascade's from that one. - if let focusedWindow = NSApplication.shared.keyWindow { - // If we are NOT the focused window, then we are a tabbed window. If we - // are closing a tabbed window, we want to set the cascade point to be - // the next cascade point from this window. - if focusedWindow != controller.window { - // The cascadeTopLeft call below should NOT move the window. Starting with - // macOS 15, we found that specifically when used with the new window snapping - // features of macOS 15, this WOULD move the frame. So we keep track of the - // old frame and restore it if necessary. Issue: - // https://github.com/ghostty-org/ghostty/issues/2565 - let oldFrame = focusedWindow.frame - - Self.lastCascadePoint = focusedWindow.cascadeTopLeft(from: NSZeroPoint) - - if focusedWindow.frame != oldFrame { - focusedWindow.setFrame(oldFrame, display: true) - } - - return - } - - // If we are the focused window, then we set the last cascade point to - // our own frame so that it shows up in the same spot. - let frame = focusedWindow.frame - Self.lastCascadePoint = NSPoint(x: frame.minX, y: frame.maxY) - } - - // I don't think we strictly have to do this but if a window is - // closed I want to make sure that the app state is invalided so - // we don't reopen closed windows. - NSApplication.shared.invalidateRestorableState() - } - - /// Close all windows, asking for confirmation if necessary. - func closeAllWindows() { - var needsConfirm: Bool = false - for w in self.windows { - if w.controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) { - needsConfirm = true - break - } - } - - if (!needsConfirm) { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we don't have a main window, we just close all windows because - // we have no window to show the modal on top of. I'm sure there's a way - // to do an app-level alert but I don't know how and this case should never - // really happen. - guard let alertWindow = mainWindow?.controller.window else { - for w in self.windows { - w.controller.close() - } - - return - } - - // If we need confirmation by any, show one confirmation for all windows - let alert = NSAlert() - alert.messageText = "Close All Windows?" - alert.informativeText = "All terminal sessions will be terminated." - alert.addButton(withTitle: "Close All Windows") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: alertWindow, completionHandler: { response in - if (response == .alertFirstButtonReturn) { - for w in self.windows { - w.controller.close() - } - } - }) - } - - /// Relabels all the tabs with the proper keyboard shortcut. - func relabelAllTabs() { - for w in windows { - w.controller.relabelTabs() - } - } - - // MARK: - Notifications - - @objc private func onNewWindow(notification: SwiftUI.Notification) { - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - self.newWindow(withBaseConfig: config) - } - - @objc private func onNewTab(notification: SwiftUI.Notification) { - guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } - guard let window = surfaceView.window else { return } - - let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] - let config = configAny as? Ghostty.SurfaceConfiguration - - self.newTab(to: window, withBaseConfig: config) - } - - @objc private func ghosttyConfigDidChange(_ notification: Notification) { - // We only care if the configuration is a global configuration, not a - // surface-specific one. - guard notification.object == nil else { return } - - // Get our managed configuration object out - guard let config = notification.userInfo?[ - Notification.Name.GhosttyConfigChangeKey - ] as? Ghostty.Config else { return } - - // Update our derived config - self.derivedConfig = DerivedConfig(config) - } - - private struct DerivedConfig { - let windowFullscreen: Bool - let windowFullscreenMode: FullscreenMode - let macosTitlebarStyle: String - let windowNewTabPosition: String - - init() { - self.windowFullscreen = false - self.windowFullscreenMode = .native - self.macosTitlebarStyle = "transparent" - self.windowNewTabPosition = "" - } - - init(_ config: Ghostty.Config) { - self.windowFullscreen = config.windowFullscreen - self.windowFullscreenMode = config.windowFullscreenMode - self.macosTitlebarStyle = config.macosTitlebarStyle - self.windowNewTabPosition = config.windowNewTabPosition - } - } -} diff --git a/macos/Sources/Features/Terminal/TerminalRestorable.swift b/macos/Sources/Features/Terminal/TerminalRestorable.swift index 5229dc46e..9d9b7ffb1 100644 --- a/macos/Sources/Features/Terminal/TerminalRestorable.swift +++ b/macos/Sources/Features/Terminal/TerminalRestorable.swift @@ -83,9 +83,9 @@ class TerminalWindowRestoration: NSObject, NSWindowRestoration { // can be found for events from libghostty. This uses the low-level // createWindow so that AppKit can place the window wherever it should // be. - let c = appDelegate.terminalManager.createWindow( - withSurfaceTree: state.surfaceTree - ) + let c = TerminalController.init( + appDelegate.ghostty, + withSurfaceTree: state.surfaceTree) guard let window = c.window else { completionHandler(nil, TerminalRestoreError.windowDidNotLoad) return