import Foundation import Cocoa import SwiftUI import Combine import GhosttyKit /// A classic, tabbed terminal experience. class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Controller { override var windowNibName: NSNib.Name? { let defaultValue = "Terminal" guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } let config = appDelegate.ghostty.config // If we have no window decorations, there's no reason to do anything but // the default titlebar (because there will be no titlebar). if !config.windowDecorations { return defaultValue } let nib = switch config.macosTitlebarStyle { case "native": "Terminal" case "hidden": "TerminalHiddenTitlebar" case "transparent": "TerminalTransparentTitlebar" case "tabs": if #available(macOS 26.0, *) { "TerminalTabsTitlebarTahoe" } else { "TerminalTabsTitlebarVentura" } default: defaultValue } return nib } /// This is set to true when we care about frame changes. This is a small optimization since /// this controller registers a listener for ALL frame change notifications and this lets us bail /// early if we don't care. private var tabListenForFrame: Bool = false /// This is the hash value of the last tabGroup.windows array. We use this to detect order /// changes in the list. private var tabWindowsHash: Int = 0 /// This is set to false by init if the window managed by this controller should not be restorable. /// For example, terminals executing custom scripts are not restorable. private var restorable: Bool = true /// The configuration derived from the Ghostty config so we don't need to rely on references. private(set) var derivedConfig: DerivedConfig /// The notification cancellable for focused surface property changes. private var surfaceAppearanceCancellables: Set = [] /// This will be set to the initial frame of the window from the xib on load. private var initialFrame: NSRect? = nil init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = 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 // time of writing this: it'd just restore to a shell in the same directory // as the script. We may want to revisit this behavior when we have scrollback // restoration. self.restorable = (base?.command ?? "") == "" // Setup our initial derived config based on the current app config self.derivedConfig = DerivedConfig(ghostty.config) super.init(ghostty, baseConfig: base, surfaceTree: tree) // Setup our notifications for behaviors let center = NotificationCenter.default center.addObserver( self, selector: #selector(onToggleFullscreen), name: Ghostty.Notification.ghosttyToggleFullscreen, object: nil) center.addObserver( self, selector: #selector(onMoveTab), name: .ghosttyMoveTab, object: nil) center.addObserver( self, selector: #selector(onGotoTab), name: Ghostty.Notification.ghosttyGotoTab, object: nil) center.addObserver( self, selector: #selector(onCloseTab), name: .ghosttyCloseTab, object: nil) center.addObserver( self, selector: #selector(onResetWindowSize), name: .ghosttyResetWindowSize, object: nil ) center.addObserver( self, selector: #selector(ghosttyConfigDidChange(_:)), name: .ghosttyConfigDidChange, object: nil ) center.addObserver( self, selector: #selector(onFrameDidChange), name: NSView.frameDidChangeNotification, object: nil) center.addObserver( self, selector: #selector(onCloseWindow), name: .ghosttyCloseWindow, object: nil ) } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } deinit { // Remove all of our notificationcenter subscriptions let center = NotificationCenter.default center.removeObserver(self) } // MARK: Base Controller Overrides override func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { super.surfaceTreeDidChange(from: from, to: to) // Whenever our surface tree changes in any way (new split, close split, etc.) // we want to invalidate our state. invalidateRestorableState() // Update our zoom state if let window = window as? TerminalWindow { window.surfaceIsZoomed = to.zoomed != nil } // If our surface tree is now nil then we close our window. if (to.isEmpty) { self.window?.close() } } override func fullscreenDidChange() { super.fullscreenDidChange() // When our fullscreen state changes, we resync our appearance because some // properties change when fullscreen or not. guard let focusedSurface else { return } 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. 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) } // Setup our undo if let undoManager = c.undoManager { undoManager.setActionName("New Window") undoManager.registerUndo( withTarget: c, expiresAfter: c.undoExpiration ) { target in // Close the window when undoing undoManager.disableUndoRegistration { target.closeWindow(nil) } // Register redo action undoManager.registerUndo( withTarget: ghostty, expiresAfter: target.undoExpiration ) { ghostty in _ = TerminalController.newWindow( ghostty, withBaseConfig: baseConfig, withParent: explicitParent) } } } 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) } // If we don't allow tabs then we create a new window instead. if (window.tabbingMode != .disallowed) { // 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) } } // 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 and are alone in the tab group. if !window.styleMask.contains(.fullScreen) && window.tabGroup?.windows.count ?? 1 == 1 { Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) } controller.showWindow(self) 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() } // Setup our undo if let undoManager = parentController.undoManager { undoManager.setActionName("New Tab") undoManager.registerUndo( withTarget: controller, expiresAfter: controller.undoExpiration ) { target in // Close the tab when undoing undoManager.disableUndoRegistration { target.closeTab(nil) } // Register redo action undoManager.registerUndo( withTarget: ghostty, expiresAfter: target.undoExpiration ) { ghostty in _ = TerminalController.newTab( ghostty, from: parent, withBaseConfig: baseConfig) } } } return controller } //MARK: - Methods @objc private func ghosttyConfigDidChange(_ notification: Notification) { // Get our managed configuration object out guard let config = notification.userInfo?[ Notification.Name.GhosttyConfigChangeKey ] as? Ghostty.Config else { return } // If this is an app-level config update then we update some things. if (notification.object == nil) { // Update our derived config self.derivedConfig = DerivedConfig(config) // If we have no surfaces in our window (is that possible?) then we update // our window appearance based on the root config. If we have surfaces, we // don't call this because focused surface changes will trigger appearance updates. if surfaceTree.isEmpty { syncAppearance(.init(config)) } return } // This is a surface-level config update. If we have the surface, we // update our appearance based on it. guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(surfaceView) else { return } // We can't use surfaceView.derivedConfig because it may not be updated // yet since it also responds to notifications. syncAppearance(.init(config)) } /// Update the accessory view of each tab according to the keyboard /// shortcut that activates it (if any). This is called when the key window /// changes, when a window is closed, and when tabs are reordered /// with the mouse. func relabelTabs() { // We only listen for frame changes if we have more than 1 window, // otherwise the accessory view doesn't matter. tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1 if let windows = window?.tabbedWindows as? [TerminalWindow] { for (tab, window) in zip(1..., windows) { // We need to clear any windows beyond this because they have had // a keyEquivalent set previously. guard tab <= 9 else { window.keyEquivalent = "" continue } let action = "goto_tab:\(tab)" if let equiv = ghostty.config.keyboardShortcut(for: action) { window.keyEquivalent = "\(equiv)" } else { window.keyEquivalent = "" } } } } private func fixTabBar() { // We do this to make sure that the tab bar will always re-composite. If we don't, // then the it will "drag" pieces of the background with it when a transparent // window is moved around. // // There might be a better way to make the tab bar "un-lazy", but I can't find it. if let window = window, !window.isOpaque { window.isOpaque = true window.isOpaque = false } } @objc private func onFrameDidChange(_ notification: NSNotification) { // This is a huge hack to set the proper shortcut for tab selection // on tab reordering using the mouse. There is no event, delegate, etc. // as far as I can tell for when a tab is manually reordered with the // mouse in a macOS-native tab group, so the way we detect it is setting // the accessoryView "postsFrameChangedNotification" to true, listening // for the view frame to change, comparing the windows list, and // relabeling the tabs. guard tabListenForFrame else { return } guard let v = self.window?.tabbedWindows?.hashValue else { return } guard tabWindowsHash != v else { return } tabWindowsHash = v self.relabelTabs() } private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { // Let our window handle its own appearance guard let window = window as? TerminalWindow else { return } // Sync our zoom state for splits window.surfaceIsZoomed = surfaceTree.zoomed != nil // Set the font for the window and tab titles. if let titleFontName = surfaceConfig.windowTitleFontFamily { window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize) } else { window.titlebarFont = nil } // Call this last in case it uses any of the properties above. window.syncAppearance(surfaceConfig) } /// Returns the default size of the window. This is contextual based on the focused surface because /// the focused surface may specify a different default size than others. private var defaultSize: NSRect? { guard let screen = window?.screen ?? NSScreen.main else { return nil } if derivedConfig.maximize { return screen.visibleFrame } else if let focusedSurface, let initialSize = focusedSurface.initialSize { // Get the current frame of the window guard var frame = window?.frame else { return nil } // Calculate the chrome size (window size minus view size) let chromeWidth = frame.size.width - focusedSurface.frame.size.width let chromeHeight = frame.size.height - focusedSurface.frame.size.height // Calculate the new width and height, clamping to the screen's size let newWidth = min(initialSize.width + chromeWidth, screen.visibleFrame.width) let newHeight = min(initialSize.height + chromeHeight, screen.visibleFrame.height) // Update the frame size while keeping the window's position intact frame.size.width = newWidth frame.size.height = newHeight // Ensure the window doesn't go outside the screen boundaries frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) return frame } guard let initialFrame else { return nil } guard var frame = window?.frame else { return nil } // Calculate the new width and height, clamping to the screen's size let newWidth = min(initialFrame.size.width, screen.visibleFrame.width) let newHeight = min(initialFrame.size.height, screen.visibleFrame.height) // Update the frame size while keeping the window's position intact frame.size.width = newWidth frame.size.height = newHeight // Ensure the window doesn't go outside the screen boundaries frame.origin.x = max(screen.frame.origin.x, min(frame.origin.x, screen.frame.maxX - newWidth)) frame.origin.y = max(screen.frame.origin.y, min(frame.origin.y, screen.frame.maxY - newHeight)) return frame } /// This is called anytime a node in the surface tree is being removed. override func closeSurface( _ node: SplitTree.Node, withConfirmation: Bool = true ) { // If this isn't the root then we're dealing with a split closure. if surfaceTree.root != node { super.closeSurface(node, withConfirmation: withConfirmation) return } // More than 1 window means we have tabs and we're closing a tab if window?.tabGroup?.windows.count ?? 0 > 1 { closeTab(nil) return } // 1 window, closing the window closeWindow(nil) } private func closeTabImmediately() { guard let window = window else { return } guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { closeWindowImmediately() return } // Undo if let undoManager, let undoState { // Register undo action to restore the tab undoManager.setActionName("Close Tab") undoManager.registerUndo( withTarget: ghostty, expiresAfter: undoExpiration ) { ghostty in let newController = TerminalController(ghostty, with: undoState) // Register redo action undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration ) { target in target.closeTabImmediately() } } } window.close() } /// Closes the current window (including any other tabs) immediately and without /// confirmation. This will setup proper undo state so the action can be undone. private func closeWindowImmediately() { guard let window = window else { return } registerUndoForCloseWindow() if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { tabGroup.windows.forEach { window in // Clear out the surfacetree to ensure there is no undo state. // This prevents unnecessary undos registered since AppKit may // process them on later ticks so we can't just disable undo registration. if let controller = window.windowController as? TerminalController { controller.surfaceTree = .init() } window.close() } } else { window.close() } } /// Registers undo for closing window(s), handling both single windows and tab groups. private func registerUndoForCloseWindow() { guard let undoManager, undoManager.isUndoRegistrationEnabled else { return } guard let window else { return } // If we don't have a tab group or we don't have multiple tabs, then // do a normal single window close. guard let tabGroup = window.tabGroup, tabGroup.windows.count > 1 else { // No tabs, just save this window's state if let undoState { // Register undo action to restore the window undoManager.setActionName("Close Window") undoManager.registerUndo( withTarget: ghostty, expiresAfter: undoExpiration) { ghostty in // Restore the undo state let newController = TerminalController(ghostty, with: undoState) // Register redo action undoManager.registerUndo( withTarget: newController, expiresAfter: newController.undoExpiration) { target in target.closeWindowImmediately() } } } return } // Multiple windows in tab group - collect all undo states in sorted order // by tab ordering. Also track which window was key. let undoStates = tabGroup.windows .compactMap { tabWindow -> UndoState? in guard let controller = tabWindow.windowController as? TerminalController, var undoState = controller.undoState else { return nil } // Clear the tab group reference since it is unneeded. It should be // garbage collected but we want to be extra sure we don't try to // restore into it because we're going to recreate it. undoState.tabGroup = nil return undoState } .sorted { (lhs, rhs) in switch (lhs.tabIndex, rhs.tabIndex) { case let (l?, r?): return l < r case (_?, nil): return true case (nil, _?): return false case (nil, nil): return true } } // Find the index of the key window in our sorted states. This is a bit verbose // but we only need this for this style of undo so we don't want to add it to // UndoState. let keyWindowIndex: Int? if let keyWindow = tabGroup.windows.first(where: { $0.isKeyWindow }), let keyController = keyWindow.windowController as? TerminalController, let keyUndoState = keyController.undoState { keyWindowIndex = undoStates.firstIndex { $0.tabIndex == keyUndoState.tabIndex } } else { keyWindowIndex = nil } // Register undo action to restore all windows guard !undoStates.isEmpty else { return } undoManager.setActionName("Close Window") undoManager.registerUndo( withTarget: ghostty, expiresAfter: undoExpiration ) { ghostty in // Restore all windows in the tab group let controllers = undoStates.map { undoState in TerminalController(ghostty, with: undoState) } // The first controller becomes the parent window for all tabs. // If we don't have a first controller (shouldn't be possible?) // then we can't restore tabs. guard let firstController = controllers.first else { return } // Add all subsequent controllers as tabs to the first window for controller in controllers.dropFirst() { controller.showWindow(nil) if let firstWindow = firstController.window, let newWindow = controller.window { firstWindow.addTabbedWindow(newWindow, ordered: .above) } } // Make the appropriate window key. If we had a key window, restore it. // Otherwise, make the last window key. if let keyWindowIndex, keyWindowIndex < controllers.count { controllers[keyWindowIndex].window?.makeKeyAndOrderFront(nil) } else { controllers.last?.window?.makeKeyAndOrderFront(nil) } // Register redo action on the first controller undoManager.registerUndo( withTarget: firstController, expiresAfter: firstController.undoExpiration ) { target in target.closeWindowImmediately() } } } /// 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() { let undoManager = (NSApp.delegate as? AppDelegate)?.undoManager undoManager?.beginUndoGrouping() all.forEach { $0.closeWindowImmediately() } undoManager?.setActionName("Close All Windows") undoManager?.endUndoGrouping() } // MARK: Undo/Redo /// The state that we require to recreate a TerminalController from an undo. struct UndoState { let frame: NSRect let surfaceTree: SplitTree let focusedSurface: UUID? let tabIndex: Int? weak var tabGroup: NSWindowTabGroup? } convenience init(_ ghostty: Ghostty.App, with undoState: UndoState ) { self.init(ghostty, withSurfaceTree: undoState.surfaceTree) // Show the window and restore its frame showWindow(nil) if let window { window.setFrame(undoState.frame, display: true) // If we have a tab group and index, restore the tab to its original position if let tabGroup = undoState.tabGroup, let tabIndex = undoState.tabIndex { if tabIndex < tabGroup.windows.count { // Find the window that is currently at that index let currentWindow = tabGroup.windows[tabIndex] currentWindow.addTabbedWindow(window, ordered: .below) } else { tabGroup.windows.last?.addTabbedWindow(window, ordered: .above) } // Make it the key window window.makeKeyAndOrderFront(nil) } // Restore focus to the previously focused surface if let focusedUUID = undoState.focusedSurface, let focusTarget = surfaceTree.first(where: { $0.uuid == focusedUUID }) { DispatchQueue.main.async { Ghostty.moveFocus(to: focusTarget, from: nil) } } } } /// The current undo state for this controller var undoState: UndoState? { guard let window else { return nil } guard !surfaceTree.isEmpty else { return nil } return .init( frame: window.frame, surfaceTree: surfaceTree, focusedSurface: focusedSurface?.uuid, tabIndex: window.tabGroup?.windows.firstIndex(of: window), tabGroup: window.tabGroup) } //MARK: - NSWindowController override func windowWillLoad() { // We do NOT want to cascade because we handle this manually from the manager. shouldCascadeWindows = false } override func windowDidLoad() { super.windowDidLoad() guard let window else { return } // Store our initial frame so we can know our default later. initialFrame = window.frame // I copy this because we may change the source in the future but also because // I regularly audit our codebase for "ghostty.config" access because generally // you shouldn't use it. Its safe in this case because for a new window we should // use whatever the latest app-level config is. let config = ghostty.config // Setting all three of these is required for restoration to work. window.isRestorable = restorable if (restorable) { window.restorationClass = TerminalWindowRestoration.self window.identifier = .init(String(describing: TerminalWindowRestoration.self)) } // If we have only a single surface (no splits) and there is a default size then // we should resize to that default size. if case let .leaf(view) = surfaceTree.root { // If this is our first surface then our focused surface will be nil // so we force the focused surface to the leaf. focusedSurface = view if let defaultSize { window.setFrame(defaultSize, display: true) } } // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, viewModel: self, delegate: self )) // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. // // Example scenarios where this happens: // - When the system user tabbing preference is "always" // - When the "+" button in the tab bar is clicked // // We don't run this logic in fullscreen because in fullscreen this will end up // removing the window and putting it into its own dedicated fullscreen, which is not // the expected or desired behavior of anyone I've found. if (!window.styleMask.contains(.fullScreen)) { // If we have more than 1 window in our tab group we know we're a new window. // Since Ghostty manages tabbing manually this will never be more than one // at this point in the AppKit lifecycle (we add to the group after this). if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 { window.tabGroup?.removeWindow(window) } } // Apply any additional appearance-related properties to the new window. We // apply this based on the root config but change it later based on surface // config (see focused surface change callback). syncAppearance(.init(config)) } // Shows the "+" button in the tab bar, responds to that click. override func newWindowForTab(_ sender: Any?) { // Trigger the ghostty core event logic for a new tab. guard let surface = self.focusedSurface?.surface else { return } ghostty.newTab(surface: surface) } // MARK: NSWindowDelegate // TabGroupCloseCoordinator.Controller lazy private(set) var tabGroupCloseCoordinator = TabGroupCloseCoordinator() override func windowShouldClose(_ sender: NSWindow) -> Bool { tabGroupCloseCoordinator.windowShouldClose(sender) { [weak self] scope in guard let self else { return } switch (scope) { case .tab: closeTab(nil) case .window: guard self.window?.isFirstWindowInTabGroup ?? false else { return } closeWindow(nil) } } // We will always explicitly close the window using the above return false } 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) { super.windowDidBecomeKey(notification) self.relabelTabs() self.fixTabBar() } override func windowDidMove(_ notification: Notification) { super.windowDidMove(notification) self.fixTabBar() // Whenever we move save our last position for the next start. if let window { LastWindowPosition.shared.save(window) } } func windowDidBecomeMain(_ notification: Notification) { // Whenever we get focused, use that as our last window position for // restart. This differs from Terminal.app but matches iTerm2 behavior // and I think its sensible. if let window { LastWindowPosition.shared.save(window) } } // Called when the window will be encoded. We handle the data encoding here in the // window controller. func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { let data = TerminalRestorableState(from: self) data.encode(with: state) } // MARK: First Responder @IBAction func newWindow(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } ghostty.newWindow(surface: surface) } @IBAction func newTab(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } ghostty.newTab(surface: surface) } @IBAction func closeTab(_ sender: Any?) { guard let window = window else { return } guard window.tabGroup?.windows.count ?? 0 > 1 else { closeWindow(sender) return } guard surfaceTree.contains(where: { $0.needsConfirmQuit }) else { closeTabImmediately() return } confirmClose( messageText: "Close Tab?", informativeText: "The terminal still has a running process. If you close the tab the process will be killed." ) { self.closeTabImmediately() } } @IBAction func returnToDefaultSize(_ sender: Any?) { guard let defaultSize else { return } window?.setFrame(defaultSize, display: true) } @IBAction override func closeWindow(_ sender: Any?) { guard let window = window else { return } // We need to check all the windows in our tab group for confirmation // if we're closing the window. If we don't have a tabgroup for any // reason we check ourselves. let windows: [NSWindow] = window.tabGroup?.windows ?? [window] // Check if any windows require close confirmation. let needsConfirm = windows.contains { tabWindow in guard let controller = tabWindow.windowController as? TerminalController else { return false } return controller.surfaceTree.contains(where: { $0.needsConfirmQuit }) } // If none need confirmation then we can just close all the windows. if !needsConfirm { closeWindowImmediately() return } confirmClose( messageText: "Close Window?", informativeText: "All terminal sessions in this window will be terminated." ) { self.closeWindowImmediately() } } @IBAction func toggleGhosttyFullScreen(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleFullscreen(surface: surface) } @IBAction func toggleTerminalInspector(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleTerminalInspector(surface: surface) } //MARK: - TerminalViewDelegate override func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { super.focusedSurfaceDidChange(to: to) // We always cancel our event listener surfaceAppearanceCancellables.removeAll() // When our focus changes, we update our window appearance based on the // currently focused surface. guard let focusedSurface else { return } syncAppearance(focusedSurface.derivedConfig) // We also want to get notified of certain changes to update our appearance. focusedSurface.$derivedConfig .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } .store(in: &surfaceAppearanceCancellables) focusedSurface.$backgroundColor .sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) } .store(in: &surfaceAppearanceCancellables) } private func syncAppearanceOnPropertyChange(_ surface: Ghostty.SurfaceView?) { guard let surface else { return } DispatchQueue.main.async { [weak self, weak surface] in guard let surface else { return } guard let self else { return } guard self.focusedSurface == surface else { return } self.syncAppearance(surface.derivedConfig) } } //MARK: - Notifications @objc private func onMoveTab(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard target == self.focusedSurface else { return } guard let window = self.window else { return } // Get the move action guard let action = notification.userInfo?[Notification.Name.GhosttyMoveTabKey] as? Ghostty.Action.MoveTab else { return } guard action.amount != 0 else { return } // Determine our current selected index guard let windowController = window.windowController else { return } guard let tabGroup = windowController.window?.tabGroup else { return } guard let selectedWindow = tabGroup.selectedWindow else { return } let tabbedWindows = tabGroup.windows guard tabbedWindows.count > 0 else { return } guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return } // Determine the final index we want to insert our tab let finalIndex: Int if action.amount < 0 { finalIndex = selectedIndex - min(selectedIndex, -action.amount) } else { let remaining: Int = tabbedWindows.count - 1 - selectedIndex finalIndex = selectedIndex + min(remaining, action.amount) } // If our index is the same we do nothing guard finalIndex != selectedIndex else { return } // Get our target window let targetWindow = tabbedWindows[finalIndex] // Begin a group of window operations to minimize visual updates NSAnimationContext.beginGrouping() NSAnimationContext.current.duration = 0 // Remove and re-add the window in the correct position tabGroup.removeWindow(selectedWindow) targetWindow.addTabbedWindow(selectedWindow, ordered: action.amount < 0 ? .below : .above) // Ensure our window remains selected selectedWindow.makeKey() NSAnimationContext.endGrouping() } @objc private func onGotoTab(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard target == self.focusedSurface else { return } guard let window = self.window else { return } // Get the tab index from the notification guard let tabEnumAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return } guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return } let tabIndex: Int32 = tabEnum.rawValue guard let windowController = window.windowController else { return } guard let tabGroup = windowController.window?.tabGroup else { return } let tabbedWindows = tabGroup.windows // This will be the index we want to actual go to let finalIndex: Int // An index that is invalid is used to signal some special values. if (tabIndex <= 0) { guard let selectedWindow = tabGroup.selectedWindow else { return } guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return } if (tabIndex == GHOSTTY_GOTO_TAB_PREVIOUS.rawValue) { if (selectedIndex == 0) { finalIndex = tabbedWindows.count - 1 } else { finalIndex = selectedIndex - 1 } } else if (tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue) { if (selectedIndex == tabbedWindows.count - 1) { finalIndex = 0 } else { finalIndex = selectedIndex + 1 } } else if (tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue) { finalIndex = tabbedWindows.count - 1 } else { return } } else { // The configured value is 1-indexed. guard tabIndex >= 1 else { return } // If our index is outside our boundary then we use the max finalIndex = min(Int(tabIndex - 1), tabbedWindows.count - 1) } guard finalIndex >= 0 else { return } let targetWindow = tabbedWindows[finalIndex] targetWindow.makeKeyAndOrderFront(nil) } @objc private func onCloseTab(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } closeTab(self) } @objc private func onCloseWindow(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } closeWindow(self) } @objc private func onResetWindowSize(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } returnToDefaultSize(nil) } @objc private func onToggleFullscreen(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard target == self.focusedSurface else { return } // Get the fullscreen mode we want to toggle let fullscreenMode: FullscreenMode if let any = notification.userInfo?[Ghostty.Notification.FullscreenModeKey], let mode = any as? FullscreenMode { fullscreenMode = mode } else { Ghostty.logger.warning("no fullscreen mode specified or invalid mode, doing nothing") return } toggleFullscreen(mode: fullscreenMode) } struct DerivedConfig { let backgroundColor: Color let macosWindowButtons: Ghostty.MacOSWindowButtons let macosTitlebarStyle: String let maximize: Bool init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.macosWindowButtons = .visible self.macosTitlebarStyle = "system" self.maximize = false } init(_ config: Ghostty.Config) { self.backgroundColor = config.backgroundColor self.macosWindowButtons = config.macosWindowButtons self.macosTitlebarStyle = config.macosTitlebarStyle self.maximize = config.maximize } } } // MARK: NSMenuItemValidation extension TerminalController: NSMenuItemValidation { func validateMenuItem(_ item: NSMenuItem) -> Bool { switch item.action { case #selector(returnToDefaultSize): guard let window else { return false } // Native fullscreen windows can't revert to default size. if window.styleMask.contains(.fullScreen) { return false } // If we're fullscreen at all then we can't change size if fullscreenStyle?.isFullscreen ?? false { return false } // If our window is already the default size or we don't have a // default size, then disable. guard let defaultSize, window.frame.size != .init( width: defaultSize.size.width, height: defaultSize.size.height ) else { return false } return true default: return true } } }