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. /// /// 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 surface tree for this window. @Published var surfaceTree: Ghostty.SplitNode? = nil { didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } } /// 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 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: Ghostty.SplitNode? = 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 ?? .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) center.addObserver( self, selector: #selector(didChangeScreenParametersNotification), name: NSApplication.didChangeScreenParametersNotification, object: nil) center.addObserver( self, selector: #selector(ghosttyConfigDidChangeBase(_:)), name: .ghosttyConfigDidChange, 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) if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } } /// 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) } } // 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) } // 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) } // 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? { // Go through all our surfaces and notify it that the flags changed. if let surfaceTree { var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0.surface } // 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 // 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 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 zoomStateDidChange(to: Bool) {} // 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() { // For some reason focus can get lost when we change fullscreen. Regardless of // mode above we just move it back. if let focusedSurface { Ghostty.moveFocus(to: focusedSurface) } } // 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() { 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. 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: alert.window.orderOut(nil) 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) } } } func windowDidResize(_ notification: Notification) { windowFrameDidChange() } func windowDidMove(_ notification: Notification) { windowFrameDidChange() } // 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) } @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 } } }