import Cocoa import SwiftUI 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. /// /// 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. /// - Fullscreen /// - 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 { /// 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 surface tree for this window. @Published var surfaceTree: Ghostty.SplitNode? = nil { didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } /// 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 required init?(coder: NSCoder) { fatalError("init(coder:) is not supported for this view") } init(_ ghostty: Ghostty.App, baseConfig base: Ghostty.SurfaceConfiguration? = nil, surfaceTree tree: Ghostty.SplitNode? = nil ) { self.ghostty = ghostty super.init(window: nil) // Initialize our initial surface. guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } self.surfaceTree = tree ?? .leaf(.init(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) } /// Called when the surfaceTree variable changed. /// /// Subclasses should call super first. func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { // If our surface tree becomes nil then ensure all surfaces // in the old tree have closed. if (to == nil) { from?.close() 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() { guard let tree = self.surfaceTree else { return } for leaf in tree { // Our focus state requires that this window is key and our currently // focused surface is the surface in this leaf. let focused: Bool = (window?.isKeyWindow ?? false) && focusedSurface != nil && leaf.surface == focusedSurface! leaf.surface.focusDidChange(focused) } } // MARK: TerminalViewDelegate // Note: this is different from surfaceDidTreeChange(from:,to:) because this is called // when the currently set value changed in place and the from:to: variant is called // when the variable was set. func surfaceTreeDidChange() {} func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { focusedSurface = to } func titleDidChange(to: String) { guard let window else { return } // Set the main window title window.title = to } func cellSizeDidChange(to: NSSize) { guard ghostty.config.windowStepResize else { return } self.window?.contentResizeIncrements = to } func zoomStateDidChange(to: Bool) {} // 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 .osc_52_write: guard case .confirm = action else { break } let pb = 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: - 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. guard let node = self.surfaceTree else { 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 (!node.needsConfirmQuit()) { return true } // We require confirmation, so show an alert as long as we aren't already. let alert = NSAlert() alert.messageText = "Close Terminal?" alert.informativeText = "The terminal still has a running process. If you close the " + "terminal the process will be killed." alert.addButton(withTitle: "Close the Terminal") alert.addButton(withTitle: "Cancel") alert.alertStyle = .warning alert.beginSheetModal(for: window, completionHandler: { response in self.alert = nil switch (response) { case .alertFirstButtonReturn: window.close() default: break } }) self.alert = alert 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 } 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) { guard let surfaceTree = self.surfaceTree else { return } let visible = self.window?.occlusionState.contains(.visible) ?? false for leaf in surfaceTree { if let surface = leaf.surface.surface { ghostty_surface_set_occlusion(surface, visible) } } } // 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 splitDown(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_DOWN) } @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: .top) } @IBAction func splitMoveFocusBelow(_ sender: Any) { splitMoveFocus(direction: .bottom) } @IBAction func splitMoveFocusLeft(_ sender: Any) { splitMoveFocus(direction: .left) } @IBAction func splitMoveFocusRight(_ sender: Any) { splitMoveFocus(direction: .right) } @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) } @objc func resetTerminal(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.resetTerminal(surface: surface) } }