#if canImport(AppKit) import AppKit import GhosttyTerminal import SwiftUI import TermindApplication /// Mounts one long-lived Ghostty view into whichever pane currently owns it. /// /// Moving a pane changes its position in `SplitNode`, which changes the /// SwiftUI parent chain. SwiftUI is allowed to dismantle and recreate an /// `NSViewRepresentable` across that move even when the pane keeps the same /// `.id`. A newly created `TerminalView` would make Ghostty replace the /// surface attached to the shared controller, losing the visible grid and /// scrollback. /// /// The owner below lives with its terminal session model, not with the /// transient representable. Reparenting therefore moves the same native view /// and surface into the new container instead of constructing either one. struct PersistentTerminalSurfaceView: NSViewRepresentable { let state: TerminalViewState let owner: PersistentTerminalViewOwner let isVisible: Bool let requestsFocus: Bool @AppStorage(SettingsKey.terminalCopyOnSelect) private var copyOnSelect = false @AppStorage(SettingsKey.terminalPasteOnRightClick) private var pasteOnRightClick = false final class Coordinator { let owner: PersistentTerminalViewOwner init(owner: PersistentTerminalViewOwner) { self.owner = owner } } func makeCoordinator() -> Coordinator { Coordinator(owner: owner) } func makeNSView(context _: Context) -> TerminalMountView { let container = TerminalMountView() owner.mount( in: container, state: state, isVisible: isVisible, requestsFocus: requestsFocus, copyOnSelect: copyOnSelect, pasteOnRightClick: pasteOnRightClick ) return container } func updateNSView(_ container: TerminalMountView, context _: Context) { owner.mount( in: container, state: state, isVisible: isVisible, requestsFocus: requestsFocus, copyOnSelect: copyOnSelect, pasteOnRightClick: pasteOnRightClick ) } static func dismantleNSView( _ container: TerminalMountView, coordinator: Coordinator ) { // The container may already have been reused for a different pane. // Unmount only the owner represented by the view being dismantled; // asking `container.owner` here could detach the newly mounted pane. coordinator.owner.unmount(from: container) } } /// A deliberately inert SwiftUI/AppKit boundary. Ghostty owns all drawing /// and input; this view supplies only stable geometry and a mount point. final class TerminalMountView: NSView { weak var owner: PersistentTerminalViewOwner? /// SwiftUI can reuse this NSView for another session while rebuilding a /// split tree. Keep a generation for every owner that has visited it so /// a late update from an older tree cannot steal it back. fileprivate var mountOrdinals: [ObjectIdentifier: UInt64] = [:] override var isOpaque: Bool { false } } /// Adds desktop mouse preferences and keeps application-owned shortcuts in /// AppKit's menu command chain. /// /// Ghostty recognizes several application commands while the terminal is /// first responder. Returning `true` prevents SwiftUI's command owner from /// seeing the event. Registered Termind shortcuts fall through; every other /// key still uses Ghostty's input path. final class TermindTerminalView: TerminalView { var inputCapture: TerminalInteractiveInputCapture? var copyOnSelect = false var pasteOnRightClick = false private var isSelectingWithMouse = false private var rightClickPastes: Bool? override func mouseDown(with event: NSEvent) { // Remote mouse reports must never copy an unrelated retained selection. isSelectingWithMouse = !isMouseCaptured super.mouseDown(with: event) } override func mouseUp(with event: NSEvent) { let shouldCopy = isSelectingWithMouse && copyOnSelect isSelectingWithMouse = false super.mouseUp(with: event) if shouldCopy { _ = copySelectedTextToPasteboard() } } override func rightMouseDown(with event: NSEvent) { let pastes = shouldPasteOnRightClick(event) rightClickPastes = pastes if pastes { acquireProgrammaticFocus() } else { super.rightMouseDown(with: event) } } override func rightMouseDragged(with event: NSEvent) { if rightClickPastes == false { super.rightMouseDragged(with: event) } } override func rightMouseUp(with event: NSEvent) { guard let pastes = rightClickPastes else { return } rightClickPastes = nil if pastes { // An empty paste must not arm a deferred broadcast ticket that // could consume the next unrelated mouse or protocol report. let pasteboard = NSPasteboard.general guard pasteboard.string(forType: .string)?.isEmpty == false || pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) else { return } captureInteraction { _ = performBindingAction("paste_from_clipboard") } } else { super.rightMouseUp(with: event) } } override func menu(for event: NSEvent) -> NSMenu? { if event.type == .rightMouseDown || event.type == .rightMouseUp, rightClickPastes ?? shouldPasteOnRightClick(event) { return nil } return super.menu(for: event) } private func shouldPasteOnRightClick(_ event: NSEvent) -> Bool { pasteOnRightClick && !isMouseCaptured && !event.modifierFlags.contains(.option) } override func keyDown(with event: NSEvent) { captureInteraction { super.keyDown(with: event) } } override func keyUp(with event: NSEvent) { captureInteraction(allowsDeferredWrite: false) { super.keyUp(with: event) } } override func flagsChanged(with event: NSEvent) { captureInteraction(allowsDeferredWrite: false) { super.flagsChanged(with: event) } } /// Menu-bar Paste reaches the first responder through `tryToPerform` /// rather than `keyDown`. Keep the capture alive while AppKit invokes /// Ghostty's paste action so bracketed-paste bytes are classified as /// interactive input too. override func tryToPerform(_ action: Selector, with object: Any?) -> Bool { guard action == NSSelectorFromString("paste:") else { return super.tryToPerform(action, with: object) } return captureInteraction { super.tryToPerform(action, with: object) } } override func performKeyEquivalent(with event: NSEvent) -> Bool { if AppShortcutRegistry.shared.routesToApplication(event) { return false } return captureInteraction { super.performKeyEquivalent(with: event) } } private func captureInteraction( allowsDeferredWrite: Bool = true, _ operation: () -> Result ) -> Result { guard let inputCapture else { return operation() } return inputCapture.withInteraction( allowsDeferredWrite: allowsDeferredWrite, operation ) } } /// Strong owner for one terminal backend, state and native view, so SplitNode /// mutations cannot change view identity or lose the screen buffer. @MainActor final class PersistentTerminalViewOwner { private let terminalView: TermindTerminalView let state: TerminalViewState nonisolated private let backend: InMemoryTerminalSession private weak var container: TerminalMountView? private var mountConstraints: [NSLayoutConstraint] = [] private var appliedVisibility: Bool? private var wantsFocus = false private var nextMountOrdinal: UInt64 = 0 private var ownerIdentifier: ObjectIdentifier { ObjectIdentifier(self) } init( write: @escaping @Sendable (Data, Bool) -> Void, resize: @escaping @Sendable (InMemoryTerminalViewport) -> Void ) { let inputCapture = TerminalInteractiveInputCapture() let backend = InMemoryTerminalSession( write: { data in write(data, inputCapture.classifyWrite(data)) }, resize: resize, suppressesPixelOnlyResizes: true ) let state = TerminalViewState(configSource: .none) state.configuration = TerminalSurfaceOptions( backend: .inMemory(backend), resizeThrottleMilliseconds: 35 ) self.backend = backend self.state = state let terminal = TermindTerminalView(frame: .zero) terminal.inputCapture = inputCapture terminal.translatesAutoresizingMaskIntoConstraints = false terminal.delegate = state terminal.configuration = state.configuration terminal.controller = state.controller terminalView = terminal } nonisolated func receive(_ data: Data) { backend.receive(data) } func appendNotice(_ message: String) { receive(Data("\r\n\u{001B}[2m[Termind] \(message)\u{001B}[0m\r\n".utf8)) } /// Idempotent: ordinary SwiftUI updates stop after the reference checks. /// Only a real pane move removes the terminal from one container and /// constrains it to another. func mount( in newContainer: TerminalMountView, state: TerminalViewState, isVisible: Bool, requestsFocus: Bool, copyOnSelect: Bool = false, pasteOnRightClick: Bool = false ) { var didReparent = false if container !== newContainer || terminalView.superview !== newContainer { assignOrdinalIfNeeded(to: newContainer) // A tree replacement can create the destination before // dismantling the source. SwiftUI may still send an update to // that older container afterwards; it must not steal the native // terminal back from the newer layout. if let currentOrdinal = container?.mountOrdinals[ownerIdentifier], let proposedOrdinal = newContainer.mountOrdinals[ownerIdentifier], proposedOrdinal < currentOrdinal { return } // A structural rewrite may hand this representable an AppKit // container that still holds another session. Detach that // session from this container before claiming it; its own // update will mount it into the corresponding destination. if let displacedOwner = newContainer.owner, displacedOwner !== self { displacedOwner.unmount(from: newContainer) } move(to: newContainer) didReparent = true } // The controller and options are established once by the session. // Do not stamp options during ordinary updates: the dependency's // equivalence helper is intentionally internal, and assigning a // value it considers different tears down the live surface. if terminalView.controller !== state.controller { terminalView.controller = state.controller } terminalView.copyOnSelect = copyOnSelect terminalView.pasteOnRightClick = pasteOnRightClick if appliedVisibility != isVisible { appliedVisibility = isVisible state.isSurfaceVisible = isVisible terminalView.setSurfaceVisible(isVisible) } synchronizeFocus( requested: requestsFocus && isVisible, force: false, reparented: didReparent ) } func unmount(from oldContainer: TerminalMountView) { // SwiftUI may build the destination before dismantling the source. // A late dismantle callback for the old container must not detach // the terminal that has already reached its new pane. guard container === oldContainer, terminalView.superview === oldContainer else { return } state.isSurfaceVisible = false appliedVisibility = false terminalView.setSurfaceVisible(false) NSLayoutConstraint.deactivate(mountConstraints) mountConstraints.removeAll(keepingCapacity: true) terminalView.removeFromSuperview() oldContainer.owner = nil container = nil } /// An explicit hand-back — Escape in the Find field, a pane becoming /// focused. This one is allowed to take the keyboard away from a text /// field, because taking it away is the whole point. func requestFocus() { synchronizeFocus(requested: true, force: true) } @discardableResult func performBindingAction(_ action: String) -> Bool { // Ghostty may make its surface first responder while dispatching a // search binding. Preserve an active AppKit field editor in the // same call stack so terminal search never produces a visible // search-field → terminal → search-field focus flash. let window = terminalView.window let editor = window?.firstResponder as? NSTextView let textField = editor?.isFieldEditor == true ? editor?.delegate as? NSTextField : nil let selection = editor?.selectedRange let handled = terminalView.performBindingAction(action) if let window, let textField, window.firstResponder !== textField, window.firstResponder !== textField.currentEditor() { window.makeFirstResponder(textField) if let selection { textField.currentEditor()?.selectedRange = selection } } return handled } #if DEBUG var hasKeyboardFocusForTesting: Bool { terminalView.window?.firstResponder === terminalView } func performKeyEquivalentForTesting(_ event: NSEvent) -> Bool { terminalView.performKeyEquivalent(with: event) } #endif private func move(to newContainer: TerminalMountView) { state.isSurfaceVisible = false appliedVisibility = false terminalView.setSurfaceVisible(false) NSLayoutConstraint.deactivate(mountConstraints) mountConstraints.removeAll(keepingCapacity: true) terminalView.removeFromSuperview() container?.owner = nil container = newContainer newContainer.owner = self newContainer.addSubview(terminalView) mountConstraints = [ terminalView.leadingAnchor.constraint(equalTo: newContainer.leadingAnchor), terminalView.trailingAnchor.constraint(equalTo: newContainer.trailingAnchor), terminalView.topAnchor.constraint(equalTo: newContainer.topAnchor), terminalView.bottomAnchor.constraint(equalTo: newContainer.bottomAnchor), ] NSLayoutConstraint.activate(mountConstraints) } private func assignOrdinalIfNeeded(to container: TerminalMountView) { guard container.mountOrdinals[ownerIdentifier] == nil else { return } nextMountOrdinal &+= 1 container.mountOrdinals[ownerIdentifier] = nextMountOrdinal } /// Moves the keyboard to or from the terminal — but only when something /// actually asked for it to move. /// /// `mount` re-runs for *any* observed change on the session, which /// includes each letter typed into the window's Find field, and each of /// those used to re-assert first responder here. Answering an unchanged /// request with a first-responder call is not idempotent: it takes the /// caret out of whatever holds it. So an ordinary update now stops at /// the first guard and never touches the responder chain at all. /// /// `force` is the explicit hand-back — Escape in the Find field, or a /// pane being focused — and always acts. `reparented` covers the pane /// move, where the request has not changed but the view has. /// /// `isEditingText` stays as a second line of defence for the remaining /// unforced case, where the request really did change while somebody was /// typing somewhere else. private func synchronizeFocus( requested: Bool, force: Bool, reparented: Bool = false ) { let changed = requested != wantsFocus wantsFocus = requested guard force || changed || reparented else { return } guard let window = terminalView.window else { if requested { scheduleFocusAfterMount(force: force) } return } if requested { guard window.firstResponder !== terminalView else { return } guard force || !isEditingText(in: window) else { return } window.makeFirstResponder(terminalView) } else if window.firstResponder === terminalView { window.makeFirstResponder(nil) } } /// Whether the window's keyboard focus is in a text field. A focused /// `NSTextField` or `NSSearchField` delegates to a shared field editor, /// so the first responder is usually that `NSTextView` rather than the /// control the user sees. private func isEditingText(in window: NSWindow) -> Bool { switch window.firstResponder { case let textView as NSTextView: textView.isFieldEditor case is NSTextField: true default: false } } private func scheduleFocusAfterMount(force: Bool) { // Mounting commonly happens inside a SwiftUI update, before the new // container has a window. One run-loop hop lets AppKit finish the // reparent without retaining a task or polling layout state. DispatchQueue.main.async { [weak self] in guard let self, wantsFocus, appliedVisibility == true, let window = terminalView.window, container != nil else { return } // Same rule as the synchronous path, and it carries `force` for // the same reason. A hop later is exactly when the Find field // has just taken the caret, and this used to be the one route to // first responder with no guard on it at all. guard force || !isEditingText(in: window) else { return } if window.firstResponder !== terminalView { window.makeFirstResponder(terminalView) } } } } #endif