ghostty/macos/Sources/Features/Terminal/TerminalController.swift
Mitchell Hashimoto 1f340b4b2d macos: for windowShouldClose, only close the tab if we have multiple
Fixes a regression from our undo/redo rework. We were accidentally
closing the entire window when the "X" button in the tab bar was
clicked.
2025-06-10 12:39:09 -07:00

1452 lines
57 KiB
Swift

import Foundation
import Cocoa
import SwiftUI
import Combine
import GhosttyKit
/// A classic, tabbed terminal experience.
class TerminalController: BaseTerminalController {
override var windowNibName: NSNib.Name? { "Terminal" }
/// 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<AnyCancellable> = []
/// 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<Ghostty.SurfaceView>? = 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<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
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()
}
}
func fullscreenDidChange() {
// When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not.
guard let focusedSurface else { return }
if (!(fullscreenStyle?.isFullscreen ?? false) &&
ghostty.config.macosTitlebarStyle == "hidden")
{
applyHiddenTitlebarStyle()
}
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)
}
// 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)
}
// 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()
}
// 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() {
// Reset this to false. It'll be set back to true later.
tabListenForFrame = false
guard let windows = self.window?.tabbedWindows as? [TerminalWindow] else { return }
// We only listen for frame changes if we have more than 1 window,
// otherwise the accessory view doesn't matter.
tabListenForFrame = windows.count > 1
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) {
guard let window = self.window as? TerminalWindow else { return }
// Set our explicit appearance if we need to based on the configuration.
window.appearance = surfaceConfig.windowAppearance
// Update our window light/darkness based on our updated background color
window.isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
// Sync our zoom state for splits
window.surfaceIsZoomed = surfaceTree.zoomed != nil
// If our window is not visible, then we do nothing. Some things such as blurring
// have no effect if the window is not visible. Ultimately, we'll have this called
// at some point when a surface becomes focused.
guard window.isVisible else { return }
// 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
}
// If we have window transparency then set it transparent. Otherwise set it opaque.
// Window transparency only takes effect if our window is not native fullscreen.
// In native fullscreen we disable transparency/opacity because the background
// becomes gray and widgets show through.
if (!window.styleMask.contains(.fullScreen) &&
surfaceConfig.backgroundOpacity < 1
) {
window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that
// matches Terminal.app much more closer. This lets users transition from
// Terminal.app more easily.
window.backgroundColor = .white.withAlphaComponent(0.001)
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
} else {
window.isOpaque = true
window.backgroundColor = .windowBackgroundColor
}
window.hasShadow = surfaceConfig.macosWindowShadow
guard window.hasStyledTabs else { return }
// Our background color depends on if our focused surface borders the top or not.
// If it does, we match the focused surface. If it doesn't, we use the app
// configuration.
let backgroundColor: OSColor
if !surfaceTree.isEmpty {
if let focusedSurface = focusedSurface,
let treeRoot = surfaceTree.root,
let focusedNode = treeRoot.node(view: focusedSurface),
treeRoot.spatial().doesBorder(side: .up, from: focusedNode) {
// Similar to above, an alpha component of "0" causes compositor issues, so
// we use 0.001. See: https://github.com/ghostty-org/ghostty/pull/4308
backgroundColor = OSColor(focusedSurface.backgroundColor ?? surfaceConfig.backgroundColor).withAlphaComponent(0.001)
} else {
// We don't have a focused surface or our surface doesn't border the
// top. We choose to match the color of the top-left most surface.
let topLeftSurface = surfaceTree.root?.leftmostLeaf()
backgroundColor = OSColor(topLeftSurface?.backgroundColor ?? derivedConfig.backgroundColor)
}
} else {
backgroundColor = OSColor(self.derivedConfig.backgroundColor)
}
window.titlebarColor = backgroundColor.withAlphaComponent(surfaceConfig.backgroundOpacity)
if (window.isOpaque) {
// Bg color is only synced if we have no transparency. This is because
// the transparency is handled at the surface level (window.backgroundColor
// ignores alpha components)
window.backgroundColor = backgroundColor
// If there is transparency, calling this will make the titlebar opaque
// so we only call this if we are opaque.
window.updateTabBar()
}
}
private func setInitialWindowPosition(x: Int16?, y: Int16?, windowDecorations: Bool) {
guard let window else { return }
// If we don't have an X/Y then we try to use the previously saved window pos.
guard let x, let y else {
if (!LastWindowPosition.shared.restore(window)) {
window.center()
}
return
}
// Prefer the screen our window is being placed on otherwise our primary screen.
guard let screen = window.screen ?? NSScreen.screens.first else {
window.center()
return
}
// Orient based on the top left of the primary monitor
let frame = screen.visibleFrame
window.setFrameOrigin(.init(
x: frame.minX + CGFloat(x),
y: frame.maxY - (CGFloat(y) + window.frame.height)))
}
/// 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 closeSurfaceNode(
_ node: SplitTree<Ghostty.SurfaceView>.Node,
withConfirmation: Bool = true
) {
// If this isn't the root then we're dealing with a split closure.
if surfaceTree.root != node {
super.closeSurfaceNode(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<Ghostty.SurfaceView>
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
}
fileprivate func hideWindowButtons() {
guard let window else { return }
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
}
fileprivate func applyHiddenTitlebarStyle() {
guard let window else { return }
window.styleMask = [
// We need `titled` in the mask to get the normal window frame
.titled,
// Full size content view so we can extend
// content in to the hidden titlebar's area
.fullSizeContentView,
.resizable,
.closable,
.miniaturizable,
]
// Hide the title
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
// Hide the traffic lights (window control buttons)
hideWindowButtons()
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
window.tabbingMode = .disallowed
// Nuke it from orbit -- hide the titlebar container entirely, just in case. There are
// some operations that appear to bring back the titlebar visibility so this ensures
// it is gone forever.
if let themeFrame = window.contentView?.superview,
let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") {
titleBarContainer.isHidden = true
}
}
override func windowDidLoad() {
super.windowDidLoad()
guard let window = window as? TerminalWindow 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 window decorations are disabled, remove our title
if (!config.windowDecorations) { window.styleMask.remove(.titled) }
// 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)
}
}
// Set our window positioning to coordinates if config value exists, otherwise
// fallback to original centering behavior
setInitialWindowPosition(
x: config.windowPositionX,
y: config.windowPositionY,
windowDecorations: config.windowDecorations)
if config.macosWindowButtons == .hidden {
hideWindowButtons()
}
// Make sure our theme is set on the window so styling is correct.
if let windowTheme = config.windowTheme {
window.windowTheme = .init(rawValue: windowTheme)
}
// Handle titlebar tabs config option. Something about what we do while setting up the
// titlebar tabs interferes with the window restore process unless window.tabbingMode
// is set to .preferred, so we set it, and switch back to automatic as soon as we can.
if (config.macosTitlebarStyle == "tabs") {
window.tabbingMode = .preferred
window.titlebarTabs = true
DispatchQueue.main.async {
window.tabbingMode = .automatic
}
} else if (config.macosTitlebarStyle == "transparent") {
window.transparentTabs = true
}
if window.hasStyledTabs {
// Set the background color of the window
let backgroundColor = NSColor(config.backgroundColor)
window.backgroundColor = backgroundColor
// This makes sure our titlebar renders correctly when there is a transparent background
window.titlebarColor = backgroundColor.withAlphaComponent(config.backgroundOpacity)
}
// Initialize our content view to the SwiftUI root
window.contentView = NSHostingView(rootView: TerminalView(
ghostty: self.ghostty,
viewModel: self,
delegate: self
))
// If our titlebar style is "hidden" we adjust the style appropriately
if (config.macosTitlebarStyle == "hidden") {
applyHiddenTitlebarStyle()
}
// 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
override func windowShouldClose(_ sender: NSWindow) -> Bool {
// If we have tabs, then this should only close the tab.
if window?.tabGroup?.windows.count ?? 0 > 1 {
closeTab(sender)
} else {
closeWindow(sender)
}
// 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 }
guard let tabGroup = window.tabGroup else {
// No tabs, no tab group, just perform a normal close.
closeWindowImmediately()
return
}
// If have one window then we just do a normal close
if tabGroup.windows.count == 1 {
closeWindowImmediately()
return
}
// Check if any windows require close confirmation.
let needsConfirm = tabGroup.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 titleDidChange(to: String) {
super.titleDidChange(to: to)
guard let window = window as? TerminalWindow else { return }
// Custom toolbar-based title used when titlebar tabs are enabled.
if let toolbar = window.toolbar as? TerminalToolbar {
if (window.titlebarTabs || derivedConfig.macosTitlebarStyle == "hidden") {
// Updating the title text as above automatically reveals the
// native title view in macOS 15.0 and above. Since we're using
// a custom view instead, we need to re-hide it.
window.titleVisibility = .hidden
}
toolbar.titleText = to
}
}
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
}
}
}