import Cocoa import SwiftUI import Combine import GhosttyKit /// A base class for windows that can contain Ghostty windows. This base class implements /// the bare minimum functionality that every terminal window in Ghostty should implement. /// /// Usage: Specify this as the base class of your window controller for the window that contains /// a terminal. The window controller must also be the window delegate OR the window delegate /// functions on this base class must be called by your own custom delegate. For the terminal /// view the TerminalView SwiftUI view must be used and this class is the view model and /// delegate. /// /// Special considerations to implement: /// /// - Fullscreen: you must manually listen for the right notification and implement the /// callback that calls toggleFullscreen on this base class. /// /// Notably, things this class does NOT implement (not exhaustive): /// /// - Tabbing, because there are many ways to get tabbed behavior in macOS and we /// don't want to be opinionated about it. /// - Window restoration or save state /// - Window visual styles (such as titlebar colors) /// /// The primary idea of all the behaviors we don't implement here are that subclasses may not /// want these behaviors. class BaseTerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel, ClipboardConfirmationViewDelegate, FullscreenDelegate { /// The app instance that this terminal view will represent. let ghostty: Ghostty.App /// The currently focused surface. var focusedSurface: Ghostty.SurfaceView? = nil { didSet { syncFocusToSurfaceTree() } } /// The tree of splits within this terminal window. @Published var surfaceTree: SplitTree = .init() { didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } /// This can be set to show/hide the command palette. @Published var commandPaletteIsShowing: Bool = false /// Whether the terminal surface should focus when the mouse is over it. var focusFollowsMouse: Bool { self.derivedConfig.focusFollowsMouse } /// Non-nil when an alert is active so we don't overlap multiple. private var alert: NSAlert? = nil /// The clipboard confirmation window, if shown. private var clipboardConfirmation: ClipboardConfirmationController? = nil /// Fullscreen state management. private(set) var fullscreenStyle: FullscreenStyle? /// Event monitor (see individual events for why) private var eventMonitor: Any? = nil /// The previous frame information from the window private var savedFrame: SavedFrame? = nil /// The configuration derived from the Ghostty config so we don't need to rely on references. private var derivedConfig: DerivedConfig /// The cancellables related to our focused surface. private var focusedSurfaceCancellables: Set = [] /// The time that undo/redo operations that contain running ptys are valid for. var undoExpiration: Duration { ghostty.config.undoTimeout } /// The undo manager for this controller is the undo manager of the window, /// which we set via the delegate method. override var undoManager: ExpiringUndoManager? { // This should be set via the delegate method windowWillReturnUndoManager if let result = window?.undoManager as? ExpiringUndoManager { return result } // If the window one isn't set, we fallback to our global one. if let appDelegate = NSApplication.shared.delegate as? AppDelegate { return appDelegate.undoManager } return nil } struct SavedFrame { let window: NSRect let screen: NSRect } required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } init(_ ghostty: Ghostty.App, baseConfig base: Ghostty.SurfaceConfiguration? = nil, surfaceTree tree: SplitTree? = nil ) { self.ghostty = ghostty self.derivedConfig = DerivedConfig(ghostty.config) super.init(window: nil) // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base)) // Setup our notifications for behaviors let center = NotificationCenter.default center.addObserver( self, selector: #selector(onConfirmClipboardRequest), name: Ghostty.Notification.confirmClipboard, object: nil) center.addObserver( self, selector: #selector(didChangeScreenParametersNotification), name: NSApplication.didChangeScreenParametersNotification, object: nil) center.addObserver( self, selector: #selector(ghosttyConfigDidChangeBase(_:)), name: .ghosttyConfigDidChange, object: nil) center.addObserver( self, selector: #selector(ghosttyCommandPaletteDidToggle(_:)), name: .ghosttyCommandPaletteDidToggle, object: nil) center.addObserver( self, selector: #selector(ghosttyMaximizeDidToggle(_:)), name: .ghosttyMaximizeDidToggle, object: nil) // Splits center.addObserver( self, selector: #selector(ghosttyDidCloseSurface(_:)), name: Ghostty.Notification.ghosttyCloseSurface, object: nil) center.addObserver( self, selector: #selector(ghosttyDidNewSplit(_:)), name: Ghostty.Notification.ghosttyNewSplit, object: nil) center.addObserver( self, selector: #selector(ghosttyDidEqualizeSplits(_:)), name: Ghostty.Notification.didEqualizeSplits, object: nil) center.addObserver( self, selector: #selector(ghosttyDidFocusSplit(_:)), name: Ghostty.Notification.ghosttyFocusSplit, object: nil) center.addObserver( self, selector: #selector(ghosttyDidToggleSplitZoom(_:)), name: Ghostty.Notification.didToggleSplitZoom, object: nil) center.addObserver( self, selector: #selector(ghosttyDidResizeSplit(_:)), name: Ghostty.Notification.didResizeSplit, object: nil) // Listen for local events that we need to know of outside of // single surface handlers. self.eventMonitor = NSEvent.addLocalMonitorForEvents( matching: [.flagsChanged] ) { [weak self] event in self?.localEventHandler(event) } } deinit { NotificationCenter.default.removeObserver(self) undoManager?.removeAllActions(withTarget: self) if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } } // MARK: Methods /// Create a new split. @discardableResult func newSplit( at oldView: Ghostty.SurfaceView, direction: SplitTree.NewDirection, baseConfig config: Ghostty.SurfaceConfiguration? = nil ) -> Ghostty.SurfaceView? { // We can only create new splits for surfaces in our tree. guard surfaceTree.root?.node(view: oldView) != nil else { return nil } // Create a new surface view guard let ghostty_app = ghostty.app else { return nil } let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config) // Do the split let newTree: SplitTree do { newTree = try surfaceTree.insert( view: newView, at: oldView, direction: direction) } catch { // If splitting fails for any reason (it should not), then we just log // and return. The new view we created will be deinitialized and its // no big deal. Ghostty.logger.warning("failed to insert split: \(error)") return nil } replaceSurfaceTree( newTree, moveFocusTo: newView, moveFocusFrom: oldView, undoAction: "New Split") return newView } /// Called when the surfaceTree variable changed. /// /// Subclasses should call super first. func surfaceTreeDidChange(from: SplitTree, to: SplitTree) { // If our surface tree becomes empty then we have no focused surface. if (to.isEmpty) { focusedSurface = nil } } /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about /// what surface is focused. This must be called whenever a surface OR window changes focus. func syncFocusToSurfaceTree() { for surfaceView in surfaceTree { // Our focus state requires that this window is key and our currently // focused surface is the surface in this view. let focused: Bool = (window?.isKeyWindow ?? false) && !commandPaletteIsShowing && focusedSurface != nil && surfaceView == focusedSurface! surfaceView.focusDidChange(focused) } } // Call this whenever the frame changes private func windowFrameDidChange() { // We need to update our saved frame information in case of monitor // changes (see didChangeScreenParameters notification). savedFrame = nil guard let window, let screen = window.screen else { return } savedFrame = .init(window: window.frame, screen: screen.visibleFrame) } func confirmClose( messageText: String, informativeText: String, completion: @escaping () -> Void ) { // If we already have an alert, we need to wait for that one. guard alert == nil else { return } // If there is no window to attach the modal then we assume success // since we'll never be able to show the modal. guard let window else { completion() return } // If we need confirmation by any, show one confirmation for all windows // in the tab group. let alert = NSAlert() alert.messageText = messageText alert.informativeText = informativeText alert.addButton(withTitle: "Close") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning alert.beginSheetModal(for: window) { response in self.alert = nil if response == .alertFirstButtonReturn { completion() } } // Store our alert so we only ever show one. self.alert = alert } /// Close a surface from a view. func closeSurface( _ view: Ghostty.SurfaceView, withConfirmation: Bool = true ) { guard let node = surfaceTree.root?.node(view: view) else { return } closeSurface(node, withConfirmation: withConfirmation) } /// Close a surface node (which may contain splits), requesting confirmation if necessary. /// /// This will also insert the proper undo stack information in. func closeSurface( _ node: SplitTree.Node, withConfirmation: Bool = true ) { // This node must be part of our tree guard surfaceTree.contains(node) else { return } // If the child process is not alive, then we exit immediately guard withConfirmation else { removeSurfaceNode(node) return } // Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog // due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that // confirmationDialog allows the user to Cmd-W close the alert, but when doing // so SwiftUI does not update any of the bindings to note that window is no longer // being shown, and provides no callback to detect this. confirmClose( messageText: "Close Terminal?", informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." ) { [weak self] in if let self { self.removeSurfaceNode(node) } } } // MARK: Split Tree Management /// Find the next surface to focus when a node is being closed. /// Goes to previous split unless we're the leftmost leaf, then goes to next. private func findNextFocusTargetAfterClosing(node: SplitTree.Node) -> Ghostty.SurfaceView? { guard let root = surfaceTree.root else { return nil } // If we're the leftmost, then we move to the next surface after closing. // Otherwise, we move to the previous. if root.leftmostLeaf() == node.leftmostLeaf() { return surfaceTree.focusTarget(for: .next, from: node) } else { return surfaceTree.focusTarget(for: .previous, from: node) } } /// Remove a node from the surface tree and move focus appropriately. /// /// This also updates the undo manager to support restoring this node. /// /// This does no confirmation and assumes confirmation is already done. private func removeSurfaceNode(_ node: SplitTree.Node) { // Move focus if the closed surface was focused and we have a next target let nextFocus: Ghostty.SurfaceView? = if node.contains( where: { $0 == focusedSurface } ) { findNextFocusTargetAfterClosing(node: node) } else { nil } replaceSurfaceTree( surfaceTree.remove(node), moveFocusTo: nextFocus, moveFocusFrom: focusedSurface, undoAction: "Close Terminal" ) } private func replaceSurfaceTree( _ newTree: SplitTree, moveFocusTo newView: Ghostty.SurfaceView? = nil, moveFocusFrom oldView: Ghostty.SurfaceView? = nil, undoAction: String? = nil ) { // Setup our new split tree let oldTree = surfaceTree surfaceTree = newTree if let newView { DispatchQueue.main.async { Ghostty.moveFocus(to: newView, from: oldView) } } // Setup our undo if let undoManager { if let undoAction { undoManager.setActionName(undoAction) } undoManager.registerUndo( withTarget: self, expiresAfter: undoExpiration ) { target in target.surfaceTree = oldTree if let oldView { DispatchQueue.main.async { Ghostty.moveFocus(to: oldView, from: target.focusedSurface) } } undoManager.registerUndo( withTarget: target, expiresAfter: target.undoExpiration ) { target in target.replaceSurfaceTree( newTree, moveFocusTo: newView, moveFocusFrom: target.focusedSurface, undoAction: undoAction) } } } } // MARK: Notifications @objc private func didChangeScreenParametersNotification(_ notification: Notification) { // If we have a window that is visible and it is outside the bounds of the // screen then we clamp it back to within the screen. guard let window else { return } guard window.isVisible else { return } // We ignore fullscreen windows because macOS automatically resizes // those back to the fullscreen bounds. guard !window.styleMask.contains(.fullScreen) else { return } guard let screen = window.screen else { return } let visibleFrame = screen.visibleFrame var newFrame = window.frame // Clamp width/height if newFrame.size.width > visibleFrame.size.width { newFrame.size.width = visibleFrame.size.width } if newFrame.size.height > visibleFrame.size.height { newFrame.size.height = visibleFrame.size.height } // Ensure the window is on-screen. We only do this if the previous frame // was also on screen. If a user explicitly wanted their window off screen // then we let it stay that way. x: if newFrame.origin.x < visibleFrame.origin.x { if let savedFrame, savedFrame.window.origin.x < savedFrame.screen.origin.x { break x; } newFrame.origin.x = visibleFrame.origin.x } y: if newFrame.origin.y < visibleFrame.origin.y { if let savedFrame, savedFrame.window.origin.y < savedFrame.screen.origin.y { break y; } newFrame.origin.y = visibleFrame.origin.y } // Apply the new window frame window.setFrame(newFrame, display: true) } @objc private func ghosttyConfigDidChangeBase(_ 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) } @objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) { guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(surfaceView) else { return } toggleCommandPalette(nil) } @objc private func ghosttyMaximizeDidToggle(_ notification: Notification) { guard let window else { return } guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(surfaceView) else { return } window.zoom(nil) } @objc private func ghosttyDidCloseSurface(_ notification: Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let node = surfaceTree.root?.node(view: target) else { return } closeSurface( node, withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false) } @objc private func ghosttyDidNewSplit(_ notification: Notification) { // The target must be within our tree guard let oldView = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.root?.node(view: oldView) != nil else { return } // Notification must contain our base config let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey] let config = configAny as? Ghostty.SurfaceConfiguration // Determine our desired direction guard let directionAny = notification.userInfo?["direction"] else { return } guard let direction = directionAny as? ghostty_action_split_direction_e else { return } let splitDirection: SplitTree.NewDirection switch (direction) { case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down case GHOSTTY_SPLIT_DIRECTION_UP: splitDirection = .up default: return } newSplit(at: oldView, direction: splitDirection, baseConfig: config) } @objc private func ghosttyDidEqualizeSplits(_ notification: Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } // Check if target surface is in current controller's tree guard surfaceTree.contains(target) else { return } // Equalize the splits surfaceTree = surfaceTree.equalize() } @objc private func ghosttyDidFocusSplit(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.root?.node(view: target) != nil else { return } // Get the direction from the notification guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return } guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return } // Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection let focusDirection: SplitTree.FocusDirection switch direction { case .previous: focusDirection = .previous case .next: focusDirection = .next case .up: focusDirection = .spatial(.up) case .down: focusDirection = .spatial(.down) case .left: focusDirection = .spatial(.left) case .right: focusDirection = .spatial(.right) } // Find the node for the target surface guard let targetNode = surfaceTree.root?.node(view: target) else { return } // Find the next surface to focus guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else { return } // Remove the zoomed state for this surface tree. if surfaceTree.zoomed != nil { surfaceTree = .init(root: surfaceTree.root, zoomed: nil) } // Move focus to the next surface DispatchQueue.main.async { Ghostty.moveFocus(to: nextSurface, from: target) } } @objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let targetNode = surfaceTree.root?.node(view: target) else { return } // Toggle the zoomed state if surfaceTree.zoomed == targetNode { // Already zoomed, unzoom it surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil) } else { // We require that the split tree have splits guard surfaceTree.isSplit else { return } // Not zoomed or different node zoomed, zoom this node surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode) } // Move focus to our window. Importantly this ensures that if we click the // reset zoom button in a tab bar of an unfocused tab that we become focused. window?.makeKeyAndOrderFront(nil) // Ensure focus stays on the target surface. We lose focus when we do // this so we need to grab it again. DispatchQueue.main.async { Ghostty.moveFocus(to: target) } } @objc private func ghosttyDidResizeSplit(_ notification: Notification) { // The target must be within our tree guard let target = notification.object as? Ghostty.SurfaceView else { return } guard let targetNode = surfaceTree.root?.node(view: target) else { return } // Extract direction and amount from notification guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return } guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return } guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return } guard let amount = amountAny as? UInt16 else { return } // Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction let spatialDirection: SplitTree.Spatial.Direction switch direction { case .up: spatialDirection = .up case .down: spatialDirection = .down case .left: spatialDirection = .left case .right: spatialDirection = .right } // Use viewBounds for the spatial calculation bounds let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds()) // Perform the resize using the new SplitTree resize method do { surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds) } catch { Ghostty.logger.warning("failed to resize split: \(error)") } } // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { return switch event.type { case .flagsChanged: localEventFlagsChanged(event) default: event } } private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? { var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0 } // If we're the main window receiving key input, then we want to avoid // calling this on our focused surface because that'll trigger a double // flagsChanged call. if NSApp.mainWindow == window { surfaces = surfaces.filter { $0 != focusedSurface } } for surface in surfaces { surface.flagsChanged(with: event) } return event } // MARK: TerminalViewDelegate func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { let lastFocusedSurface = focusedSurface focusedSurface = to // Important to cancel any prior subscriptions focusedSurfaceCancellables = [] // Setup our title listener. If we have a focused surface we always use that. // Otherwise, we try to use our last focused surface. In either case, we only // want to care if the surface is in the tree so we don't listen to titles of // closed surfaces. if let titleSurface = focusedSurface ?? lastFocusedSurface, surfaceTree.contains(titleSurface) { // If we have a surface, we want to listen for title changes. titleSurface.$title .sink { [weak self] in self?.titleDidChange(to: $0) } .store(in: &focusedSurfaceCancellables) } else { // There is no surface to listen to titles for. titleDidChange(to: "👻") } } func titleDidChange(to: String) { guard let window else { return } // Set the main window title window.title = to } func pwdDidChange(to: URL?) { guard let window else { return } if derivedConfig.macosTitlebarProxyIcon == .visible { // Use the 'to' URL directly window.representedURL = to } else { window.representedURL = nil } } func cellSizeDidChange(to: NSSize) { guard derivedConfig.windowStepResize else { return } self.window?.contentResizeIncrements = to } func splitDidResize(node: SplitTree.Node, to newRatio: Double) { let resizedNode = node.resize(to: newRatio) do { surfaceTree = try surfaceTree.replace(node: node, with: resizedNode) } catch { Ghostty.logger.warning("failed to replace node during split resize: \(error)") return } } func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) { guard let surface = surfaceView.surface else { return } let len = action.utf8CString.count if (len == 0) { return } _ = action.withCString { cString in ghostty_surface_binding_action(surface, cString, UInt(len - 1)) } } // MARK: Fullscreen /// Toggle fullscreen for the given mode. func toggleFullscreen(mode: FullscreenMode) { // We need a window to fullscreen guard let window = self.window else { return } // If we have a previous fullscreen style initialized, we want to check if // our mode changed. If it changed and we're in fullscreen, we exit so we can // toggle it next time. If it changed and we're not in fullscreen we can just // switch the handler. var newStyle = mode.style(for: window) newStyle?.delegate = self old: if let oldStyle = self.fullscreenStyle { // If we're not fullscreen, we can nil it out so we get the new style if !oldStyle.isFullscreen { self.fullscreenStyle = newStyle break old } assert(oldStyle.isFullscreen) // We consider our mode changed if the types change (obvious) but // also if its nil (not obvious) because nil means that the style has // likely changed but we don't support it. if newStyle == nil || type(of: newStyle!) != type(of: oldStyle) { // Our mode changed. Exit fullscreen (since we're toggling anyways) // and then set the new style for future use oldStyle.exit() self.fullscreenStyle = newStyle // We're done return } // Style is the same. } else { // We have no previous style self.fullscreenStyle = newStyle } guard let fullscreenStyle else { return } if fullscreenStyle.isFullscreen { fullscreenStyle.exit() } else { fullscreenStyle.enter() } } func fullscreenDidChange() {} // MARK: Clipboard Confirmation @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { guard let target = notification.object as? Ghostty.SurfaceView else { return } guard target == self.focusedSurface else { return } guard let surface = target.surface else { return } // We need a window guard let window = self.window else { return } // Check whether we use non-native fullscreen guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return } guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return } guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return } // If we already have a clipboard confirmation view up, we ignore this request. // This shouldn't be possible... guard self.clipboardConfirmation == nil else { Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true) return } // Show our paste confirmation self.clipboardConfirmation = ClipboardConfirmationController( surface: surface, contents: str, request: request, state: state, delegate: self ) window.beginSheet(self.clipboardConfirmation!.window!) } func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) { // End our clipboard confirmation no matter what guard let cc = self.clipboardConfirmation else { return } self.clipboardConfirmation = nil // Close the sheet if let ccWindow = cc.window { window?.endSheet(ccWindow) } switch (request) { case let .osc_52_write(pasteboard): guard case .confirm = action else { break } let pb = pasteboard ?? NSPasteboard.general pb.declareTypes([.string], owner: nil) pb.setString(cc.contents, forType: .string) case .osc_52_read, .paste: let str: String switch (action) { case .cancel: str = "" case .confirm: str = cc.contents } Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true) } } // MARK: NSWindowController override func windowDidLoad() { super.windowDidLoad() // Setup our undo manager. // Everything beyond here is setting up the window guard let window else { return } // If there is a hardcoded title in the configuration, we set that // immediately. Future `set_title` apprt actions will override this // if necessary but this ensures our window loads with the proper // title immediately rather than on another event loop tick (see #5934) if let title = derivedConfig.title { window.title = title } // We always initialize our fullscreen style to native if we can because // initialization sets up some state (i.e. observers). If its set already // somehow we don't do this. if fullscreenStyle == nil { fullscreenStyle = NativeFullscreen(window) fullscreenStyle?.delegate = self } } // MARK: NSWindowDelegate // This is called when performClose is called on a window (NOT when close() // is called directly). performClose is called primarily when UI elements such // as the "red X" are pressed. func windowShouldClose(_ sender: NSWindow) -> Bool { // We must have a window. Is it even possible not to? guard let window = self.window else { return true } // If we have no surfaces, close. if surfaceTree.isEmpty { return true } // If we already have an alert, continue with it guard alert == nil else { return false } // If our surfaces don't require confirmation, close. if !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true } // We require confirmation, so show an alert as long as we aren't already. confirmClose( messageText: "Close Terminal?", informativeText: "The terminal still has a running process. If you close the terminal the process will be killed." ) { window.close() } return false } func windowWillClose(_ notification: Notification) { guard let window else { return } // I don't know if this is required anymore. We previously had a ref cycle between // the view and the window so we had to nil this out to break it but I think this // may now be resolved. We should verify that no memory leaks and we can remove this. window.contentView = nil // Make sure we clean up all our undos window.undoManager?.removeAllActions(withTarget: self) } func windowDidBecomeKey(_ notification: Notification) { // Becoming/losing key means we have to notify our surface(s) that we have focus // so things like cursors blink, pty events are sent, etc. self.syncFocusToSurfaceTree() } func windowDidResignKey(_ notification: Notification) { // Becoming/losing key means we have to notify our surface(s) that we have focus // so things like cursors blink, pty events are sent, etc. self.syncFocusToSurfaceTree() } func windowDidChangeOcclusionState(_ notification: Notification) { let visible = self.window?.occlusionState.contains(.visible) ?? false for view in surfaceTree { if let surface = view.surface { ghostty_surface_set_occlusion(surface, visible) } } } func windowDidResize(_ notification: Notification) { windowFrameDidChange() } func windowDidMove(_ notification: Notification) { windowFrameDidChange() } func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } return appDelegate.undoManager } // MARK: First Responder @IBAction func close(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.requestClose(surface: surface) } @IBAction func closeWindow(_ sender: Any) { guard let window = window else { return } window.performClose(sender) } @IBAction func splitRight(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) } @IBAction func splitLeft(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_LEFT) } @IBAction func splitDown(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_DOWN) } @IBAction func splitUp(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_UP) } @IBAction func splitZoom(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.splitToggleZoom(surface: surface) } @IBAction func splitMoveFocusPrevious(_ sender: Any) { splitMoveFocus(direction: .previous) } @IBAction func splitMoveFocusNext(_ sender: Any) { splitMoveFocus(direction: .next) } @IBAction func splitMoveFocusAbove(_ sender: Any) { splitMoveFocus(direction: .up) } @IBAction func splitMoveFocusBelow(_ sender: Any) { splitMoveFocus(direction: .down) } @IBAction func splitMoveFocusLeft(_ sender: Any) { splitMoveFocus(direction: .left) } @IBAction func splitMoveFocusRight(_ sender: Any) { splitMoveFocus(direction: .right) } @IBAction func equalizeSplits(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.splitEqualize(surface: surface) } @IBAction func moveSplitDividerUp(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.splitResize(surface: surface, direction: .up, amount: 10) } @IBAction func moveSplitDividerDown(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.splitResize(surface: surface, direction: .down, amount: 10) } @IBAction func moveSplitDividerLeft(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.splitResize(surface: surface, direction: .left, amount: 10) } @IBAction func moveSplitDividerRight(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.splitResize(surface: surface, direction: .right, amount: 10) } private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { guard let surface = focusedSurface?.surface else { return } ghostty.splitMoveFocus(surface: surface, direction: direction) } @IBAction func increaseFontSize(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.changeFontSize(surface: surface, .increase(1)) } @IBAction func decreaseFontSize(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.changeFontSize(surface: surface, .decrease(1)) } @IBAction func resetFontSize(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.changeFontSize(surface: surface, .reset) } @IBAction func toggleCommandPalette(_ sender: Any?) { commandPaletteIsShowing.toggle() } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.resetTerminal(surface: surface) } private struct DerivedConfig { let title: String? let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon let windowStepResize: Bool let focusFollowsMouse: Bool init() { self.title = nil self.macosTitlebarProxyIcon = .visible self.windowStepResize = false self.focusFollowsMouse = false } init(_ config: Ghostty.Config) { self.title = config.title self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon self.windowStepResize = config.windowStepResize self.focusFollowsMouse = config.focusFollowsMouse } } }