import Foundation import SwiftUI import AppKit import Metal import QuartzCore import Combine import Darwin import Sentry import Bonsplit import IOSurface #if os(macOS) func cmuxShouldUseTransparentBackgroundWindow() -> Bool { let defaults = UserDefaults.standard let sidebarBlendMode = defaults.string(forKey: "sidebarBlendMode") ?? "withinWindow" let bgGlassEnabled = defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false return sidebarBlendMode == "behindWindow" && bgGlassEnabled && !WindowGlassEffect.isAvailable } func cmuxShouldUseClearWindowBackground(for opacity: Double) -> Bool { cmuxShouldUseTransparentBackgroundWindow() || opacity < 0.999 } private func cmuxTransparentWindowBaseColor() -> NSColor { // A tiny non-zero alpha matches Ghostty's window compositing behavior on macOS and // avoids visual artifacts that can happen with a fully clear window background. NSColor.white.withAlphaComponent(0.001) } #endif #if DEBUG private func cmuxChildExitProbePath() -> String? { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1", let path = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"], !path.isEmpty else { return nil } return path } private func cmuxLoadChildExitProbe(at path: String) -> [String: String] { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return [:] } return object } private func cmuxWriteChildExitProbe(_ updates: [String: String], increments: [String: Int] = [:]) { guard let path = cmuxChildExitProbePath() else { return } var payload = cmuxLoadChildExitProbe(at: path) for (key, by) in increments { let current = Int(payload[key] ?? "") ?? 0 payload[key] = String(current + by) } for (key, value) in updates { payload[key] = value } guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return } try? out.write(to: URL(fileURLWithPath: path), options: .atomic) } private func cmuxScalarHex(_ value: String?) -> String { guard let value else { return "" } return value.unicodeScalars .map { String(format: "%04X", $0.value) } .joined(separator: ",") } #endif private enum GhosttyPasteboardHelper { private static let selectionPasteboard = NSPasteboard( name: NSPasteboard.Name("com.mitchellh.ghostty.selection") ) private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text") private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? { switch location { case GHOSTTY_CLIPBOARD_STANDARD: return .general case GHOSTTY_CLIPBOARD_SELECTION: return selectionPasteboard default: return nil } } static func stringContents(from pasteboard: NSPasteboard) -> String? { if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty { return urls .map { $0.isFileURL ? escapeForShell($0.path) : $0.absoluteString } .joined(separator: " ") } if let value = pasteboard.string(forType: .string) { return value } return pasteboard.string(forType: utf8PlainTextType) } static func hasString(for location: ghostty_clipboard_e) -> Bool { guard let pasteboard = pasteboard(for: location) else { return false } return (stringContents(from: pasteboard) ?? "").isEmpty == false } static func writeString(_ string: String, to location: ghostty_clipboard_e) { guard let pasteboard = pasteboard(for: location) else { return } pasteboard.clearContents() pasteboard.setString(string, forType: .string) } private static func escapeForShell(_ value: String) -> String { var result = value for char in shellEscapeCharacters { result = result.replacingOccurrences(of: String(char), with: "\\\(char)") } return result } } enum TerminalOpenURLTarget: Equatable { case embeddedBrowser(URL) case external(URL) var url: URL { switch self { case let .embeddedBrowser(url), let .external(url): return url } } } enum GhosttyDefaultBackgroundUpdateScope: Int { case unscoped = 0 case app = 1 case surface = 2 var logLabel: String { switch self { case .unscoped: return "unscoped" case .app: return "app" case .surface: return "surface" } } } /// Coalesces Ghostty background notifications so consumers only observe /// the latest runtime background for a burst of updates. final class GhosttyDefaultBackgroundNotificationDispatcher { private let coalescer: NotificationBurstCoalescer private let postNotification: ([AnyHashable: Any]) -> Void private var pendingUserInfo: [AnyHashable: Any]? private var pendingEventId: UInt64 = 0 private var pendingSource: String = "unspecified" private let logEvent: ((String) -> Void)? init( delay: TimeInterval = 1.0 / 30.0, logEvent: ((String) -> Void)? = nil, postNotification: @escaping ([AnyHashable: Any]) -> Void = { userInfo in NotificationCenter.default.post( name: .ghosttyDefaultBackgroundDidChange, object: nil, userInfo: userInfo ) } ) { coalescer = NotificationBurstCoalescer(delay: delay) self.logEvent = logEvent self.postNotification = postNotification } func signal(backgroundColor: NSColor, opacity: Double, eventId: UInt64, source: String) { let signalOnMain = { [self] in pendingEventId = eventId pendingSource = source pendingUserInfo = [ GhosttyNotificationKey.backgroundColor: backgroundColor, GhosttyNotificationKey.backgroundOpacity: opacity, GhosttyNotificationKey.backgroundEventId: NSNumber(value: eventId), GhosttyNotificationKey.backgroundSource: source ] logEvent?( "bg notify queued id=\(eventId) source=\(source) color=\(backgroundColor.hexString()) opacity=\(String(format: "%.3f", opacity))" ) coalescer.signal { [self] in guard let userInfo = pendingUserInfo else { return } let eventId = pendingEventId let source = pendingSource pendingUserInfo = nil logEvent?("bg notify flushed id=\(eventId) source=\(source)") logEvent?("bg notify posting id=\(eventId) source=\(source)") postNotification(userInfo) logEvent?("bg notify posted id=\(eventId) source=\(source)") } } if Thread.isMainThread { signalOnMain() } else { DispatchQueue.main.async(execute: signalOnMain) } } } func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) #if DEBUG dlog("link.resolve input=\(trimmed)") #endif guard !trimmed.isEmpty else { #if DEBUG dlog("link.resolve result=nil (empty)") #endif return nil } if NSString(string: trimmed).isAbsolutePath { #if DEBUG dlog("link.resolve result=external(absolutePath) url=\(trimmed)") #endif return .external(URL(fileURLWithPath: trimmed)) } if let parsed = URL(string: trimmed), let scheme = parsed.scheme?.lowercased() { if scheme == "http" || scheme == "https" { guard BrowserInsecureHTTPSettings.normalizeHost(parsed.host ?? "") != nil else { #if DEBUG dlog("link.resolve result=external(invalidHost) url=\(parsed)") #endif return .external(parsed) } #if DEBUG dlog("link.resolve result=embeddedBrowser url=\(parsed)") #endif return .embeddedBrowser(parsed) } #if DEBUG dlog("link.resolve result=external(scheme=\(scheme)) url=\(parsed)") #endif return .external(parsed) } if let webURL = resolveBrowserNavigableURL(trimmed) { guard BrowserInsecureHTTPSettings.normalizeHost(webURL.host ?? "") != nil else { #if DEBUG dlog("link.resolve result=external(bareHost-invalidHost) url=\(webURL)") #endif return .external(webURL) } #if DEBUG dlog("link.resolve result=embeddedBrowser(bareHost) url=\(webURL)") #endif return .embeddedBrowser(webURL) } guard let fallback = URL(string: trimmed) else { #if DEBUG dlog("link.resolve result=nil (unparseable)") #endif return nil } #if DEBUG dlog("link.resolve result=external(fallback) url=\(fallback)") #endif return .external(fallback) } enum TerminalKeyboardCopyModeSelectionMove: String, Equatable { case left case right case up case down case pageUp = "page_up" case pageDown = "page_down" case home case end case beginningOfLine = "beginning_of_line" case endOfLine = "end_of_line" } enum TerminalKeyboardCopyModeAction: Equatable { case exit case startSelection case clearSelection case copyAndExit case copyLineAndExit case scrollLines(Int) case scrollPage(Int) case scrollToTop case scrollToBottom case jumpToPrompt(Int) case startSearch case searchNext case searchPrevious case adjustSelection(TerminalKeyboardCopyModeSelectionMove) } struct TerminalKeyboardCopyModeInputState: Equatable { var countPrefix: Int? var pendingYankLine = false mutating func reset() { countPrefix = nil pendingYankLine = false } } enum TerminalKeyboardCopyModeResolution: Equatable { case perform(TerminalKeyboardCopyModeAction, count: Int) case consume } private let terminalKeyboardCopyModeMaxCount = 9_999 private func terminalKeyboardCopyModeClampCount(_ value: Int) -> Int { min(max(value, 1), terminalKeyboardCopyModeMaxCount) } func terminalKeyboardCopyModeInitialViewportRow( rows: Int, imePointY: Double, imeCellHeight: Double, topPadding: Double = 0 ) -> Int { let clampedRows = max(rows, 1) guard imeCellHeight > 0 else { return clampedRows - 1 } // `ghostty_surface_ime_point` returns a top-origin Y coordinate at the // cursor baseline plus one cell-height. Convert that to a zero-based row. let estimatedRow = Int(floor(((imePointY - topPadding) / imeCellHeight) - 1)) return max(0, min(clampedRows - 1, estimatedRow)) } private func terminalKeyboardCopyModeNormalizedModifiers( _ modifierFlags: NSEvent.ModifierFlags ) -> NSEvent.ModifierFlags { modifierFlags .intersection(.deviceIndependentFlagsMask) .subtracting([.numericPad, .function, .capsLock]) } private func terminalKeyboardCopyModeChars( _ charactersIgnoringModifiers: String? ) -> String { guard let scalar = charactersIgnoringModifiers?.unicodeScalars.first else { return "" } return String(scalar).lowercased() } func terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: NSEvent.ModifierFlags) -> Bool { let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) return normalized.contains(.command) } func terminalKeyboardCopyModeAction( keyCode: UInt16, charactersIgnoringModifiers: String?, modifierFlags: NSEvent.ModifierFlags, hasSelection: Bool ) -> TerminalKeyboardCopyModeAction? { let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers) if keyCode == 53 { // Escape return .exit } switch keyCode { case 126: // Up return hasSelection ? .adjustSelection(.up) : .scrollLines(-1) case 125: // Down return hasSelection ? .adjustSelection(.down) : .scrollLines(1) case 123: // Left return hasSelection ? .adjustSelection(.left) : nil case 124: // Right return hasSelection ? .adjustSelection(.right) : nil case 116: // Page Up return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1) case 121: // Page Down return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1) case 115: // Home return hasSelection ? .adjustSelection(.home) : .scrollToTop case 119: // End return hasSelection ? .adjustSelection(.end) : .scrollToBottom default: break } if normalized == [.control] { if chars == "u" || chars == "\u{15}" { return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1) } if chars == "d" || chars == "\u{04}" { return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1) } if chars == "b" || chars == "\u{02}" { return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1) } if chars == "f" || chars == "\u{06}" { return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1) } if chars == "y" || chars == "\u{19}" { return hasSelection ? .adjustSelection(.up) : .scrollLines(-1) } if chars == "e" || chars == "\u{05}" { return hasSelection ? .adjustSelection(.down) : .scrollLines(1) } return nil } guard normalized.isEmpty || normalized == [.shift] else { return nil } switch chars { case "q": return .exit case "v": return hasSelection ? .clearSelection : .startSelection case "y": if normalized == [.shift], !hasSelection { return .copyLineAndExit } return hasSelection ? .copyAndExit : nil case "j": return hasSelection ? .adjustSelection(.down) : .scrollLines(1) case "k": return hasSelection ? .adjustSelection(.up) : .scrollLines(-1) case "h": return hasSelection ? .adjustSelection(.left) : nil case "l": return hasSelection ? .adjustSelection(.right) : nil case "g": if normalized == [.shift] { return hasSelection ? .adjustSelection(.end) : .scrollToBottom } return hasSelection ? .adjustSelection(.home) : .scrollToTop case "0", "^": return hasSelection ? .adjustSelection(.beginningOfLine) : nil case "$", "4": guard chars == "$" || normalized == [.shift] else { return nil } return hasSelection ? .adjustSelection(.endOfLine) : nil case "{", "[": guard chars == "{" || normalized == [.shift] else { return nil } return .jumpToPrompt(-1) case "}", "]": guard chars == "}" || normalized == [.shift] else { return nil } return .jumpToPrompt(1) case "/": return .startSearch case "n": return normalized == [.shift] ? .searchPrevious : .searchNext default: return nil } } func terminalKeyboardCopyModeResolve( keyCode: UInt16, charactersIgnoringModifiers: String?, modifierFlags: NSEvent.ModifierFlags, hasSelection: Bool, state: inout TerminalKeyboardCopyModeInputState ) -> TerminalKeyboardCopyModeResolution { let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags) let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers) if keyCode == 53 { // Escape state.reset() return .perform(.exit, count: 1) } if state.pendingYankLine { if chars == "y", normalized.isEmpty || normalized == [.shift] { let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1) state.reset() return .perform(.copyLineAndExit, count: count) } // Only `yy`/`Y` are supported as line-yank operators, so cancel the // pending yank and treat this key as a fresh command. state.pendingYankLine = false } if normalized.isEmpty, let scalar = chars.unicodeScalars.first, scalar.isASCII, scalar.value >= 48, scalar.value <= 57 { let digit = Int(scalar.value - 48) if digit == 0 { if let currentCount = state.countPrefix { state.countPrefix = terminalKeyboardCopyModeClampCount(currentCount * 10) return .consume } } else { let currentCount = state.countPrefix ?? 0 state.countPrefix = terminalKeyboardCopyModeClampCount((currentCount * 10) + digit) return .consume } } if !hasSelection, chars == "y", normalized.isEmpty { state.pendingYankLine = true return .consume } guard let action = terminalKeyboardCopyModeAction( keyCode: keyCode, charactersIgnoringModifiers: charactersIgnoringModifiers, modifierFlags: modifierFlags, hasSelection: hasSelection ) else { state.reset() return .consume } let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1) state.reset() return .perform(action, count: count) } private final class GhosttySurfaceCallbackContext { weak var surfaceView: GhosttyNSView? weak var terminalSurface: TerminalSurface? let surfaceId: UUID init(surfaceView: GhosttyNSView, terminalSurface: TerminalSurface) { self.surfaceView = surfaceView self.terminalSurface = terminalSurface self.surfaceId = terminalSurface.id } var tabId: UUID? { terminalSurface?.tabId ?? surfaceView?.tabId } var runtimeSurface: ghostty_surface_t? { terminalSurface?.surface ?? surfaceView?.terminalSurface?.surface } } // Minimal Ghostty wrapper for terminal rendering // This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation // MARK: - Ghostty App Singleton class GhosttyApp { static let shared = GhosttyApp() private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter }() private(set) var app: ghostty_app_t? private(set) var config: ghostty_config_t? private(set) var defaultBackgroundColor: NSColor = .windowBackgroundColor private(set) var defaultBackgroundOpacity: Double = 1.0 private static func resolveBackgroundLogURL( environment: [String: String] = ProcessInfo.processInfo.environment ) -> URL { if let explicitPath = environment["CMUX_DEBUG_BG_LOG"], !explicitPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return URL(fileURLWithPath: explicitPath) } if let debugLogPath = environment["CMUX_DEBUG_LOG"], !debugLogPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let baseURL = URL(fileURLWithPath: debugLogPath) let extensionSeparatorIndex = baseURL.lastPathComponent.lastIndex(of: ".") let stem = extensionSeparatorIndex.map { String(baseURL.lastPathComponent[..<$0]) } ?? baseURL.lastPathComponent let bgName = "\(stem)-bg.log" return baseURL.deletingLastPathComponent().appendingPathComponent(bgName) } return URL(fileURLWithPath: "/tmp/cmux-bg.log") } let backgroundLogEnabled = { if ProcessInfo.processInfo.environment["CMUX_DEBUG_BG"] == "1" { return true } if ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"] != nil { return true } if ProcessInfo.processInfo.environment["GHOSTTYTABS_DEBUG_BG"] == "1" { return true } if UserDefaults.standard.bool(forKey: "cmuxDebugBG") { return true } return UserDefaults.standard.bool(forKey: "GhosttyTabsDebugBG") }() private let backgroundLogURL = GhosttyApp.resolveBackgroundLogURL() private let backgroundLogStartUptime = ProcessInfo.processInfo.systemUptime private let backgroundLogLock = NSLock() private var backgroundLogSequence: UInt64 = 0 private var appObservers: [NSObjectProtocol] = [] private var backgroundEventCounter: UInt64 = 0 private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped private var defaultBackgroundScopeSource: String = "initialize" private var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference? private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher = // Theme chrome should track terminal theme changes in the same frame. // Keep coalescing semantics, but flush in the next main turn instead of waiting ~1 frame. GhosttyDefaultBackgroundNotificationDispatcher(delay: 0, logEvent: { [weak self] message in guard let self, self.backgroundLogEnabled else { return } self.logBackground(message) }) // Scroll lag tracking private(set) var isScrolling = false private var scrollLagSampleCount = 0 private var scrollLagTotalMs: Double = 0 private var scrollLagMaxMs: Double = 0 private let scrollLagThresholdMs: Double = 40 private let scrollLagMinimumSamples = 8 private let scrollLagMinimumAverageMs: Double = 12 private let scrollLagReportCooldownSeconds: TimeInterval = 300 private var lastScrollLagReportUptime: TimeInterval? private var scrollEndTimer: DispatchWorkItem? func markScrollActivity(hasMomentum: Bool, momentumEnded: Bool) { // Cancel any pending scroll-end timer scrollEndTimer?.cancel() scrollEndTimer = nil if momentumEnded { // Trackpad momentum ended - scrolling is done endScrollSession() } else if hasMomentum { // Trackpad scrolling with momentum - wait for momentum to end isScrolling = true } else { // Mouse wheel or non-momentum scroll - use timeout isScrolling = true let timer = DispatchWorkItem { [weak self] in self?.endScrollSession() } scrollEndTimer = timer DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: timer) } } private func endScrollSession() { guard isScrolling else { return } isScrolling = false // Report accumulated lag stats if any exceeded threshold if scrollLagSampleCount > 0 { let avgLag = scrollLagTotalMs / Double(scrollLagSampleCount) let maxLag = scrollLagMaxMs let samples = scrollLagSampleCount let threshold = scrollLagThresholdMs let nowUptime = ProcessInfo.processInfo.systemUptime if Self.shouldCaptureScrollLagEvent( samples: samples, averageMs: avgLag, maxMs: maxLag, thresholdMs: threshold, minimumSamples: scrollLagMinimumSamples, minimumAverageMs: scrollLagMinimumAverageMs, nowUptime: nowUptime, lastReportedUptime: lastScrollLagReportUptime, cooldown: scrollLagReportCooldownSeconds ) { if TelemetrySettings.enabledForCurrentLaunch { SentrySDK.capture(message: "Scroll lag detected") { scope in scope.setLevel(.warning) scope.setContext(value: [ "samples": samples, "avg_ms": String(format: "%.2f", avgLag), "max_ms": String(format: "%.2f", maxLag), "threshold_ms": threshold ], key: "scroll_lag") } } lastScrollLagReportUptime = nowUptime } // Reset stats scrollLagSampleCount = 0 scrollLagTotalMs = 0 scrollLagMaxMs = 0 } } private init() { initializeGhostty() } #if DEBUG private static let initLogPath = "/tmp/cmux-ghostty-init.log" private static func initLog(_ message: String) { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: initLogPath) { handle.seekToEndOfFile() handle.write(line.data(using: .utf8)!) handle.closeFile() } else { FileManager.default.createFile(atPath: initLogPath, contents: line.data(using: .utf8)) } } private static func dumpConfigDiagnostics(_ config: ghostty_config_t, label: String) { let count = Int(ghostty_config_diagnostics_count(config)) guard count > 0 else { initLog("ghostty diagnostics (\(label)): none") return } initLog("ghostty diagnostics (\(label)): count=\(count)") for i in 0.. 0 else { return } let buffer = UnsafeBufferPointer(start: content, count: Int(len)) var fallback: String? for item in buffer { guard let dataPtr = item.data else { continue } let value = String(cString: dataPtr) if let mimePtr = item.mime { let mime = String(cString: mimePtr) if mime.hasPrefix("text/plain") { GhosttyPasteboardHelper.writeString(value, to: location) return } } if fallback == nil { fallback = value } } if let fallback { GhosttyPasteboardHelper.writeString(fallback, to: location) } } runtimeConfig.close_surface_cb = { userdata, needsConfirmClose in guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return } let callbackSurfaceId = callbackContext.surfaceId let callbackTabId = callbackContext.tabId #if DEBUG cmuxWriteChildExitProbe( [ "probeCloseSurfaceNeedsConfirm": needsConfirmClose ? "1" : "0", "probeCloseSurfaceTabId": callbackTabId?.uuidString ?? "", "probeCloseSurfaceSurfaceId": callbackSurfaceId.uuidString, ], increments: ["probeCloseSurfaceCbCount": 1] ) #endif DispatchQueue.main.async { guard let app = AppDelegate.shared else { return } // Close requests must be resolved by the callback's workspace/surface IDs only. // If the mapping is already gone (duplicate/stale callback), ignore it. if let callbackTabId, let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), workspace.panels[callbackSurfaceId] != nil { if needsConfirmClose { manager.closeRuntimeSurfaceWithConfirmation( tabId: callbackTabId, surfaceId: callbackSurfaceId ) } else { manager.closeRuntimeSurface( tabId: callbackTabId, surfaceId: callbackSurfaceId ) } } } } // Create app if let created = ghostty_app_new(&runtimeConfig, primaryConfig) { self.app = created self.config = primaryConfig } else { #if DEBUG Self.initLog("ghostty_app_new(primary) failed; attempting fallback config") Self.dumpConfigDiagnostics(primaryConfig, label: "primary") #endif // If the user config is invalid, prefer a minimal fallback configuration so // cmux still launches with working terminals. ghostty_config_free(primaryConfig) guard let fallbackConfig = ghostty_config_new() else { print("Failed to create ghostty fallback config") return } ghostty_config_finalize(fallbackConfig) updateDefaultBackground(from: fallbackConfig, source: "initialize.fallbackConfig") guard let created = ghostty_app_new(&runtimeConfig, fallbackConfig) else { #if DEBUG Self.initLog("ghostty_app_new(fallback) failed") Self.dumpConfigDiagnostics(fallbackConfig, label: "fallback") #endif print("Failed to create ghostty app") ghostty_config_free(fallbackConfig) return } self.app = created self.config = fallbackConfig } // Notify observers that a usable config is available (initial load). lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) #if os(macOS) if let app { ghostty_app_set_focus(app, NSApp.isActive) } appObservers.append(NotificationCenter.default.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in guard let app = self?.app else { return } ghostty_app_set_focus(app, true) }) appObservers.append(NotificationCenter.default.addObserver( forName: NSApplication.didResignActiveNotification, object: nil, queue: .main ) { [weak self] _ in guard let app = self?.app else { return } ghostty_app_set_focus(app, false) }) #endif } private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) { ghostty_config_load_default_files(config) loadReleaseAppSupportGhosttyConfigIfNeeded(config) loadLegacyGhosttyConfigIfNeeded(config) ghostty_config_load_recursive_files(config) ghostty_config_finalize(config) } static func shouldLoadLegacyGhosttyConfig( newConfigFileSize: Int?, legacyConfigFileSize: Int? ) -> Bool { guard let newConfigFileSize, newConfigFileSize == 0 else { return false } guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false } return true } static func shouldLoadReleaseAppSupportGhosttyConfig( currentBundleIdentifier: String?, currentConfigFileSize: Int?, currentLegacyConfigFileSize: Int?, releaseConfigFileSize: Int?, releaseLegacyConfigFileSize: Int? ) -> Bool { guard SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) else { return false } let hasCurrentAppSupportConfig = (currentConfigFileSize ?? 0) > 0 || (currentLegacyConfigFileSize ?? 0) > 0 guard !hasCurrentAppSupportConfig else { return false } let hasReleaseAppSupportConfig = (releaseConfigFileSize ?? 0) > 0 || (releaseLegacyConfigFileSize ?? 0) > 0 return hasReleaseAppSupportConfig } static func shouldApplyDefaultBackgroundUpdate( currentScope: GhosttyDefaultBackgroundUpdateScope, incomingScope: GhosttyDefaultBackgroundUpdateScope ) -> Bool { incomingScope.rawValue >= currentScope.rawValue } static func shouldReloadConfigurationForAppearanceChange( previousColorScheme: GhosttyConfig.ColorSchemePreference?, currentColorScheme: GhosttyConfig.ColorSchemePreference ) -> Bool { previousColorScheme != currentColorScheme } static func shouldCaptureScrollLagEvent( samples: Int, averageMs: Double, maxMs: Double, thresholdMs: Double, minimumSamples: Int = 8, minimumAverageMs: Double = 12, nowUptime: TimeInterval, lastReportedUptime: TimeInterval?, cooldown: TimeInterval = 300 ) -> Bool { guard samples >= minimumSamples else { return false } guard averageMs.isFinite, maxMs.isFinite, thresholdMs.isFinite, nowUptime.isFinite, cooldown.isFinite else { return false } guard averageMs >= minimumAverageMs else { return false } guard maxMs > thresholdMs else { return false } if let lastReportedUptime, nowUptime - lastReportedUptime < cooldown { return false } return true } private func loadReleaseAppSupportGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) let fm = FileManager.default guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } guard let currentBundleIdentifier = Bundle.main.bundleIdentifier, !currentBundleIdentifier.isEmpty else { return } let currentAppSupportDir = appSupport.appendingPathComponent(currentBundleIdentifier, isDirectory: true) let releaseAppSupportDir = appSupport.appendingPathComponent(Self.releaseBundleIdentifier, isDirectory: true) let currentConfig = currentAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false) let currentLegacyConfig = currentAppSupportDir.appendingPathComponent("config", isDirectory: false) let releaseConfig = releaseAppSupportDir.appendingPathComponent("config.ghostty", isDirectory: false) let releaseLegacyConfig = releaseAppSupportDir.appendingPathComponent("config", isDirectory: false) func fileSize(_ url: URL) -> Int? { guard let attrs = try? fm.attributesOfItem(atPath: url.path), let size = attrs[.size] as? NSNumber else { return nil } return size.intValue } let releaseConfigSize = fileSize(releaseConfig) let releaseLegacyConfigSize = fileSize(releaseLegacyConfig) guard Self.shouldLoadReleaseAppSupportGhosttyConfig( currentBundleIdentifier: currentBundleIdentifier, currentConfigFileSize: fileSize(currentConfig), currentLegacyConfigFileSize: fileSize(currentLegacyConfig), releaseConfigFileSize: releaseConfigSize, releaseLegacyConfigFileSize: releaseLegacyConfigSize ) else { return } if let releaseLegacyConfigSize, releaseLegacyConfigSize > 0 { releaseLegacyConfig.path.withCString { path in ghostty_config_load_file(config, path) } } if let releaseConfigSize, releaseConfigSize > 0 { releaseConfig.path.withCString { path in ghostty_config_load_file(config, path) } } #if DEBUG Self.initLog( "loaded release app support ghostty config fallback from: \(releaseAppSupportDir.path)" ) #endif #endif } private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) { #if os(macOS) // Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real // settings in the legacy `config` file. If the new file exists but is empty, // load the legacy file as a compatibility fallback. let fm = FileManager.default guard let appSupport = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return } let ghosttyDir = appSupport.appendingPathComponent("com.mitchellh.ghostty", isDirectory: true) let configNew = ghosttyDir.appendingPathComponent("config.ghostty", isDirectory: false) let configLegacy = ghosttyDir.appendingPathComponent("config", isDirectory: false) func fileSize(_ url: URL) -> Int? { guard let attrs = try? fm.attributesOfItem(atPath: url.path), let size = attrs[.size] as? NSNumber else { return nil } return size.intValue } guard Self.shouldLoadLegacyGhosttyConfig( newConfigFileSize: fileSize(configNew), legacyConfigFileSize: fileSize(configLegacy) ) else { return } configLegacy.path.withCString { path in ghostty_config_load_file(config, path) } #if DEBUG Self.initLog("loaded legacy ghostty config because config.ghostty was empty: \(configLegacy.path)") #endif #endif } func tick() { guard let app = app else { return } let start = CACurrentMediaTime() ghostty_app_tick(app) let elapsedMs = (CACurrentMediaTime() - start) * 1000 // Track lag during scrolling if isScrolling { scrollLagSampleCount += 1 scrollLagTotalMs += elapsedMs scrollLagMaxMs = max(scrollLagMaxMs, elapsedMs) } } func reloadConfiguration(soft: Bool = false, source: String = "unspecified") { guard let app else { logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app") return } logThemeAction("reload begin source=\(source) soft=\(soft)") resetDefaultBackgroundUpdateScope(source: "reloadConfiguration(source=\(source))") if soft, let config { ghostty_app_update_config(app, config) lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) scheduleSurfaceRefreshAfterConfigurationReload(source: source) logThemeAction("reload end source=\(source) soft=\(soft) mode=soft") return } guard let newConfig = ghostty_config_new() else { logThemeAction("reload skipped source=\(source) soft=\(soft) reason=config_alloc_failed") return } loadDefaultConfigFilesWithLegacyFallback(newConfig) ghostty_app_update_config(app, newConfig) updateDefaultBackground( from: newConfig, source: "reloadConfiguration(source=\(source))", scope: .unscoped ) DispatchQueue.main.async { self.applyBackgroundToKeyWindow() } if let oldConfig = config { ghostty_config_free(oldConfig) } config = newConfig lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference() NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) scheduleSurfaceRefreshAfterConfigurationReload(source: source) logThemeAction("reload end source=\(source) soft=\(soft) mode=full") } private func scheduleSurfaceRefreshAfterConfigurationReload(source: String) { DispatchQueue.main.async { AppDelegate.shared?.refreshTerminalSurfacesAfterGhosttyConfigReload(source: source) } } func synchronizeThemeWithAppearance(_ appearance: NSAppearance?, source: String) { let currentColorScheme = GhosttyConfig.currentColorSchemePreference( appAppearance: appearance ?? NSApp?.effectiveAppearance ) let shouldReload = Self.shouldReloadConfigurationForAppearanceChange( previousColorScheme: lastAppearanceColorScheme, currentColorScheme: currentColorScheme ) if backgroundLogEnabled { let previousLabel: String switch lastAppearanceColorScheme { case .light: previousLabel = "light" case .dark: previousLabel = "dark" case nil: previousLabel = "nil" } let currentLabel: String = currentColorScheme == .dark ? "dark" : "light" logBackground( "appearance sync source=\(source) previous=\(previousLabel) current=\(currentLabel) reload=\(shouldReload)" ) } guard shouldReload else { return } lastAppearanceColorScheme = currentColorScheme reloadConfiguration(source: "appearanceSync:\(source)") } func openConfigurationInTextEdit() { #if os(macOS) let path = ghosttyStringValue(ghostty_config_open_path()) guard !path.isEmpty else { return } let fileURL = URL(fileURLWithPath: path) let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app") let configuration = NSWorkspace.OpenConfiguration() NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration) #endif } private func ghosttyStringValue(_ value: ghostty_string_s) -> String { defer { ghostty_string_free(value) } guard let ptr = value.ptr, value.len > 0 else { return "" } let rawPtr = UnsafeRawPointer(ptr).assumingMemoryBound(to: UInt8.self) let buffer = UnsafeBufferPointer(start: rawPtr, count: Int(value.len)) return String(decoding: buffer, as: UTF8.self) } private func resetDefaultBackgroundUpdateScope(source: String) { let previousScope = defaultBackgroundUpdateScope let previousScopeSource = defaultBackgroundScopeSource defaultBackgroundUpdateScope = .unscoped defaultBackgroundScopeSource = "reset:\(source)" if backgroundLogEnabled { logBackground( "default background scope reset source=\(source) previousScope=\(previousScope.logLabel) previousSource=\(previousScopeSource)" ) } } private func updateDefaultBackground( from config: ghostty_config_t?, source: String, scope: GhosttyDefaultBackgroundUpdateScope = .unscoped ) { guard let config else { return } var resolvedColor = defaultBackgroundColor var color = ghostty_config_color_s() let bgKey = "background" if ghostty_config_get(config, &color, bgKey, UInt(bgKey.lengthOfBytes(using: .utf8))) { resolvedColor = NSColor( red: CGFloat(color.r) / 255, green: CGFloat(color.g) / 255, blue: CGFloat(color.b) / 255, alpha: 1.0 ) } var opacity = defaultBackgroundOpacity let opacityKey = "background-opacity" _ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8))) opacity = min(1.0, max(0.0, opacity)) applyDefaultBackground( color: resolvedColor, opacity: opacity, source: source, scope: scope ) } func focusFollowsMouseEnabled() -> Bool { guard let config else { return false } var enabled = false let key = "focus-follows-mouse" let keyLength = UInt(key.lengthOfBytes(using: .utf8)) let found = ghostty_config_get(config, &enabled, key, keyLength) return found && enabled } private func applyDefaultBackground( color: NSColor, opacity: Double, source: String, scope: GhosttyDefaultBackgroundUpdateScope ) { let previousScope = defaultBackgroundUpdateScope let previousScopeSource = defaultBackgroundScopeSource guard Self.shouldApplyDefaultBackgroundUpdate(currentScope: previousScope, incomingScope: scope) else { if backgroundLogEnabled { logBackground( "default background skipped source=\(source) incomingScope=\(scope.logLabel) currentScope=\(previousScope.logLabel) currentSource=\(previousScopeSource) color=\(color.hexString()) opacity=\(String(format: "%.3f", opacity))" ) } return } defaultBackgroundUpdateScope = scope defaultBackgroundScopeSource = source let previousHex = defaultBackgroundColor.hexString() let previousOpacity = defaultBackgroundOpacity defaultBackgroundColor = color defaultBackgroundOpacity = opacity let hasChanged = previousHex != defaultBackgroundColor.hexString() || abs(previousOpacity - defaultBackgroundOpacity) > 0.0001 if hasChanged { notifyDefaultBackgroundDidChange(source: source) } if backgroundLogEnabled { logBackground( "default background updated source=\(source) scope=\(scope.logLabel) previousScope=\(previousScope.logLabel) previousScopeSource=\(previousScopeSource) previousColor=\(previousHex) previousOpacity=\(String(format: "%.3f", previousOpacity)) color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity)) changed=\(hasChanged)" ) } } private func nextBackgroundEventId() -> UInt64 { precondition(Thread.isMainThread, "Background event IDs must be generated on main thread") backgroundEventCounter &+= 1 return backgroundEventCounter } private func notifyDefaultBackgroundDidChange(source: String) { let signal = { [self] in let eventId = nextBackgroundEventId() defaultBackgroundNotificationDispatcher.signal( backgroundColor: defaultBackgroundColor, opacity: defaultBackgroundOpacity, eventId: eventId, source: source ) } if Thread.isMainThread { signal() } else { DispatchQueue.main.async(execute: signal) } } private func logThemeAction(_ message: String) { guard backgroundLogEnabled else { return } logBackground("theme action \(message)") } private func actionLabel(for action: ghostty_action_s) -> String { switch action.tag { case GHOSTTY_ACTION_RELOAD_CONFIG: return "reload_config" case GHOSTTY_ACTION_CONFIG_CHANGE: return "config_change" case GHOSTTY_ACTION_COLOR_CHANGE: return "color_change" default: return String(describing: action.tag) } } private func logAction(_ action: ghostty_action_s, target: ghostty_target_s, tabId: UUID?, surfaceId: UUID?) { guard backgroundLogEnabled else { return } let targetLabel = target.tag == GHOSTTY_TARGET_SURFACE ? "surface" : "app" logBackground( "action event target=\(targetLabel) action=\(actionLabel(for: action)) tab=\(tabId?.uuidString ?? "nil") surface=\(surfaceId?.uuidString ?? "nil")" ) } private func performOnMain(_ work: @MainActor () -> T) -> T { if Thread.isMainThread { return MainActor.assumeIsolated { work() } } return DispatchQueue.main.sync { MainActor.assumeIsolated { work() } } } private func splitDirection(from direction: ghostty_action_split_direction_e) -> SplitDirection? { switch direction { case GHOSTTY_SPLIT_DIRECTION_RIGHT: return .right case GHOSTTY_SPLIT_DIRECTION_LEFT: return .left case GHOSTTY_SPLIT_DIRECTION_DOWN: return .down case GHOSTTY_SPLIT_DIRECTION_UP: return .up default: return nil } } private func focusDirection(from direction: ghostty_action_goto_split_e) -> NavigationDirection? { switch direction { // For previous/next, we use left/right as a reasonable default // Bonsplit doesn't have cycle-based navigation case GHOSTTY_GOTO_SPLIT_PREVIOUS: return .left case GHOSTTY_GOTO_SPLIT_NEXT: return .right case GHOSTTY_GOTO_SPLIT_UP: return .up case GHOSTTY_GOTO_SPLIT_DOWN: return .down case GHOSTTY_GOTO_SPLIT_LEFT: return .left case GHOSTTY_GOTO_SPLIT_RIGHT: return .right default: return nil } } private func resizeDirection(from direction: ghostty_action_resize_split_direction_e) -> ResizeDirection? { switch direction { case GHOSTTY_RESIZE_SPLIT_UP: return .up case GHOSTTY_RESIZE_SPLIT_DOWN: return .down case GHOSTTY_RESIZE_SPLIT_LEFT: return .left case GHOSTTY_RESIZE_SPLIT_RIGHT: return .right default: return nil } } private static func callbackContext(from userdata: UnsafeMutableRawPointer?) -> GhosttySurfaceCallbackContext? { guard let userdata else { return nil } return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || action.tag == GHOSTTY_ACTION_COLOR_CHANGE { logAction(action, target: target, tabId: nil, surfaceId: nil) } if action.tag == GHOSTTY_ACTION_DESKTOP_NOTIFICATION { let actionTitle = action.action.desktop_notification.title .flatMap { String(cString: $0) } ?? "" let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager, let tabId = tabManager.selectedTabId else { return false } let tabTitle = tabManager.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody let surfaceId = tabManager.focusedSurfaceId(for: tabId) TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, title: command, subtitle: "", body: body ) return true } } if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG { let soft = action.action.reload_config.soft logThemeAction("reload request target=app soft=\(soft)") performOnMain { GhosttyApp.shared.reloadConfiguration(soft: soft, source: "action.reload_config.app") } return true } if action.tag == GHOSTTY_ACTION_COLOR_CHANGE, action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change let resolvedColor = NSColor( red: CGFloat(change.r) / 255, green: CGFloat(change.g) / 255, blue: CGFloat(change.b) / 255, alpha: 1.0 ) applyDefaultBackground( color: resolvedColor, opacity: defaultBackgroundOpacity, source: "action.color_change.app", scope: .app ) DispatchQueue.main.async { GhosttyApp.shared.applyBackgroundToKeyWindow() } return true } if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE { updateDefaultBackground( from: action.action.config_change.config, source: "action.config_change.app", scope: .app ) DispatchQueue.main.async { GhosttyApp.shared.applyBackgroundToKeyWindow() } return true } return false } let callbackContext = Self.callbackContext(from: ghostty_surface_userdata(target.target.surface)) let callbackTabId = callbackContext?.tabId let callbackSurfaceId = callbackContext?.surfaceId if action.tag == GHOSTTY_ACTION_SHOW_CHILD_EXITED { // The child (shell) exited. Ghostty will fall back to printing // "Process exited. Press any key..." into the terminal unless the host // handles this action. For cmux, the correct behavior is to close // the panel immediately (no prompt). #if DEBUG dlog( "surface.action.showChildExited tab=\(callbackTabId?.uuidString.prefix(5) ?? "nil") " + "surface=\(callbackSurfaceId?.uuidString.prefix(5) ?? "nil")" ) #endif #if DEBUG cmuxWriteChildExitProbe( [ "probeShowChildExitedTabId": callbackTabId?.uuidString ?? "", "probeShowChildExitedSurfaceId": callbackSurfaceId?.uuidString ?? "", ], increments: ["probeShowChildExitedCount": 1] ) #endif // Keep host-close async to avoid re-entrant close/deinit while Ghostty is still // dispatching this action callback. DispatchQueue.main.async { guard let app = AppDelegate.shared else { return } if let callbackTabId, let callbackSurfaceId, let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == callbackTabId }), workspace.panels[callbackSurfaceId] != nil { manager.closePanelAfterChildExited(tabId: callbackTabId, surfaceId: callbackSurfaceId) } } // Always report handled so Ghostty doesn't print the fallback prompt. return true } guard let surfaceView = callbackContext?.surfaceView else { return false } if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || action.tag == GHOSTTY_ACTION_CONFIG_CHANGE || action.tag == GHOSTTY_ACTION_COLOR_CHANGE { logAction( action, target: target, tabId: callbackTabId ?? surfaceView.tabId, surfaceId: callbackSurfaceId ?? surfaceView.terminalSurface?.id ) } switch action.tag { case GHOSTTY_ACTION_NEW_SPLIT: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, let direction = splitDirection(from: action.action.new_split) else { return false } return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } case GHOSTTY_ACTION_GOTO_SPLIT: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, let direction = focusDirection(from: action.action.goto_split) else { return false } return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.moveSplitFocus(tabId: tabId, surfaceId: surfaceId, direction: direction) } case GHOSTTY_ACTION_RESIZE_SPLIT: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id, let direction = resizeDirection(from: action.action.resize_split.direction) else { return false } let amount = action.action.resize_split.amount return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.resizeSplit( tabId: tabId, surfaceId: surfaceId, direction: direction, amount: amount ) } case GHOSTTY_ACTION_EQUALIZE_SPLITS: guard let tabId = surfaceView.tabId else { return false } return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.equalizeSplits(tabId: tabId) } case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id else { return false } return performOnMain { guard let tabManager = AppDelegate.shared?.tabManager else { return false } return tabManager.toggleSplitZoom(tabId: tabId, surfaceId: surfaceId) } case GHOSTTY_ACTION_SCROLLBAR: let scrollbar = GhosttyScrollbar(c: action.action.scrollbar) surfaceView.scrollbar = scrollbar NotificationCenter.default.post( name: .ghosttyDidUpdateScrollbar, object: surfaceView, userInfo: [GhosttyNotificationKey.scrollbar: scrollbar] ) return true case GHOSTTY_ACTION_CELL_SIZE: let cellSize = CGSize( width: CGFloat(action.action.cell_size.width), height: CGFloat(action.action.cell_size.height) ) surfaceView.cellSize = cellSize NotificationCenter.default.post( name: .ghosttyDidUpdateCellSize, object: surfaceView, userInfo: [GhosttyNotificationKey.cellSize: cellSize] ) return true case GHOSTTY_ACTION_START_SEARCH: guard let terminalSurface = surfaceView.terminalSurface else { return true } let needle = action.action.start_search.needle.flatMap { String(cString: $0) } DispatchQueue.main.async { if let searchState = terminalSurface.searchState { if let needle, !needle.isEmpty { searchState.needle = needle } } else { terminalSurface.searchState = TerminalSurface.SearchState(needle: needle ?? "") } NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface) } return true case GHOSTTY_ACTION_END_SEARCH: guard let terminalSurface = surfaceView.terminalSurface else { return true } DispatchQueue.main.async { terminalSurface.searchState = nil } return true case GHOSTTY_ACTION_SEARCH_TOTAL: guard let terminalSurface = surfaceView.terminalSurface else { return true } let rawTotal = action.action.search_total.total let total: UInt? = rawTotal >= 0 ? UInt(rawTotal) : nil DispatchQueue.main.async { terminalSurface.searchState?.total = total } return true case GHOSTTY_ACTION_SEARCH_SELECTED: guard let terminalSurface = surfaceView.terminalSurface else { return true } let rawSelected = action.action.search_selected.selected let selected: UInt? = rawSelected >= 0 ? UInt(rawSelected) : nil DispatchQueue.main.async { terminalSurface.searchState?.selected = selected } return true case GHOSTTY_ACTION_SET_TITLE: let title = action.action.set_title.title .flatMap { String(cString: $0) } ?? "" if let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id { DispatchQueue.main.async { NotificationCenter.default.post( name: .ghosttyDidSetTitle, object: surfaceView, userInfo: [ GhosttyNotificationKey.tabId: tabId, GhosttyNotificationKey.surfaceId: surfaceId, GhosttyNotificationKey.title: title, ] ) } } return true case GHOSTTY_ACTION_PWD: guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id else { return true } let pwd = action.action.pwd.pwd.flatMap { String(cString: $0) } ?? "" DispatchQueue.main.async { AppDelegate.shared?.tabManager?.updateSurfaceDirectory( tabId: tabId, surfaceId: surfaceId, directory: pwd ) } return true case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: guard let tabId = surfaceView.tabId else { return true } let surfaceId = surfaceView.terminalSurface?.id let actionTitle = action.action.desktop_notification.title .flatMap { String(cString: $0) } ?? "" let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" performOnMain { let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal" let command = actionTitle.isEmpty ? tabTitle : actionTitle let body = actionBody TerminalNotificationStore.shared.addNotification( tabId: tabId, surfaceId: surfaceId, title: command, subtitle: "", body: body ) } return true case GHOSTTY_ACTION_COLOR_CHANGE: if action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change surfaceView.backgroundColor = NSColor( red: CGFloat(change.r) / 255, green: CGFloat(change.g) / 255, blue: CGFloat(change.b) / 255, alpha: 1.0 ) if backgroundLogEnabled { logBackground( "surface override set tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString()) source=action.color_change.surface" ) } surfaceView.applySurfaceBackground() if backgroundLogEnabled { logBackground("OSC background change tab=\(surfaceView.tabId?.uuidString ?? "unknown") color=\(surfaceView.backgroundColor?.description ?? "nil")") } DispatchQueue.main.async { surfaceView.applyWindowBackgroundIfActive() } } return true case GHOSTTY_ACTION_CONFIG_CHANGE: if let staleOverride = surfaceView.backgroundColor { surfaceView.backgroundColor = nil if backgroundLogEnabled { logBackground( "surface override cleared tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") cleared=\(staleOverride.hexString()) source=action.config_change.surface" ) } surfaceView.applySurfaceBackground() DispatchQueue.main.async { surfaceView.applyWindowBackgroundIfActive() } } updateDefaultBackground( from: action.action.config_change.config, source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")", scope: .surface ) if backgroundLogEnabled { logBackground( "surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString())" ) } return true case GHOSTTY_ACTION_RELOAD_CONFIG: let soft = action.action.reload_config.soft logThemeAction( "reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)" ) return performOnMain { // Keep all runtime theme/default-background state in the same path. GhosttyApp.shared.reloadConfiguration( soft: soft, source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")" ) return true } case GHOSTTY_ACTION_KEY_SEQUENCE: return performOnMain { surfaceView.updateKeySequence(action.action.key_sequence) return true } case GHOSTTY_ACTION_KEY_TABLE: return performOnMain { surfaceView.updateKeyTable(action.action.key_table) return true } case GHOSTTY_ACTION_OPEN_URL: let openUrl = action.action.open_url guard let cstr = openUrl.url else { return false } let urlString = String( data: Data(bytes: cstr, count: Int(openUrl.len)), encoding: .utf8 ) ?? "" #if DEBUG dlog("link.openURL raw=\(urlString)") #endif guard let target = resolveTerminalOpenURLTarget(urlString) else { #if DEBUG dlog("link.openURL resolve failed, returning false") #endif return false } if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() { #if DEBUG dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)") #endif return performOnMain { NSWorkspace.shared.open(target.url) } } switch target { case let .external(url): #if DEBUG dlog("link.openURL target=external, opening externally url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) } case let .embeddedBrowser(url): if BrowserLinkOpenSettings.shouldOpenExternally(url) { #if DEBUG dlog("link.openURL target=embedded but shouldOpenExternally=true url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) } } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { #if DEBUG dlog("link.openURL target=embedded but normalizeHost=nil host=\(url.host ?? "nil") url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) } } // If a host whitelist is configured and this host isn't in it, open externally. if !BrowserLinkOpenSettings.hostMatchesWhitelist(host) { #if DEBUG dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)") #endif return performOnMain { NSWorkspace.shared.open(url) } } guard let tabId = surfaceView.tabId, let surfaceId = surfaceView.terminalSurface?.id else { #if DEBUG dlog("link.openURL target=embedded but tabId/surfaceId=nil") #endif return false } #if DEBUG dlog("link.openURL target=embedded, opening in browser pane host=\(host) url=\(url) tabId=\(tabId) surfaceId=\(surfaceId)") #endif return performOnMain { guard let app = AppDelegate.shared, let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, let workspace = tabManager.tabs.first(where: { $0.id == tabId }) else { #if DEBUG dlog("link.openURL embedded but workspace lookup failed tabId=\(tabId)") #endif return false } if let targetPane = workspace.preferredBrowserTargetPane(fromPanelId: surfaceId) { #if DEBUG dlog("link.openURL opening in existing browser pane=\(targetPane)") #endif return workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil } else { #if DEBUG dlog("link.openURL opening as new browser split from surface=\(surfaceId)") #endif return workspace.newBrowserSplit(from: surfaceId, orientation: .horizontal, url: url) != nil } } } default: return false } } private func applyBackgroundToKeyWindow() { guard let window = activeMainWindow() else { return } if cmuxShouldUseClearWindowBackground(for: defaultBackgroundOpacity) { window.backgroundColor = cmuxTransparentWindowBaseColor() window.isOpaque = false if backgroundLogEnabled { logBackground("applied transparent window background opacity=\(String(format: "%.3f", defaultBackgroundOpacity))") } } else { let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity) window.backgroundColor = color window.isOpaque = color.alphaComponent >= 1.0 if backgroundLogEnabled { logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") } } } private func activeMainWindow() -> NSWindow? { let keyWindow = NSApp.keyWindow if let raw = keyWindow?.identifier?.rawValue, raw == "cmux.main" || raw.hasPrefix("cmux.main.") { return keyWindow } return NSApp.windows.first(where: { window in guard let raw = window.identifier?.rawValue else { return false } return raw == "cmux.main" || raw.hasPrefix("cmux.main.") }) } func logBackground(_ message: String) { let timestamp = Self.backgroundLogTimestampFormatter.string(from: Date()) let uptimeMs = (ProcessInfo.processInfo.systemUptime - backgroundLogStartUptime) * 1000 let frame60 = Int((CACurrentMediaTime() * 60.0).rounded(.down)) let frame120 = Int((CACurrentMediaTime() * 120.0).rounded(.down)) let threadLabel = Thread.isMainThread ? "main" : "background" backgroundLogLock.lock() defer { backgroundLogLock.unlock() } backgroundLogSequence &+= 1 let sequence = backgroundLogSequence let line = "\(timestamp) seq=\(sequence) t+\(String(format: "%.3f", uptimeMs))ms thread=\(threadLabel) frame60=\(frame60) frame120=\(frame120) cmux bg: \(message)\n" if let data = line.data(using: .utf8) { if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false { FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil) } if let handle = try? FileHandle(forWritingTo: backgroundLogURL) { defer { try? handle.close() } try? handle.seekToEnd() try? handle.write(contentsOf: data) } } } } // MARK: - Debug Render Instrumentation /// Lightweight instrumentation to detect whether Ghostty is actually requesting Metal drawables. /// This helps catch "frozen until refocus" regressions without relying on screenshots (which can /// mask redraw issues by forcing a window server flush). final class GhosttyMetalLayer: CAMetalLayer { private let lock = NSLock() private var drawableCount: Int = 0 private var lastDrawableTime: CFTimeInterval = 0 func debugStats() -> (count: Int, last: CFTimeInterval) { lock.lock() defer { lock.unlock() } return (drawableCount, lastDrawableTime) } override func nextDrawable() -> CAMetalDrawable? { lock.lock() drawableCount += 1 lastDrawableTime = CACurrentMediaTime() lock.unlock() return super.nextDrawable() } } // MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle) final class TerminalSurface: Identifiable, ObservableObject { final class SearchState: ObservableObject { @Published var needle: String @Published var selected: UInt? @Published var total: UInt? init(needle: String = "") { self.needle = needle self.selected = nil self.total = nil } } private(set) var surface: ghostty_surface_t? private weak var attachedView: GhosttyNSView? /// Whether the terminal surface view is currently attached to a window. /// /// Use the hosted view rather than the inner surface view, since the surface can be /// temporarily unattached (surface not yet created / reparenting) even while the panel /// is already in the window. var isViewInWindow: Bool { hostedView.window != nil } let id: UUID private(set) var tabId: UUID /// Port ordinal for CMUX_PORT range assignment var portOrdinal: Int = 0 /// Snapshotted once per app session so all workspaces use consistent values private static let sessionPortBase: Int = { let val = UserDefaults.standard.integer(forKey: "cmuxPortBase") return val > 0 ? val : 9100 }() private static let sessionPortRangeSize: Int = { let val = UserDefaults.standard.integer(forKey: "cmuxPortRange") return val > 0 ? val : 10 }() private let surfaceContext: ghostty_surface_context_e private let configTemplate: ghostty_surface_config_s? private let workingDirectory: String? private let additionalEnvironment: [String: String] let hostedView: GhosttySurfaceScrollView private let surfaceView: GhosttyNSView private var lastPixelWidth: UInt32 = 0 private var lastPixelHeight: UInt32 = 0 private var lastXScale: CGFloat = 0 private var lastYScale: CGFloat = 0 private var pendingTextQueue: [Data] = [] private var pendingTextBytes: Int = 0 private let maxPendingTextBytes = 1_048_576 private var backgroundSurfaceStartQueued = false private var surfaceCallbackContext: Unmanaged? private enum PortalLifecycleState: String { case live case closing case closed } private var portalLifecycleState: PortalLifecycleState = .live private var portalLifecycleGeneration: UInt64 = 1 @Published var searchState: SearchState? = nil { didSet { if let searchState { hostedView.cancelFocusRequest() NSLog("Find: search state created tab=%@ surface=%@", tabId.uuidString, id.uuidString) searchNeedleCancellable = searchState.$needle .removeDuplicates() .map { needle -> AnyPublisher in if needle.isEmpty || needle.count >= 3 { return Just(needle).eraseToAnyPublisher() } return Just(needle) .delay(for: .milliseconds(300), scheduler: DispatchQueue.main) .eraseToAnyPublisher() } .switchToLatest() .sink { [weak self] needle in NSLog("Find: needle updated tab=%@ surface=%@ needle=%@", self?.tabId.uuidString ?? "unknown", self?.id.uuidString ?? "unknown", needle) _ = self?.performBindingAction("search:\(needle)") } } else if oldValue != nil { searchNeedleCancellable = nil NSLog("Find: search state cleared tab=%@ surface=%@", tabId.uuidString, id.uuidString) _ = performBindingAction("end_search") } } } @Published private(set) var keyboardCopyModeActive: Bool = false private var searchNeedleCancellable: AnyCancellable? init( tabId: UUID, context: ghostty_surface_context_e, configTemplate: ghostty_surface_config_s?, workingDirectory: String? = nil, additionalEnvironment: [String: String] = [:] ) { self.id = UUID() self.tabId = tabId self.surfaceContext = context self.configTemplate = configTemplate self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) self.additionalEnvironment = additionalEnvironment // Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer // has non-zero bounds and the renderer can initialize without presenting a blank/stretched // intermediate frame on the first real resize. let view = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) self.surfaceView = view self.hostedView = GhosttySurfaceScrollView(surfaceView: view) // Surface is created when attached to a view hostedView.attachSurface(self) } func updateWorkspaceId(_ newTabId: UUID) { tabId = newTabId attachedView?.tabId = newTabId surfaceView.tabId = newTabId } func portalBindingGeneration() -> UInt64 { portalLifecycleGeneration } func portalBindingStateLabel() -> String { portalLifecycleState.rawValue } func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { guard portalLifecycleState == .live else { return false } if let expectedSurfaceId, expectedSurfaceId != id { return false } if let expectedGeneration, expectedGeneration != portalLifecycleGeneration { return false } return true } func beginPortalCloseLifecycle(reason: String) { guard portalLifecycleState != .closed else { return } guard portalLifecycleState != .closing else { return } portalLifecycleState = .closing portalLifecycleGeneration &+= 1 #if DEBUG dlog( "surface.lifecycle.close.begin surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + "generation=\(portalLifecycleGeneration)" ) #endif } private func markPortalLifecycleClosed(reason: String) { guard portalLifecycleState != .closed else { return } portalLifecycleState = .closed portalLifecycleGeneration &+= 1 #if DEBUG dlog( "surface.lifecycle.close.sealed surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " + "generation=\(portalLifecycleGeneration)" ) #endif } #if DEBUG private static let surfaceLogPath = "/tmp/cmux-ghostty-surface.log" private static let sizeLogPath = "/tmp/cmux-ghostty-size.log" private static func surfaceLog(_ message: String) { let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: surfaceLogPath) { handle.seekToEndOfFile() handle.write(line.data(using: .utf8)!) handle.closeFile() } else { FileManager.default.createFile(atPath: surfaceLogPath, contents: line.data(using: .utf8)) } } private static func sizeLog(_ message: String) { let env = ProcessInfo.processInfo.environment guard env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1" else { return } let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "[\(timestamp)] \(message)\n" if let handle = FileHandle(forWritingAtPath: sizeLogPath) { handle.seekToEndOfFile() handle.write(line.data(using: .utf8)!) handle.closeFile() } else { FileManager.default.createFile(atPath: sizeLogPath, contents: line.data(using: .utf8)) } } #endif /// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived /// from backing-space points and truncated (never rounded up). private func pixelDimension(from value: CGFloat) -> UInt32 { guard value.isFinite else { return 0 } let floored = floor(max(0, value)) if floored >= CGFloat(UInt32.max) { return UInt32.max } return UInt32(floored) } private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) { let scale = max( 1.0, view.window?.backingScaleFactor ?? view.layer?.contentsScale ?? NSScreen.main?.backingScaleFactor ?? 1.0 ) return (scale, scale, scale) } private func scaleApproximatelyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool { abs(lhs - rhs) <= epsilon } func attachToView(_ view: GhosttyNSView) { #if DEBUG dlog( "surface.attach surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque()) " + "attached=\(attachedView != nil ? 1 : 0) hasSurface=\(surface != nil ? 1 : 0) inWindow=\(view.window != nil ? 1 : 0)" ) #endif // If already attached to this view, nothing to do. // Still re-assert the display id: during split close tree restructuring, the view can be // removed/re-added (or briefly have window/screen nil) without recreating the surface. // Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing // or stale, the surface can appear visually frozen until a focus/visibility change. if attachedView === view && surface != nil { #if DEBUG dlog("surface.attach.reuse surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque())") #endif if let screen = view.window?.screen ?? NSScreen.main, let displayID = screen.displayID, displayID != 0, let s = surface { ghostty_surface_set_display_id(s, displayID) } view.forceRefreshSurface() return } if let attachedView, attachedView !== view { #if DEBUG dlog( "surface.attach.skip surface=\(id.uuidString.prefix(5)) reason=alreadyAttachedToDifferentView " + "current=\(Unmanaged.passUnretained(attachedView).toOpaque()) new=\(Unmanaged.passUnretained(view).toOpaque())" ) #endif return } attachedView = view // If surface doesn't exist yet, create it once the view is in a real window so // content scale and pixel geometry are derived from the actual backing context. if surface == nil { guard view.window != nil else { #if DEBUG dlog( "surface.attach.defer surface=\(id.uuidString.prefix(5)) reason=noWindow " + "bounds=\(String(format: "%.1fx%.1f", view.bounds.width, view.bounds.height))" ) #endif return } #if DEBUG dlog("surface.attach.create surface=\(id.uuidString.prefix(5))") #endif createSurface(for: view) #if DEBUG dlog("surface.attach.create.done surface=\(id.uuidString.prefix(5)) hasSurface=\(surface != nil ? 1 : 0)") #endif } else if let screen = view.window?.screen ?? NSScreen.main, let displayID = screen.displayID, displayID != 0, let s = surface { // Surface exists but we're (re)attaching after a view hierarchy move; ensure display id. ghostty_surface_set_display_id(s, displayID) #if DEBUG dlog("surface.attach.displayId surface=\(id.uuidString.prefix(5)) display=\(displayID)") #endif } } private func createSurface(for view: GhosttyNSView) { #if DEBUG let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap { String(cString: $0) } ?? "(unset)" let terminfo = getenv("TERMINFO").flatMap { String(cString: $0) } ?? "(unset)" let xdg = getenv("XDG_DATA_DIRS").flatMap { String(cString: $0) } ?? "(unset)" let manpath = getenv("MANPATH").flatMap { String(cString: $0) } ?? "(unset)" Self.surfaceLog("createSurface start surface=\(id.uuidString) tab=\(tabId.uuidString) bounds=\(view.bounds) inWindow=\(view.window != nil) resources=\(resourcesDir) terminfo=\(terminfo) xdg=\(xdg) manpath=\(manpath)") #endif guard let app = GhosttyApp.shared.app else { print("Ghostty app not initialized") #if DEBUG Self.surfaceLog("createSurface FAILED surface=\(id.uuidString): ghostty app not initialized") #endif return } let scaleFactors = scaleFactors(for: view) var surfaceConfig = configTemplate ?? ghostty_surface_config_new() surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceView: view, terminalSurface: self)) surfaceConfig.userdata = callbackContext.toOpaque() surfaceCallbackContext?.release() surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer surfaceConfig.context = surfaceContext #if DEBUG let templateFontText = String(format: "%.2f", surfaceConfig.font_size) dlog( "zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + "templateFont=\(templateFontText)" ) #endif var envVars: [ghostty_env_var_s] = [] var envStorage: [(UnsafeMutablePointer, UnsafeMutablePointer)] = [] defer { for (key, value) in envStorage { free(key) free(value) } } var env: [String: String] = [:] if surfaceConfig.env_var_count > 0, let existingEnv = surfaceConfig.env_vars { let count = Int(surfaceConfig.env_var_count) if count > 0 { for i in 0.. 0, hpx > 0 { ghostty_surface_set_size(createdSurface, wpx, hpx) lastPixelWidth = wpx lastPixelHeight = hpx lastXScale = scaleFactors.x lastYScale = scaleFactors.y } // Some GhosttyKit builds can drop inherited font_size during post-create // config/scale reconciliation. If runtime points don't match the inherited // template points, re-apply via binding action so all creation paths // (new surface, split, new workspace) preserve zoom from the source terminal. if let inheritedFontPoints = configTemplate?.font_size, inheritedFontPoints > 0 { let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface) let shouldReapply = { guard let currentFontPoints else { return true } return abs(currentFontPoints - inheritedFontPoints) > 0.05 }() if shouldReapply { let action = String(format: "set_font_size:%.3f", inheritedFontPoints) _ = performBindingAction(action) } } flushPendingTextIfNeeded() // Kick an initial draw after creation/size setup. On some startup paths Ghostty can // miss the first vsync callback and sit on a blank frame until another focus/visibility // transition nudges the renderer. view.forceRefreshSurface() ghostty_surface_refresh(createdSurface) #if DEBUG let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map { String(format: "%.2f", $0) } ?? "nil" dlog( "zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + "runtimeFont=\(runtimeFontText)" ) #endif } func updateSize( width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat, backingSize: CGSize? = nil ) { guard let surface = surface else { return } _ = layerScale let resolvedBackingWidth = backingSize?.width ?? (width * xScale) let resolvedBackingHeight = backingSize?.height ?? (height * yScale) let wpx = pixelDimension(from: resolvedBackingWidth) let hpx = pixelDimension(from: resolvedBackingHeight) guard wpx > 0, hpx > 0 else { return } let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale) let sizeChanged = wpx != lastPixelWidth || hpx != lastPixelHeight #if DEBUG Self.sizeLog("updateSize-call surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) changed=\((scaleChanged || sizeChanged) ? 1 : 0)") #endif guard scaleChanged || sizeChanged else { return } #if DEBUG if sizeChanged { let win = attachedView?.window != nil ? "1" : "0" Self.sizeLog("updateSize surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) win=\(win)") } #endif if scaleChanged { ghostty_surface_set_content_scale(surface, xScale, yScale) lastXScale = xScale lastYScale = yScale } if sizeChanged { ghostty_surface_set_size(surface, wpx, hpx) lastPixelWidth = wpx lastPixelHeight = hpx } // Let Ghostty continue rendering on its own wakeups for steady-state frames. } /// Force a full size recalculation and surface redraw. func forceRefresh() { let hasSurface = surface != nil let viewState: String if let view = attachedView { let inWindow = view.window != nil let bounds = view.bounds let metalOK = (view.layer as? CAMetalLayer) != nil viewState = "inWindow=\(inWindow) bounds=\(bounds) metalOK=\(metalOK) hasSurface=\(hasSurface)" } else { viewState = "NO_ATTACHED_VIEW hasSurface=\(hasSurface)" } #if DEBUG let ts = ISO8601DateFormatter().string(from: Date()) let line = "[\(ts)] forceRefresh: \(id) \(viewState)\n" let logPath = "/tmp/cmux-refresh-debug.log" if let handle = FileHandle(forWritingAtPath: logPath) { handle.seekToEndOfFile() handle.write(line.data(using: .utf8)!) handle.closeFile() } else { FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8)) } #endif guard let view = attachedView, view.window != nil, view.bounds.width > 0, view.bounds.height > 0 else { return } guard let currentSurface = self.surface else { return } // Re-read self.surface before each ghostty call to guard against the surface // being freed during wake-from-sleep geometry reconciliation (issue #432). // The surface can be invalidated between calls when AppKit layout triggers // view lifecycle changes (e.g., forceRefreshSurface → layout → deinit → free). // Reassert display id on topology churn (split close/reparent) before forcing a refresh. // This avoids a first-run stuck-vsync state where Ghostty believes vsync is active // but callbacks have not resumed for the current display. if let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, displayID != 0 { ghostty_surface_set_display_id(currentSurface, displayID) } view.forceRefreshSurface() guard let surface = self.surface else { return } ghostty_surface_refresh(surface) } func applyWindowBackgroundIfActive() { surfaceView.applyWindowBackgroundIfActive() } func setFocus(_ focused: Bool) { guard let surface = surface else { return } ghostty_surface_set_focus(surface, focused) // If we focus a surface while it is being rapidly reparented (closing splits, etc), // Ghostty's CVDisplayLink can end up started before the display id is valid, leaving // hasVsync() true but with no callbacks ("stuck-vsync-no-frames"). Reasserting the // display id *after* focusing lets Ghostty restart the display link when needed. if focused { if let view = attachedView, let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, displayID != 0 { ghostty_surface_set_display_id(surface, displayID) } } } func setOcclusion(_ visible: Bool) { guard let surface = surface else { return } ghostty_surface_set_occlusion(surface, visible) } func needsConfirmClose() -> Bool { guard let surface = surface else { return false } return ghostty_surface_needs_confirm_quit(surface) } func sendText(_ text: String) { guard let data = text.data(using: .utf8), !data.isEmpty else { return } guard let surface = surface else { enqueuePendingText(data) return } writeTextData(data, to: surface) } func requestBackgroundSurfaceStartIfNeeded() { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.requestBackgroundSurfaceStartIfNeeded() } return } guard surface == nil, attachedView != nil else { return } guard !backgroundSurfaceStartQueued else { return } backgroundSurfaceStartQueued = true DispatchQueue.main.async { [weak self] in guard let self else { return } self.backgroundSurfaceStartQueued = false guard self.surface == nil, let view = self.attachedView else { return } #if DEBUG let startedAt = ProcessInfo.processInfo.systemUptime #endif self.createSurface(for: view) #if DEBUG let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0 dlog( "surface.background_start surface=\(self.id.uuidString.prefix(8)) inWindow=\(view.window != nil ? 1 : 0) ready=\(self.surface != nil ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs))" ) #endif } } private func writeTextData(_ data: Data, to surface: ghostty_surface_t) { data.withUnsafeBytes { rawBuffer in guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } ghostty_surface_text(surface, baseAddress, UInt(rawBuffer.count)) } } private func enqueuePendingText(_ data: Data) { let incomingBytes = data.count while !pendingTextQueue.isEmpty && pendingTextBytes + incomingBytes > maxPendingTextBytes { let dropped = pendingTextQueue.removeFirst() pendingTextBytes -= dropped.count } pendingTextQueue.append(data) pendingTextBytes += incomingBytes #if DEBUG dlog( "surface.send_text.queue surface=\(id.uuidString.prefix(8)) chunks=\(pendingTextQueue.count) bytes=\(pendingTextBytes)" ) #endif } private func flushPendingTextIfNeeded() { guard let surface = surface, !pendingTextQueue.isEmpty else { return } let queued = pendingTextQueue let queuedBytes = pendingTextBytes pendingTextQueue.removeAll(keepingCapacity: false) pendingTextBytes = 0 for chunk in queued { writeTextData(chunk, to: surface) } #if DEBUG dlog( "surface.send_text.flush surface=\(id.uuidString.prefix(8)) chunks=\(queued.count) bytes=\(queuedBytes)" ) #endif } func performBindingAction(_ action: String) -> Bool { guard let surface = surface else { return false } return action.withCString { cString in ghostty_surface_binding_action(surface, cString, UInt(strlen(cString))) } } @discardableResult func toggleKeyboardCopyMode() -> Bool { let handled = surfaceView.toggleKeyboardCopyMode() if handled { setKeyboardCopyModeActive(surfaceView.isKeyboardCopyModeActive) } return handled } func setKeyboardCopyModeActive(_ active: Bool) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.setKeyboardCopyModeActive(active) } return } if keyboardCopyModeActive != active { keyboardCopyModeActive = active } hostedView.setKeyboardCopyModeIndicator(visible: active) } func hasSelection() -> Bool { guard let surface = surface else { return false } return ghostty_surface_has_selection(surface) } #if DEBUG /// Test-only helper to deterministically simulate a released runtime surface. @MainActor func releaseSurfaceForTesting() { let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil guard let surfaceToFree = surface else { callbackContext?.release() return } surface = nil ghostty_surface_free(surfaceToFree) callbackContext?.release() } #endif deinit { markPortalLifecycleClosed(reason: "deinit") let callbackContext = surfaceCallbackContext surfaceCallbackContext = nil // Nil out the surface pointer so any in-flight closures (e.g. geometry // reconcile dispatched via DispatchQueue.main.async) that read self.surface // before this object is fully deallocated will see nil and bail out, // rather than passing a freed pointer to ghostty_surface_refresh (#432). let surfaceToFree = surface surface = nil guard let surfaceToFree else { #if DEBUG dlog( "surface.lifecycle.deinit.skip surface=\(id.uuidString.prefix(5)) " + "workspace=\(tabId.uuidString.prefix(5)) reason=noRuntimeSurface" ) #endif callbackContext?.release() return } #if DEBUG let surfaceToken = String(id.uuidString.prefix(5)) let workspaceToken = String(tabId.uuidString.prefix(5)) dlog( "surface.lifecycle.deinit.begin surface=\(surfaceToken) " + "workspace=\(workspaceToken) hasAttachedView=\(attachedView != nil ? 1 : 0) " + "hostedInWindow=\(hostedView.window != nil ? 1 : 0)" ) #endif // Keep teardown asynchronous to avoid re-entrant close/deinit loops, but retain // callback userdata until surface free completes so callbacks never dereference // a deallocated view pointer. Task { @MainActor in ghostty_surface_free(surfaceToFree) callbackContext?.release() #if DEBUG dlog( "surface.lifecycle.deinit.end surface=\(surfaceToken) " + "workspace=\(workspaceToken) freed=1" ) #endif } } } // MARK: - Ghostty Surface View class GhosttyNSView: NSView, NSUserInterfaceValidations { private static let focusDebugEnabled: Bool = { if ProcessInfo.processInfo.environment["CMUX_FOCUS_DEBUG"] == "1" { return true } return UserDefaults.standard.bool(forKey: "cmuxFocusDebug") }() private static let dropTypes: Set = [ .string, .fileURL, .URL ] private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t" fileprivate static func focusLog(_ message: String) { guard focusDebugEnabled else { return } FocusLogStore.shared.append(message) NSLog("[FOCUSDBG] %@", message) } weak var terminalSurface: TerminalSurface? var scrollbar: GhosttyScrollbar? var cellSize: CGSize = .zero var desiredFocus: Bool = false var suppressingReparentFocus: Bool = false var tabId: UUID? var onFocus: (() -> Void)? var onTriggerFlash: (() -> Void)? var backgroundColor: NSColor? private var appliedColorScheme: ghostty_color_scheme_e? private var lastLoggedSurfaceBackgroundSignature: String? private var lastLoggedWindowBackgroundSignature: String? private var keySequence: [ghostty_input_trigger_s] = [] private var keyTables: [String] = [] fileprivate private(set) var keyboardCopyModeActive = false private var keyboardCopyModeConsumedKeyUps: Set = [] private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState() private var keyboardCopyModeViewportRow: Int? fileprivate var isKeyboardCopyModeActive: Bool { keyboardCopyModeActive } #if DEBUG private static let keyLatencyProbeEnabled: Bool = { if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" { return true } return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe") }() static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)? #endif private var eventMonitor: Any? private var trackingArea: NSTrackingArea? private var windowObserver: NSObjectProtocol? private var lastScrollEventTime: CFTimeInterval = 0 private var visibleInUI: Bool = true private var pendingSurfaceSize: CGSize? #if DEBUG private var lastSizeSkipSignature: String? #endif private var hasUsableFocusGeometry: Bool { bounds.width > 1 && bounds.height > 1 } static func shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: Bool, pressedMouseButtons: Int, appIsActive: Bool, windowIsKey: Bool, alreadyFirstResponder: Bool, visibleInUI: Bool, hasUsableGeometry: Bool, hiddenInHierarchy: Bool ) -> Bool { guard focusFollowsMouseEnabled else { return false } guard pressedMouseButtons == 0 else { return false } guard appIsActive, windowIsKey else { return false } guard !alreadyFirstResponder else { return false } guard visibleInUI, hasUsableGeometry, !hiddenInHierarchy else { return false } return true } // Visibility is used for focus gating, not for libghostty occlusion. fileprivate var isVisibleInUI: Bool { visibleInUI } fileprivate func setVisibleInUI(_ visible: Bool) { visibleInUI = visible } override init(frame frameRect: NSRect) { super.init(frame: frameRect) setup() } required init?(coder: NSCoder) { super.init(coder: coder) setup() } private func setup() { // Only enable our instrumented CAMetalLayer in targeted debug/test scenarios. // The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs. wantsLayer = true layer?.masksToBounds = true installEventMonitor() updateTrackingAreas() registerForDraggedTypes(Array(Self.dropTypes)) } private func effectiveBackgroundColor() -> NSColor { let base = backgroundColor ?? GhosttyApp.shared.defaultBackgroundColor let opacity = GhosttyApp.shared.defaultBackgroundOpacity return base.withAlphaComponent(opacity) } func applySurfaceBackground() { let color = effectiveBackgroundColor() if let layer { CATransaction.begin() CATransaction.setDisableActions(true) // GhosttySurfaceScrollView owns the panel background fill. Keeping this layer clear // avoids stacking multiple identical translucent backgrounds (which looks opaque). layer.backgroundColor = NSColor.clear.cgColor layer.isOpaque = false CATransaction.commit() } terminalSurface?.hostedView.setBackgroundColor(color) if GhosttyApp.shared.backgroundLogEnabled { let signature = "\(color.hexString()):\(String(format: "%.3f", color.alphaComponent))" if signature != lastLoggedSurfaceBackgroundSignature { lastLoggedSurfaceBackgroundSignature = signature let hasOverride = backgroundColor != nil let overrideHex = backgroundColor?.hexString() ?? "nil" let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString() let source = hasOverride ? "surfaceOverride" : "defaultBackground" GhosttyApp.shared.logBackground( "surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" ) } } } func applyWindowBackgroundIfActive() { guard let window else { return } if let tabId, let selectedId = AppDelegate.shared?.tabManager?.selectedTabId, tabId != selectedId { return } applySurfaceBackground() let color = effectiveBackgroundColor() if cmuxShouldUseClearWindowBackground(for: color.alphaComponent) { window.backgroundColor = cmuxTransparentWindowBaseColor() window.isOpaque = false } else { window.backgroundColor = color window.isOpaque = color.alphaComponent >= 1.0 } if GhosttyApp.shared.backgroundLogEnabled { let signature = "\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent) ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent))" if signature != lastLoggedWindowBackgroundSignature { lastLoggedWindowBackgroundSignature = signature let hasOverride = backgroundColor != nil let overrideHex = backgroundColor?.hexString() ?? "nil" let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString() let source = hasOverride ? "surfaceOverride" : "defaultBackground" GhosttyApp.shared.logBackground( "window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) transparent=\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent)) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))" ) } } } private func installEventMonitor() { guard eventMonitor == nil else { return } eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in return self?.localEventHandler(event) ?? event } } private func localEventHandler(_ event: NSEvent) -> NSEvent? { switch event.type { case .scrollWheel: return localEventScrollWheel(event) default: return event } } private func localEventScrollWheel(_ event: NSEvent) -> NSEvent? { guard let window, let eventWindow = event.window, window == eventWindow else { return event } let location = convert(event.locationInWindow, from: nil) guard hitTest(location) == self else { return event } Self.focusLog("localEventScrollWheel: window=\(ObjectIdentifier(window)) firstResponder=\(String(describing: window.firstResponder))") return event } func attachSurface(_ surface: TerminalSurface) { appliedColorScheme = nil terminalSurface = surface tabId = surface.tabId surface.attachToView(self) surface.setKeyboardCopyModeActive(keyboardCopyModeActive) updateSurfaceSize() applySurfaceBackground() applySurfaceColorScheme(force: true) } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) self.windowObserver = nil } #if DEBUG dlog( "surface.view.windowMove surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "inWindow=\(window != nil ? 1 : 0) bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " + "pending=\(String(format: "%.1fx%.1f", pendingSurfaceSize?.width ?? 0, pendingSurfaceSize?.height ?? 0))" ) #endif guard let window else { return } // If the surface creation was deferred while detached, create/attach it now. terminalSurface?.attachToView(self) windowObserver = NotificationCenter.default.addObserver( forName: NSWindow.didChangeScreenNotification, object: window, queue: .main ) { [weak self] notification in self?.windowDidChangeScreen(notification) } if let surface = terminalSurface?.surface, let displayID = window.screen?.displayID, displayID != 0 { ghostty_surface_set_display_id(surface, displayID) } // Recompute from current bounds after layout. Pending size is only a fallback // when we don't have usable bounds (e.g. detached/off-window transitions). superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() updateSurfaceSize() applySurfaceBackground() applySurfaceColorScheme(force: true) GhosttyApp.shared.synchronizeThemeWithAppearance( effectiveAppearance, source: "surface.viewDidMoveToWindow" ) applyWindowBackgroundIfActive() } override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() if GhosttyApp.shared.backgroundLogEnabled { let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) GhosttyApp.shared.logBackground( "surface appearance changed tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil")" ) } applySurfaceColorScheme() GhosttyApp.shared.synchronizeThemeWithAppearance( effectiveAppearance, source: "surface.viewDidChangeEffectiveAppearance" ) } fileprivate func updateOcclusionState() { // Intentionally no-op: we don't drive libghostty occlusion from AppKit occlusion state. // This avoids transient clears during reparenting and keeps rendering logic minimal. } override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() if let window { CATransaction.begin() CATransaction.setDisableActions(true) layer?.contentsScale = window.backingScaleFactor CATransaction.commit() } updateSurfaceSize() } override func layout() { super.layout() updateSurfaceSize() } override var isOpaque: Bool { false } private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize { if let size, size.width > 0, size.height > 0 { return size } let currentBounds = bounds.size if currentBounds.width > 0, currentBounds.height > 0 { return currentBounds } if let pending = pendingSurfaceSize, pending.width > 0, pending.height > 0 { return pending } return currentBounds } private func updateSurfaceSize(size: CGSize? = nil) { guard let terminalSurface = terminalSurface else { return } let size = resolvedSurfaceSize(preferred: size) guard size.width > 0 && size.height > 0 else { #if DEBUG let signature = "nonPositive-\(Int(size.width))x\(Int(size.height))" if lastSizeSkipSignature != signature { dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=nonPositive size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "inWindow=\(window != nil ? 1 : 0)" ) lastSizeSkipSignature = signature } #endif return } pendingSurfaceSize = size guard let window else { #if DEBUG let signature = "noWindow-\(Int(size.width))x\(Int(size.height))" if lastSizeSkipSignature != signature { dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=noWindow " + "size=\(String(format: "%.1fx%.1f", size.width, size.height))" ) lastSizeSkipSignature = signature } #endif return } // First principles: derive pixel size from AppKit's backing conversion for the current // window/screen. Avoid updating Ghostty while detached from a window. let backingSize = convertToBacking(NSRect(origin: .zero, size: size)).size guard backingSize.width > 0, backingSize.height > 0 else { #if DEBUG let signature = "zeroBacking-\(Int(backingSize.width))x\(Int(backingSize.height))" if lastSizeSkipSignature != signature { dlog( "surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=zeroBacking " + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "backing=\(String(format: "%.1fx%.1f", backingSize.width, backingSize.height))" ) lastSizeSkipSignature = signature } #endif return } #if DEBUG if lastSizeSkipSignature != nil { dlog( "surface.size.resume surface=\(terminalSurface.id.uuidString.prefix(5)) " + "size=\(String(format: "%.1fx%.1f", size.width, size.height)) " + "backing=\(String(format: "%.1fx%.1f", backingSize.width, backingSize.height))" ) lastSizeSkipSignature = nil } #endif let xScale = backingSize.width / size.width let yScale = backingSize.height / size.height let layerScale = max(1.0, window.backingScaleFactor) let drawablePixelSize = CGSize( width: floor(max(0, backingSize.width)), height: floor(max(0, backingSize.height)) ) CATransaction.begin() CATransaction.setDisableActions(true) layer?.contentsScale = layerScale layer?.masksToBounds = true if let metalLayer = layer as? CAMetalLayer { metalLayer.drawableSize = drawablePixelSize } CATransaction.commit() terminalSurface.updateSize( width: size.width, height: size.height, xScale: xScale, yScale: yScale, layerScale: layerScale, backingSize: backingSize ) } fileprivate func pushTargetSurfaceSize(_ size: CGSize) { updateSurfaceSize(size: size) } /// Force a full size recalculation and Metal layer refresh. /// Resets cached metrics so updateSurfaceSize() re-runs unconditionally. func forceRefreshSurface() { updateSurfaceSize() } private func nearlyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool { abs(lhs - rhs) <= epsilon } func expectedPixelSize(for pointsSize: CGSize) -> CGSize { let backing = convertToBacking(NSRect(origin: .zero, size: pointsSize)).size if backing.width > 0, backing.height > 0 { return backing } let scale = max(1.0, window?.backingScaleFactor ?? layer?.contentsScale ?? 1.0) return CGSize(width: pointsSize.width * scale, height: pointsSize.height * scale) } // Convenience accessor for the ghostty surface private var surface: ghostty_surface_t? { terminalSurface?.surface } private func applySurfaceColorScheme(force: Bool = false) { guard let surface else { return } let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) let scheme: ghostty_color_scheme_e = bestMatch == .darkAqua ? GHOSTTY_COLOR_SCHEME_DARK : GHOSTTY_COLOR_SCHEME_LIGHT if !force, appliedColorScheme == scheme { if GhosttyApp.shared.backgroundLogEnabled { let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" GhosttyApp.shared.logBackground( "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=false" ) } return } ghostty_surface_set_color_scheme(surface, scheme) appliedColorScheme = scheme if GhosttyApp.shared.backgroundLogEnabled { let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light" GhosttyApp.shared.logBackground( "surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=true" ) } } @discardableResult private func ensureSurfaceReadyForInput() -> ghostty_surface_t? { if let surface = surface { return surface } guard window != nil else { return nil } terminalSurface?.attachToView(self) updateSurfaceSize(size: bounds.size) applySurfaceColorScheme(force: true) return surface } func performBindingAction(_ action: String) -> Bool { guard let surface = surface else { return false } return action.withCString { cString in ghostty_surface_binding_action(surface, cString, UInt(strlen(cString))) } } @discardableResult func toggleKeyboardCopyMode() -> Bool { guard surface != nil else { return false } setKeyboardCopyModeActive(!keyboardCopyModeActive) if !keyboardCopyModeActive, let surface { _ = ghostty_surface_clear_selection(surface) } return true } private func setKeyboardCopyModeActive(_ active: Bool) { keyboardCopyModeInputState.reset() keyboardCopyModeActive = active if active, let surface { keyboardCopyModeViewportRow = keyboardCopyModeSelectionAnchor(surface: surface)?.row _ = ghostty_surface_clear_selection(surface) if keyboardCopyModeViewportRow == nil { keyboardCopyModeViewportRow = keyboardCopyModeImeViewportRow(surface: surface) } } else { keyboardCopyModeViewportRow = nil } terminalSurface?.setKeyboardCopyModeActive(active) } private func performBindingAction(_ action: String, repeatCount: Int) { let count = terminalKeyboardCopyModeClampCount(repeatCount) for _ in 0 ..< count { _ = performBindingAction(action) } } private func currentKeyboardCopyModeViewportRow(surface: ghostty_surface_t) -> Int { let rows = max(Int(ghostty_surface_size(surface).rows), 1) let fallback = rows - 1 return max(0, min(rows - 1, keyboardCopyModeViewportRow ?? fallback)) } private func keyboardCopyModeImeViewportRow(surface: ghostty_surface_t) -> Int { let rows = max(Int(ghostty_surface_size(surface).rows), 1) var x: Double = 0 var y: Double = 0 var width: Double = 0 var height: Double = 0 ghostty_surface_ime_point(surface, &x, &y, &width, &height) return terminalKeyboardCopyModeInitialViewportRow( rows: rows, imePointY: y, imeCellHeight: height ) } private func keyboardCopyModeSelectionAnchor(surface: ghostty_surface_t) -> (row: Int, y: Double)? { let size = ghostty_surface_size(surface) guard size.rows > 0, size.columns > 0 else { return nil } guard ghostty_surface_select_cursor_cell(surface) else { return nil } var text = ghostty_text_s() guard ghostty_surface_read_selection(surface, &text) else { return nil } defer { ghostty_surface_free_text(surface, &text) } let rows = max(Int(size.rows), 1) let cols = max(Int(size.columns), 1) let rawRow = Int(text.offset_start) / cols let clampedRow = max(0, min(rows - 1, rawRow)) return (row: clampedRow, y: text.tl_px_y) } private func refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: ghostty_surface_t) { guard !ghostty_surface_has_selection(surface) else { return } guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return } keyboardCopyModeViewportRow = anchor.row _ = ghostty_surface_clear_selection(surface) } private func copyCurrentViewportLinesToClipboard( surface: ghostty_surface_t, startRow: Int, lineCount: Int ) -> Bool { let clampedCount = terminalKeyboardCopyModeClampCount(lineCount) let rows = max(Int(ghostty_surface_size(surface).rows), 1) let targetRow = max(0, min(rows - 1, startRow)) let endRow = min(rows - 1, targetRow + clampedCount - 1) guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return false } _ = ghostty_surface_clear_selection(surface) var imeX: Double = 0 var imeY: Double = 0 var imeWidth: Double = 0 var imeHeight: Double = 0 ghostty_surface_ime_point(surface, &imeX, &imeY, &imeWidth, &imeHeight) let cellHeight = imeHeight > 0 ? imeHeight : max(bounds.height / Double(rows), 1) let yMax = max(bounds.height - 1, 0) let startRawY = anchor.y + (Double(targetRow - anchor.row) * cellHeight) let endRawY = anchor.y + (Double(endRow - anchor.row) * cellHeight) let startY = max(0, min(startRawY, yMax)) let endY = max(0, min(endRawY, yMax)) let xMax = max(bounds.width - 1, 0) let startX = min(1, xMax) let endX = xMax let mods = ghostty_input_mods_e(rawValue: GHOSTTY_MODS_NONE.rawValue) ?? GHOSTTY_MODS_NONE ghostty_surface_mouse_pos(surface, startX, startY, mods) guard ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else { return false } defer { _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) } ghostty_surface_mouse_pos(surface, endX, endY, mods) guard ghostty_surface_has_selection(surface) else { return false } return performBindingAction("copy_to_clipboard") } private func handleKeyboardCopyModeIfNeeded(_ event: NSEvent, surface: ghostty_surface_t) -> Bool { guard keyboardCopyModeActive else { return false } if terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: event.modifierFlags) { keyboardCopyModeInputState.reset() return false } let hasSelection = ghostty_surface_has_selection(surface) let resolution = terminalKeyboardCopyModeResolve( keyCode: event.keyCode, charactersIgnoringModifiers: event.charactersIgnoringModifiers, modifierFlags: event.modifierFlags, hasSelection: hasSelection, state: &keyboardCopyModeInputState ) guard case let .perform(action, count) = resolution else { return true } switch action { case .exit: _ = ghostty_surface_clear_selection(surface) setKeyboardCopyModeActive(false) case .startSelection: _ = ghostty_surface_select_cursor_cell(surface) case .clearSelection: _ = ghostty_surface_clear_selection(surface) case .copyAndExit: _ = performBindingAction("copy_to_clipboard") _ = ghostty_surface_clear_selection(surface) setKeyboardCopyModeActive(false) case .copyLineAndExit: let startRow = currentKeyboardCopyModeViewportRow(surface: surface) _ = copyCurrentViewportLinesToClipboard( surface: surface, startRow: startRow, lineCount: count ) _ = ghostty_surface_clear_selection(surface) setKeyboardCopyModeActive(false) case let .scrollLines(delta): _ = performBindingAction("scroll_page_lines:\(delta * count)") refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .scrollPage(delta): performBindingAction(delta > 0 ? "scroll_page_down" : "scroll_page_up", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .scrollToTop: keyboardCopyModeViewportRow = 0 _ = performBindingAction("scroll_to_top") case .scrollToBottom: keyboardCopyModeViewportRow = max(Int(ghostty_surface_size(surface).rows) - 1, 0) _ = performBindingAction("scroll_to_bottom") case let .jumpToPrompt(delta): _ = performBindingAction("jump_to_prompt:\(delta * count)") refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .startSearch: _ = performBindingAction("start_search") case .searchNext: performBindingAction("navigate_search:next", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case .searchPrevious: performBindingAction("navigate_search:previous", repeatCount: count) refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface) case let .adjustSelection(direction): performBindingAction("adjust_selection:\(direction.rawValue)", repeatCount: count) } return true } // MARK: - Input Handling @IBAction func copy(_ sender: Any?) { _ = performBindingAction("copy_to_clipboard") } // MARK: - Clipboard image paste private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB /// Quick check: does the clipboard have image data and no text? private static func clipboardHasImageOnly() -> Bool { let pb = NSPasteboard.general let types = pb.types ?? [] let hasText = types.contains(.string) || types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) if hasText { return false } return types.contains(.tiff) || types.contains(.png) } /// When the clipboard contains only image data (no text/HTML), saves it as /// a temporary PNG file and returns the file path. Returns nil if the /// clipboard contains text or no image. private static func saveClipboardImageIfNeeded() -> String? { let pb = NSPasteboard.general let types = pb.types ?? [] // If pasteboard has text/HTML, this is a normal copy — let Ghostty handle it. let hasText = types.contains(.string) || types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) if hasText { return nil } // Check for image types (TIFF from screenshots, PNG from some tools). guard types.contains(.tiff) || types.contains(.png) else { return nil } guard let image = NSImage(pasteboard: pb), let tiffData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiffData), let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil } guard pngData.count <= maxClipboardImageSize else { #if DEBUG dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(pngData.count)") #endif return nil } let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd-HHmmss" formatter.locale = Locale(identifier: "en_US_POSIX") let timestamp = formatter.string(from: Date()) let filename = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).png" let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename) do { try pngData.write(to: URL(fileURLWithPath: path)) } catch { #if DEBUG dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)") #endif return nil } return path } /// Pastes clipboard content into the terminal. If the clipboard contains only /// image data, saves it as a temporary PNG and pastes the shell-escaped file path. @IBAction func paste(_ sender: Any?) { // When the clipboard contains only image data (e.g. from Cmd+Ctrl+Shift+4 // screenshot), save it as a temporary PNG and paste the file path so that // CLI tools like Claude Code can accept the image. if let path = Self.saveClipboardImageIfNeeded() { #if DEBUG dlog("terminal.paste.image path=\(path)") #endif terminalSurface?.sendText(Self.escapeDropForShell(path)) return } _ = performBindingAction("paste_from_clipboard") } /// Pastes clipboard text as plain text, stripping any rich formatting. @IBAction func pasteAsPlainText(_ sender: Any?) { _ = performBindingAction("paste_from_clipboard") } /// Validates whether edit menu items (copy, paste, split) should be enabled. func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { switch item.action { case #selector(copy(_:)): guard let surface = surface else { return false } return ghostty_surface_has_selection(surface) case #selector(paste(_:)): return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) || Self.clipboardHasImageOnly() case #selector(pasteAsPlainText(_:)): return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD) case #selector(splitHorizontally(_:)), #selector(splitVertically(_:)): return canSplitCurrentSurface() default: return true } } override var acceptsFirstResponder: Bool { true } override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() var shouldApplySurfaceFocus = false if result { // If we become first responder before the ghostty surface exists (e.g. during // split/tab creation while the surface is still being created), record the desired focus. desiredFocus = true // During programmatic splits, SwiftUI reparents the old NSView which triggers // becomeFirstResponder. Suppress onFocus + ghostty_surface_set_focus to prevent // the old view from stealing focus and creating model/surface divergence. if suppressingReparentFocus { #if DEBUG dlog("focus.firstResponder SUPPRESSED (reparent) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif return result } // Always notify the host app that this pane became the first responder so bonsplit // focus/selection can converge. Previously this was gated on `surface != nil`, which // allowed a mismatch where AppKit focus moved but the UI focus indicator (bonsplit) // stayed behind. let hiddenInHierarchy = isHiddenOrHasHiddenAncestor if isVisibleInUI && hasUsableFocusGeometry && !hiddenInHierarchy { shouldApplySurfaceFocus = true onFocus?() } else if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) { #if DEBUG dlog( "focus.firstResponder SUPPRESSED (hidden_or_tiny) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) hidden=\(hiddenInHierarchy ? 1 : 0)" ) #endif } } if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput() { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") #if DEBUG dlog("focus.firstResponder surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") if let terminalSurface { AppDelegate.shared?.recordJumpUnreadFocusIfExpected( tabId: terminalSurface.tabId, surfaceId: terminalSurface.id ) } #endif if let terminalSurface { NotificationCenter.default.post( name: .ghosttyDidBecomeFirstResponderSurface, object: nil, userInfo: [ GhosttyNotificationKey.tabId: terminalSurface.tabId, GhosttyNotificationKey.surfaceId: terminalSurface.id, ] ) } ghostty_surface_set_focus(surface, true) // Ghostty only restarts its vsync display link on display-id changes while focused. // During rapid split close / SwiftUI reparenting, the view can reattach to a window // and get its display id set *before* it becomes first responder; in that case, the // renderer can remain stuck until some later screen/focus transition. Reassert the // display id now that we're focused to ensure the renderer is running. if let displayID = window?.screen?.displayID, displayID != 0 { ghostty_surface_set_display_id(surface, displayID) } } return result } override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() if result { desiredFocus = false } if result, let surface = surface { let now = CACurrentMediaTime() let deltaMs = (now - lastScrollEventTime) * 1000 Self.focusLog("resignFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))") ghostty_surface_set_focus(surface, false) } return result } // For NSTextInputClient - accumulates text during key events private var keyTextAccumulator: [String]? = nil private var markedText = NSMutableAttributedString() private var lastPerformKeyEvent: TimeInterval? #if DEBUG // Test-only accessors for keyTextAccumulator to verify CJK IME composition behavior. func setKeyTextAccumulatorForTesting(_ value: [String]?) { keyTextAccumulator = value } var keyTextAccumulatorForTesting: [String]? { keyTextAccumulator } func shouldSuppressShiftSpaceFallbackTextForTesting(event: NSEvent, markedTextBefore: Bool) -> Bool { shouldSuppressShiftSpaceFallbackText(event: event, markedTextBefore: markedTextBefore) } // Test-only IME point override so firstRect behavior can be regression tested. private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) { imePointOverrideForTesting = (x, y, width, height) } func clearIMEPointForTesting() { imePointOverrideForTesting = nil } #endif #if DEBUG private func recordKeyLatency(path: String, event: NSEvent) { guard Self.keyLatencyProbeEnabled else { return } guard event.timestamp > 0 else { return } let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000) let delayText = String(format: "%.2f", delayMs) dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)") } #endif // Prevents NSBeep for unimplemented actions from interpretKeyEvents override func doCommand(by selector: Selector) { // Intentionally empty - prevents system beep on unhandled key commands } override func performKeyEquivalent(with event: NSEvent) -> Bool { guard event.type == .keyDown else { return false } guard let fr = window?.firstResponder as? NSView, fr === self || fr.isDescendant(of: self) else { return false } guard let surface = ensureSurfaceReadyForInput() else { return false } // If the IME is composing (marked text present) and the key has no Cmd // modifier, don't intercept — let it flow through to keyDown so the input // method can process it normally. Cmd-based shortcuts should still work // during composition since Cmd is never part of IME input sequences. if hasMarkedText(), !event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) { return false } #if DEBUG recordKeyLatency(path: "performKeyEquivalent", event: event) #endif #if DEBUG cmuxWriteChildExitProbe( [ "probePerformCharsHex": cmuxScalarHex(event.characters), "probePerformCharsIgnoringHex": cmuxScalarHex(event.charactersIgnoringModifiers), "probePerformKeyCode": String(event.keyCode), "probePerformModsRaw": String(event.modifierFlags.rawValue), "probePerformSurfaceId": terminalSurface?.id.uuidString ?? "", ], increments: ["probePerformKeyEquivalentCount": 1] ) #endif // Check if this event matches a Ghostty keybinding. let bindingFlags: ghostty_binding_flags_e? = { var keyEvent = ghosttyKeyEvent(for: event, surface: surface) let text = event.characters ?? "" var flags = ghostty_binding_flags_e(0) let isBinding = text.withCString { ptr in keyEvent.text = ptr return ghostty_surface_key_is_binding(surface, keyEvent, &flags) } return isBinding ? flags : nil }() if let bindingFlags { let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0 let isAll = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0 let isPerformable = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0 // If the binding is consumed and not meant for the menu, allow menu first. if isConsumed && !isAll && !isPerformable && keySequence.isEmpty && keyTables.isEmpty { if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { return true } } keyDown(with: event) return true } let equivalent: String switch event.charactersIgnoringModifiers { case "\r": // Pass Ctrl+Return through verbatim (prevent context menu equivalent). guard event.modifierFlags.contains(.control) else { return false } equivalent = "\r" case "/": // Treat Ctrl+/ as Ctrl+_ to avoid the system beep. guard event.modifierFlags.contains(.control), event.modifierFlags.isDisjoint(with: [.shift, .command, .option]) else { return false } equivalent = "_" default: // Ignore synthetic events. if event.timestamp == 0 { return false } // Match AppKit key-equivalent routing for menu-style shortcuts (Command-modified). // Control-only terminal input (e.g. Ctrl+D) should not participate in redispatch; // it must flow through the normal keyDown path exactly once. if !event.modifierFlags.contains(.command) { lastPerformKeyEvent = nil return false } if let lastPerformKeyEvent { self.lastPerformKeyEvent = nil if lastPerformKeyEvent == event.timestamp { equivalent = event.characters ?? "" break } } lastPerformKeyEvent = event.timestamp return false } let finalEvent = NSEvent.keyEvent( with: .keyDown, location: event.locationInWindow, modifierFlags: event.modifierFlags, timestamp: event.timestamp, windowNumber: event.windowNumber, context: nil, characters: equivalent, charactersIgnoringModifiers: equivalent, isARepeat: event.isARepeat, keyCode: event.keyCode ) if let finalEvent { keyDown(with: finalEvent) return true } return false } override func keyDown(with event: NSEvent) { guard let surface = ensureSurfaceReadyForInput() else { super.keyDown(with: event) return } if handleKeyboardCopyModeIfNeeded(event, surface: surface) { keyboardCopyModeConsumedKeyUps.insert(event.keyCode) return } #if DEBUG recordKeyLatency(path: "keyDown", event: event) #endif #if DEBUG cmuxWriteChildExitProbe( [ "probeKeyDownCharsHex": cmuxScalarHex(event.characters), "probeKeyDownCharsIgnoringHex": cmuxScalarHex(event.charactersIgnoringModifiers), "probeKeyDownKeyCode": String(event.keyCode), "probeKeyDownModsRaw": String(event.modifierFlags.rawValue), "probeKeyDownSurfaceId": terminalSurface?.id.uuidString ?? "", ], increments: ["probeKeyDownCount": 1] ) #endif // Fast path for control-modified terminal input (for example Ctrl+D). // // These keys are terminal control input, not text composition, so we bypass // AppKit text interpretation and send a single deterministic Ghostty key event. // This avoids intermittent drops after rapid split close/reparent transitions. let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) if flags.contains(.control) && !flags.contains(.command) && !flags.contains(.option) { ghostty_surface_set_focus(surface, true) var keyEvent = ghostty_input_key_s() keyEvent.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.composing = false keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) let text = (event.charactersIgnoringModifiers ?? event.characters ?? "") let handled: Bool if text.isEmpty { keyEvent.text = nil handled = ghostty_surface_key(surface, keyEvent) } else { handled = text.withCString { ptr in keyEvent.text = ptr return ghostty_surface_key(surface, keyEvent) } } #if DEBUG dlog( "key.ctrl path=ghostty surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "handled=\(handled ? 1 : 0) keyCode=\(event.keyCode) chars=\(cmuxScalarHex(event.characters)) " + "ign=\(cmuxScalarHex(event.charactersIgnoringModifiers)) mods=\(event.modifierFlags.rawValue)" ) #endif // If Ghostty handled the key (action/encoding), we're done. // If not (e.g. `ignore` keybind), fall through to interpretKeyEvents // so the IME gets a chance to process this event. if handled { return } } let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS // Translate mods to respect Ghostty config (e.g., macos-option-as-alt) let translationModsGhostty = ghostty_surface_key_translation_mods(surface, modsFromEvent(event)) var translationMods = event.modifierFlags for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { let hasFlag: Bool switch flag { case .shift: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0 case .control: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0 case .option: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0 case .command: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0 default: hasFlag = translationMods.contains(flag) } if hasFlag { translationMods.insert(flag) } else { translationMods.remove(flag) } } let translationEvent: NSEvent if translationMods == event.modifierFlags { translationEvent = event } else { translationEvent = NSEvent.keyEvent( with: event.type, location: event.locationInWindow, modifierFlags: translationMods, timestamp: event.timestamp, windowNumber: event.windowNumber, context: nil, characters: event.characters(byApplyingModifiers: translationMods) ?? "", charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", isARepeat: event.isARepeat, keyCode: event.keyCode ) ?? event } // Set up text accumulator for interpretKeyEvents keyTextAccumulator = [] defer { keyTextAccumulator = nil } // Track whether we had marked text (IME preedit) before this event, // so we can detect when composition ends. let markedTextBefore = markedText.length > 0 // Capture the keyboard layout ID before interpretation so we can // detect if an IME changed it (e.g. toggling input methods). // We only check when not already in a preedit state. let keyboardIdBefore: String? = if (!markedTextBefore) { KeyboardLayout.id } else { nil } // Let the input system handle the event (for IME, dead keys, etc.) interpretKeyEvents([translationEvent]) // If the keyboard layout changed, an input method grabbed the event. // Sync preedit and return without sending the key to Ghostty. if !markedTextBefore, let kbBefore = keyboardIdBefore, kbBefore != KeyboardLayout.id { syncPreedit(clearIfNeeded: markedTextBefore) return } // Sync the preedit state with Ghostty so it can render the IME // composition overlay (e.g. for Korean, Japanese, Chinese input). syncPreedit(clearIfNeeded: markedTextBefore) // Build the key event var keyEvent = ghostty_input_key_s() keyEvent.action = action keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) // Control and Command never contribute to text translation keyEvent.consumed_mods = consumedModsFromFlags(translationMods) keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) // We're composing if we have preedit (the obvious case). But we're also // composing if we don't have preedit and we had marked text before, // because this input probably just reset the preedit state. It shouldn't // be encoded. Example: Japanese begin composing, then press backspace. // This should only cancel the composing state but not actually delete // the prior input characters (prior to the composing). keyEvent.composing = markedText.length > 0 || markedTextBefore // Use accumulated text from insertText (for IME), or compute text for key let accumulatedText = keyTextAccumulator ?? [] if !accumulatedText.isEmpty { // Accumulated text comes from insertText (IME composition result). // These never have "composing" set to true because these are the // result of a composition. keyEvent.composing = false for text in accumulatedText { if shouldSendText(text) { text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } } else { keyEvent.text = nil _ = ghostty_surface_key(surface, keyEvent) } } } else { // Get the appropriate text for this key event // For control characters, this returns the unmodified character // so Ghostty's KeyEncoder can handle ctrl encoding let suppressShiftSpaceFallbackText = shouldSuppressShiftSpaceFallbackText( event: translationEvent, markedTextBefore: markedTextBefore ) if let text = textForKeyEvent(translationEvent) { if shouldSendText(text), !suppressShiftSpaceFallbackText { text.withCString { ptr in keyEvent.text = ptr _ = ghostty_surface_key(surface, keyEvent) } } else { keyEvent.text = nil _ = ghostty_surface_key(surface, keyEvent) } } else { keyEvent.text = nil _ = ghostty_surface_key(surface, keyEvent) } } // Rendering is driven by Ghostty's wakeups/renderer. } @discardableResult private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool { #if DEBUG Self.debugGhosttySurfaceKeyEventObserver?(keyEvent) #endif return ghostty_surface_key(surface, keyEvent) } override func keyUp(with event: NSEvent) { guard let surface = ensureSurfaceReadyForInput() else { super.keyUp(with: event) return } if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil { return } // Build release events from the same translation path as keyDown so // consumers that depend on precise key identity (for example Space // hold/release flows) receive consistent metadata. var keyEvent = ghosttyKeyEvent(for: event, surface: surface) keyEvent.action = GHOSTTY_ACTION_RELEASE keyEvent.text = nil keyEvent.composing = false _ = sendGhosttyKey(surface, keyEvent) } override func flagsChanged(with event: NSEvent) { guard let surface = surface else { super.flagsChanged(with: event) return } var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil keyEvent.composing = false _ = ghostty_surface_key(surface, keyEvent) } private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e { var mods = GHOSTTY_MODS_NONE.rawValue if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } if event.modifierFlags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue } if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } if event.modifierFlags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue } return ghostty_input_mods_e(rawValue: mods) } /// Consumed mods are modifiers that were used for text translation. /// Control and Command never contribute to text translation, so they /// should be excluded from consumed_mods. private func consumedModsFromFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e { var mods = GHOSTTY_MODS_NONE.rawValue // Only include Shift and Option as potentially consumed // Control and Command are never consumed for text translation if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue } if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue } return ghostty_input_mods_e(rawValue: mods) } /// Get the characters for a key event with control character handling. /// When control is pressed, we get the character without the control modifier /// so Ghostty's KeyEncoder can apply its own control character encoding. private func textForKeyEvent(_ event: NSEvent) -> String? { guard let chars = event.characters, !chars.isEmpty else { return nil } if chars.count == 1, let scalar = chars.unicodeScalars.first { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) // If we have a single control character, return the character without // the control modifier so Ghostty's KeyEncoder can handle it. if scalar.value < 0x20 { if flags.contains(.control) { return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control)) } // Some AppKit key paths can report Shift+` as a bare ESC control // character even though the physical key should produce "~". if scalar.value == 0x1B, flags == [.shift], event.charactersIgnoringModifiers == "`" { return "~" } } // Private Use Area characters (function keys) should not be sent if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { return nil } } return chars } /// Get the unshifted codepoint for the key event private func unshiftedCodepointFromEvent(_ event: NSEvent) -> UInt32 { if let layoutChars = KeyboardLayout.character(forKeyCode: event.keyCode), layoutChars.count == 1, let layoutScalar = layoutChars.unicodeScalars.first, layoutScalar.value >= 0x20, !(layoutScalar.value >= 0xF700 && layoutScalar.value <= 0xF8FF) { return layoutScalar.value } guard let chars = (event.characters(byApplyingModifiers: []) ?? event.charactersIgnoringModifiers ?? event.characters), let scalar = chars.unicodeScalars.first else { return 0 } return scalar.value } private func shouldSendText(_ text: String) -> Bool { guard let first = text.utf8.first else { return false } return first >= 0x20 } /// If AppKit consumed Shift+Space for IME/input-source switching, interpretKeyEvents /// can return without insertText and without a detectable layout ID change. /// In that case we must not synthesize a literal space fallback. private func shouldSuppressShiftSpaceFallbackText(event: NSEvent, markedTextBefore: Bool) -> Bool { guard event.keyCode == 49 else { return false } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) guard flags == [.shift] else { return false } guard !markedTextBefore, markedText.length == 0 else { return false } return true } private func ghosttyKeyEvent(for event: NSEvent, surface: ghostty_surface_t) -> ghostty_input_key_s { var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = UInt32(event.keyCode) keyEvent.mods = modsFromEvent(event) // Translate mods to respect Ghostty config (e.g., macos-option-as-alt). let translationModsGhostty = ghostty_surface_key_translation_mods(surface, modsFromEvent(event)) var translationMods = event.modifierFlags for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { let hasFlag: Bool switch flag { case .shift: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0 case .control: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0 case .option: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0 case .command: hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0 default: hasFlag = translationMods.contains(flag) } if hasFlag { translationMods.insert(flag) } else { translationMods.remove(flag) } } keyEvent.consumed_mods = consumedModsFromFlags(translationMods) keyEvent.text = nil keyEvent.composing = false keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event) return keyEvent } func updateKeySequence(_ action: ghostty_action_key_sequence_s) { if action.active { keySequence.append(action.trigger) } else { keySequence.removeAll() } } func updateKeyTable(_ action: ghostty_action_key_table_s) { switch action.tag { case GHOSTTY_KEY_TABLE_ACTIVATE: let namePtr = action.value.activate.name let nameLen = Int(action.value.activate.len) if let namePtr, nameLen > 0 { let data = Data(bytes: namePtr, count: nameLen) if let name = String(data: data, encoding: .utf8) { keyTables.append(name) } } case GHOSTTY_KEY_TABLE_DEACTIVATE: _ = keyTables.popLast() case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL: keyTables.removeAll() default: break } } // MARK: - Mouse Handling #if DEBUG private func debugModifierString(_ flags: NSEvent.ModifierFlags) -> String { [ flags.contains(.command) ? "cmd" : nil, flags.contains(.shift) ? "shift" : nil, flags.contains(.control) ? "ctrl" : nil, flags.contains(.option) ? "opt" : nil, ].compactMap { $0 }.joined(separator: "+") } #endif override func mouseDown(with event: NSEvent) { #if DEBUG let debugPoint = convert(event.locationInWindow, from: nil) dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))") #endif window?.makeFirstResponder(self) guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) } override func mouseUp(with event: NSEvent) { #if DEBUG dlog("terminal.mouseUp surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))]") #endif guard let surface = surface else { return } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event)) } override func rightMouseDown(with event: NSEvent) { guard let surface = surface else { return } if !ghostty_surface_mouse_captured(surface) { super.rightMouseDown(with: event) return } window?.makeFirstResponder(self) let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) } override func rightMouseUp(with event: NSEvent) { guard let surface = surface else { return } if !ghostty_surface_mouse_captured(surface) { super.rightMouseUp(with: event) return } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) } override func otherMouseDown(with event: NSEvent) { guard event.buttonNumber == 2 else { super.otherMouseDown(with: event) return } window?.makeFirstResponder(self) guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, modsFromEvent(event)) } override func otherMouseUp(with event: NSEvent) { guard event.buttonNumber == 2 else { super.otherMouseUp(with: event) return } guard let surface = surface else { return } _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, modsFromEvent(event)) } override func menu(for event: NSEvent) -> NSMenu? { guard let surface = surface else { return nil } if ghostty_surface_mouse_captured(surface) { return nil } window?.makeFirstResponder(self) let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event)) let menu = NSMenu() if onTriggerFlash != nil { let flashItem = menu.addItem(withTitle: "Trigger Flash", action: #selector(triggerFlash(_:)), keyEquivalent: "") flashItem.target = self menu.addItem(.separator()) } if ghostty_surface_has_selection(surface) { let item = menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "") item.target = self } let pasteItem = menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "") pasteItem.target = self menu.addItem(.separator()) let splitHorizontallyItem = menu.addItem( withTitle: "Split Horizontally", action: #selector(splitHorizontally(_:)), keyEquivalent: "d" ) splitHorizontallyItem.target = self splitHorizontallyItem.keyEquivalentModifierMask = [.command, .shift] splitHorizontallyItem.image = NSImage( systemSymbolName: "rectangle.bottomhalf.inset.filled", accessibilityDescription: nil ) let splitVerticallyItem = menu.addItem( withTitle: "Split Vertically", action: #selector(splitVertically(_:)), keyEquivalent: "d" ) splitVerticallyItem.target = self splitVerticallyItem.keyEquivalentModifierMask = [.command] splitVerticallyItem.image = NSImage( systemSymbolName: "rectangle.righthalf.inset.filled", accessibilityDescription: nil ) return menu } private func canSplitCurrentSurface() -> Bool { guard let tabId, let surfaceId = terminalSurface?.id, let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager, let workspace = manager.tabs.first(where: { $0.id == tabId }) else { return false } return workspace.panels[surfaceId] != nil } @objc private func splitHorizontally(_ sender: Any?) { _ = splitCurrentSurface(direction: .down) } @objc private func splitVertically(_ sender: Any?) { _ = splitCurrentSurface(direction: .right) } @discardableResult private func splitCurrentSurface(direction: SplitDirection) -> Bool { guard let tabId, let surfaceId = terminalSurface?.id, let app = AppDelegate.shared, let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } return manager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil } @objc private func triggerFlash(_ sender: Any?) { onTriggerFlash?() } override func mouseMoved(with event: NSEvent) { maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) maybeRequestFirstResponderForMouseFocus() guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) } private func maybeRequestFirstResponderForMouseFocus() { guard let window else { return } let alreadyFirstResponder = window.firstResponder === self let shouldRequest = Self.shouldRequestFirstResponderForMouseFocus( focusFollowsMouseEnabled: GhosttyApp.shared.focusFollowsMouseEnabled(), pressedMouseButtons: NSEvent.pressedMouseButtons, appIsActive: NSApp.isActive, windowIsKey: window.isKeyWindow, alreadyFirstResponder: alreadyFirstResponder, visibleInUI: isVisibleInUI, hasUsableGeometry: hasUsableFocusGeometry, hiddenInHierarchy: isHiddenOrHasHiddenAncestor ) guard shouldRequest else { return } window.makeFirstResponder(self) } override func mouseExited(with event: NSEvent) { guard let surface = surface else { return } if NSEvent.pressedMouseButtons != 0 { return } ghostty_surface_mouse_pos(surface, -1, -1, modsFromEvent(event)) } override func mouseDragged(with event: NSEvent) { guard let surface = surface else { return } let point = convert(event.locationInWindow, from: nil) ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event)) } override func scrollWheel(with event: NSEvent) { guard let surface = surface else { return } lastScrollEventTime = CACurrentMediaTime() Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))") var x = event.scrollingDeltaX var y = event.scrollingDeltaY let precision = event.hasPreciseScrollingDeltas if precision { x *= 2 y *= 2 } var mods: Int32 = 0 if precision { mods |= 0b0000_0001 } let momentum: Int32 switch event.momentumPhase { case .began: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_BEGAN.rawValue) case .stationary: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_STATIONARY.rawValue) case .changed: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_CHANGED.rawValue) case .ended: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_ENDED.rawValue) case .cancelled: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_CANCELLED.rawValue) case .mayBegin: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN.rawValue) default: momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_NONE.rawValue) } mods |= momentum << 1 // Track scroll state for lag detection let hasMomentum = event.momentumPhase != [] && event.momentumPhase != .mayBegin let momentumEnded = event.momentumPhase == .ended || event.momentumPhase == .cancelled GhosttyApp.shared.markScrollActivity(hasMomentum: hasMomentum, momentumEnded: momentumEnded) ghostty_surface_mouse_scroll( surface, x, y, ghostty_input_scroll_mods_t(mods) ) } deinit { // Surface lifecycle is managed by TerminalSurface, not the view #if DEBUG dlog( "surface.view.deinit view=\(Unmanaged.passUnretained(self).toOpaque()) " + "surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0)" ) #endif if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } if let windowObserver { NotificationCenter.default.removeObserver(windowObserver) } terminalSurface = nil } override func updateTrackingAreas() { super.updateTrackingAreas() if let trackingArea { removeTrackingArea(trackingArea) } trackingArea = NSTrackingArea( rect: bounds, options: [ .mouseEnteredAndExited, .mouseMoved, .inVisibleRect, .activeAlways, ], owner: self, userInfo: nil ) if let trackingArea { addTrackingArea(trackingArea) } } private func windowDidChangeScreen(_ notification: Notification) { guard let window else { return } guard let object = notification.object as? NSWindow, window == object else { return } guard let screen = window.screen else { return } guard let surface = terminalSurface?.surface else { return } if let displayID = screen.displayID, displayID != 0 { ghostty_surface_set_display_id(surface, displayID) } DispatchQueue.main.async { [weak self] in self?.viewDidChangeBackingProperties() } } fileprivate static func escapeDropForShell(_ value: String) -> String { var result = value for char in shellEscapeCharacters { result = result.replacingOccurrences(of: String(char), with: "\\\(char)") } return result } private func droppedContent(from pasteboard: NSPasteboard) -> String? { if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty { return urls .map { Self.escapeDropForShell($0.path) } .joined(separator: " ") } if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty { return Self.escapeDropForShell(rawURL) } if let str = pasteboard.string(forType: .string), !str.isEmpty { return str } return nil } @discardableResult fileprivate func insertDroppedPasteboard(_ pasteboard: NSPasteboard) -> Bool { guard let content = droppedContent(from: pasteboard) else { return false } // Use the text/paste path (ghostty_surface_text) instead of the key event // path (ghostty_surface_key) so bracketed paste mode is triggered and the // insertion is instant, matching upstream Ghostty behaviour. terminalSurface?.sendText(content) return true } #if DEBUG @discardableResult fileprivate func debugSimulateFileDrop(paths: [String]) -> Bool { guard !paths.isEmpty else { return false } let urls = paths.map { URL(fileURLWithPath: $0) as NSURL } let pbName = NSPasteboard.Name("cmux.debug.drop.\(UUID().uuidString)") let pasteboard = NSPasteboard(name: pbName) pasteboard.clearContents() pasteboard.writeObjects(urls) return insertDroppedPasteboard(pasteboard) } fileprivate func debugRegisteredDropTypes() -> [String] { (registeredDraggedTypes ?? []).map(\.rawValue) } #endif // MARK: NSDraggingDestination override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { #if DEBUG let types = sender.draggingPasteboard.types ?? [] dlog("terminal.draggingEntered surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } return .copy } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { #if DEBUG let types = sender.draggingPasteboard.types ?? [] dlog("terminal.draggingUpdated surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))") #endif guard let types = sender.draggingPasteboard.types else { return [] } if Set(types).isDisjoint(with: Self.dropTypes) { return [] } return .copy } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { #if DEBUG dlog("terminal.fileDrop surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif return insertDroppedPasteboard(sender.draggingPasteboard) } } private extension NSScreen { var displayID: UInt32? { let key = NSDeviceDescriptionKey("NSScreenNumber") if let v = deviceDescription[key] as? UInt32 { return v } if let v = deviceDescription[key] as? Int { return UInt32(v) } if let v = deviceDescription[key] as? NSNumber { return v.uint32Value } return nil } } struct GhosttyScrollbar { let total: UInt64 let offset: UInt64 let len: UInt64 init(c: ghostty_action_scrollbar_s) { total = c.total offset = c.offset len = c.len } } enum GhosttyNotificationKey { static let scrollbar = "ghostty.scrollbar" static let cellSize = "ghostty.cellSize" static let tabId = "ghostty.tabId" static let surfaceId = "ghostty.surfaceId" static let title = "ghostty.title" static let backgroundColor = "ghostty.backgroundColor" static let backgroundOpacity = "ghostty.backgroundOpacity" static let backgroundEventId = "ghostty.backgroundEventId" static let backgroundSource = "ghostty.backgroundSource" } extension Notification.Name { static let ghosttyDidUpdateScrollbar = Notification.Name("ghosttyDidUpdateScrollbar") static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize") static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus") static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload") static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange") } // MARK: - Scroll View Wrapper (Ghostty-style scrollbar) private final class GhosttyScrollView: NSScrollView { weak var surfaceView: GhosttyNSView? // Keep keyboard routing on the terminal surface; this wrapper is viewport plumbing. override var acceptsFirstResponder: Bool { false } override func scrollWheel(with event: NSEvent) { guard let surfaceView else { super.scrollWheel(with: event) return } // Route wheel gestures to the terminal surface so Ghostty handles scrollback. // Letting NSScrollView consume these events moves the wrapper viewport itself, // which causes pane-content drift instead of terminal scrollback movement. GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: surface scroll") if window?.firstResponder !== surfaceView { window?.makeFirstResponder(surfaceView) } surfaceView.scrollWheel(with: event) } } private final class GhosttyFlashOverlayView: NSView { override var acceptsFirstResponder: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { nil } } private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView { override var acceptsFirstResponder: Bool { false } override func hitTest(_ point: NSPoint) -> NSView? { nil } } final class GhosttySurfaceScrollView: NSView { private let backgroundView: NSView private let scrollView: GhosttyScrollView private let documentView: NSView private let surfaceView: GhosttyNSView private let inactiveOverlayView: GhosttyFlashOverlayView private let dropZoneOverlayView: GhosttyFlashOverlayView private let notificationRingOverlayView: GhosttyFlashOverlayView private let notificationRingLayer: CAShapeLayer private let flashOverlayView: GhosttyFlashOverlayView private let flashLayer: CAShapeLayer private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView private let keyboardCopyModeBadgeLabel: NSTextField private var searchOverlayHostingView: NSHostingView? private var observers: [NSObjectProtocol] = [] private var windowObservers: [NSObjectProtocol] = [] private var isLiveScrolling = false private var lastSentRow: Int? private var isActive = true private var activeDropZone: DropZone? private var pendingDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 // Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection. private static func panelBackgroundFillColor(for terminalBackgroundColor: NSColor) -> NSColor { // The Ghostty renderer already draws translucent terminal backgrounds. If we paint an // additional translucent layer here, alpha stacks and appears effectively opaque. terminalBackgroundColor.alphaComponent < 0.999 ? .clear : terminalBackgroundColor } #if DEBUG private var lastDropZoneOverlayLogSignature: String? private static var flashCounts: [UUID: Int] = [:] private static var drawCounts: [UUID: Int] = [:] private static var lastDrawTimes: [UUID: CFTimeInterval] = [:] private static var presentCounts: [UUID: Int] = [:] private static var dropOverlayShowCounts: [UUID: Int] = [:] private static var lastPresentTimes: [UUID: CFTimeInterval] = [:] private static var lastContentsKeys: [UUID: String] = [:] static func flashCount(for surfaceId: UUID) -> Int { flashCounts[surfaceId, default: 0] } static func resetFlashCounts() { flashCounts.removeAll() } private static func recordFlash(for surfaceId: UUID) { flashCounts[surfaceId, default: 0] += 1 } static func drawStats(for surfaceId: UUID) -> (count: Int, last: CFTimeInterval) { (drawCounts[surfaceId, default: 0], lastDrawTimes[surfaceId, default: 0]) } static func resetDrawStats() { drawCounts.removeAll() lastDrawTimes.removeAll() } static func recordSurfaceDraw(_ surfaceId: UUID) { drawCounts[surfaceId, default: 0] += 1 lastDrawTimes[surfaceId] = CACurrentMediaTime() } private static func contentsKey(for layer: CALayer?) -> String { guard let modelLayer = layer else { return "nil" } // Prefer the presentation layer to better reflect what the user sees on screen. let layer = modelLayer.presentation() ?? modelLayer guard let contents = layer.contents else { return "nil" } // Prefer pointer identity for object/CFType contents. if let obj = contents as AnyObject? { let ptr = Unmanaged.passUnretained(obj).toOpaque() var key = "0x" + String(UInt(bitPattern: ptr), radix: 16) // For IOSurface-backed terminal layers, the IOSurface object can remain stable while // its contents change. Include the IOSurface seed so "new frame rendered" is visible // to debug/test tooling even when the pointer identity doesn't change. let cf = contents as CFTypeRef if CFGetTypeID(cf) == IOSurfaceGetTypeID() { let surfaceRef = (contents as! IOSurfaceRef) let seed = IOSurfaceGetSeed(surfaceRef) key += ":seed=\(seed)" } return key } return String(describing: contents) } private static func updatePresentStats(surfaceId: UUID, layer: CALayer?) -> (count: Int, last: CFTimeInterval, key: String) { let key = contentsKey(for: layer) if lastContentsKeys[surfaceId] != key { presentCounts[surfaceId, default: 0] += 1 lastPresentTimes[surfaceId] = CACurrentMediaTime() lastContentsKeys[surfaceId] = key } return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key) } private func recordDropOverlayShowAnimation() { guard let surfaceId = surfaceView.terminalSurface?.id else { return } Self.dropOverlayShowCounts[surfaceId, default: 0] += 1 } func debugProbeDropOverlayAnimation(useDeferredPath: Bool) -> (before: Int, after: Int, bounds: CGSize) { guard let surfaceId = surfaceView.terminalSurface?.id else { return (0, 0, bounds.size) } let before = Self.dropOverlayShowCounts[surfaceId, default: 0] // Reset to a hidden baseline so each probe exercises an initial-show transition. dropZoneOverlayAnimationGeneration &+= 1 activeDropZone = nil pendingDropZone = nil dropZoneOverlayView.layer?.removeAllAnimations() dropZoneOverlayView.isHidden = true dropZoneOverlayView.alphaValue = 1 if useDeferredPath { pendingDropZone = .left synchronizeGeometryAndContent() } else { setDropZoneOverlay(zone: .left) } let after = Self.dropOverlayShowCounts[surfaceId, default: 0] setDropZoneOverlay(zone: nil) return (before, after, bounds.size) } var debugSurfaceId: UUID? { surfaceView.terminalSurface?.id } #endif func portalBindingGuardState() -> (surfaceId: UUID?, generation: UInt64?, state: String) { guard let terminalSurface = surfaceView.terminalSurface else { return (surfaceId: nil, generation: nil, state: "missingSurface") } return ( surfaceId: terminalSurface.id, generation: terminalSurface.portalBindingGeneration(), state: terminalSurface.portalBindingStateLabel() ) } func canAcceptPortalBinding(expectedSurfaceId: UUID?, expectedGeneration: UInt64?) -> Bool { guard let terminalSurface = surfaceView.terminalSurface else { return false } return terminalSurface.canAcceptPortalBinding( expectedSurfaceId: expectedSurfaceId, expectedGeneration: expectedGeneration ) } init(surfaceView: GhosttyNSView) { self.surfaceView = surfaceView backgroundView = NSView(frame: .zero) scrollView = GhosttyScrollView() inactiveOverlayView = GhosttyFlashOverlayView(frame: .zero) dropZoneOverlayView = GhosttyFlashOverlayView(frame: .zero) notificationRingOverlayView = GhosttyFlashOverlayView(frame: .zero) notificationRingLayer = CAShapeLayer() flashOverlayView = GhosttyFlashOverlayView(frame: .zero) flashLayer = CAShapeLayer() keyboardCopyModeBadgeView = GhosttyPassthroughVisualEffectView(frame: .zero) keyboardCopyModeBadgeLabel = NSTextField(labelWithString: "VI MODE") scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = false scrollView.usesPredominantAxisScrolling = true scrollView.scrollerStyle = .overlay scrollView.drawsBackground = false scrollView.backgroundColor = .clear scrollView.contentView.clipsToBounds = true scrollView.contentView.drawsBackground = false scrollView.contentView.backgroundColor = .clear scrollView.surfaceView = surfaceView documentView = NSView(frame: .zero) scrollView.documentView = documentView documentView.addSubview(surfaceView) super.init(frame: .zero) wantsLayer = true layer?.masksToBounds = true backgroundView.wantsLayer = true let initialTerminalBackground = GhosttyApp.shared.defaultBackgroundColor .withAlphaComponent(GhosttyApp.shared.defaultBackgroundOpacity) let initialPanelFill = Self.panelBackgroundFillColor(for: initialTerminalBackground) backgroundView.layer?.backgroundColor = initialPanelFill.cgColor backgroundView.layer?.isOpaque = initialPanelFill.alphaComponent >= 1.0 addSubview(backgroundView) addSubview(scrollView) inactiveOverlayView.wantsLayer = true inactiveOverlayView.layer?.backgroundColor = NSColor.clear.cgColor inactiveOverlayView.isHidden = true addSubview(inactiveOverlayView) dropZoneOverlayView.wantsLayer = true dropZoneOverlayView.layer?.backgroundColor = cmuxAccentNSColor().withAlphaComponent(0.25).cgColor dropZoneOverlayView.layer?.borderColor = cmuxAccentNSColor().cgColor dropZoneOverlayView.layer?.borderWidth = 2 dropZoneOverlayView.layer?.cornerRadius = 8 dropZoneOverlayView.isHidden = true addSubview(dropZoneOverlayView) notificationRingOverlayView.wantsLayer = true notificationRingOverlayView.layer?.backgroundColor = NSColor.clear.cgColor notificationRingOverlayView.layer?.masksToBounds = false notificationRingOverlayView.autoresizingMask = [.width, .height] notificationRingLayer.fillColor = NSColor.clear.cgColor notificationRingLayer.strokeColor = NSColor.systemBlue.cgColor notificationRingLayer.lineWidth = 2.5 notificationRingLayer.lineJoin = .round notificationRingLayer.lineCap = .round notificationRingLayer.shadowColor = NSColor.systemBlue.cgColor notificationRingLayer.shadowOpacity = 0.35 notificationRingLayer.shadowRadius = 3 notificationRingLayer.shadowOffset = .zero notificationRingLayer.opacity = 0 notificationRingOverlayView.layer?.addSublayer(notificationRingLayer) notificationRingOverlayView.isHidden = true addSubview(notificationRingOverlayView) flashOverlayView.wantsLayer = true flashOverlayView.layer?.backgroundColor = NSColor.clear.cgColor flashOverlayView.layer?.masksToBounds = false flashOverlayView.autoresizingMask = [.width, .height] flashLayer.fillColor = NSColor.clear.cgColor flashLayer.strokeColor = NSColor.systemBlue.cgColor flashLayer.lineWidth = 3 flashLayer.lineJoin = .round flashLayer.lineCap = .round flashLayer.shadowColor = NSColor.systemBlue.cgColor flashLayer.shadowOpacity = 0.6 flashLayer.shadowRadius = 6 flashLayer.shadowOffset = .zero flashLayer.opacity = 0 flashOverlayView.layer?.addSublayer(flashLayer) addSubview(flashOverlayView) keyboardCopyModeBadgeView.translatesAutoresizingMaskIntoConstraints = false keyboardCopyModeBadgeView.wantsLayer = true keyboardCopyModeBadgeView.material = .hudWindow keyboardCopyModeBadgeView.blendingMode = .withinWindow keyboardCopyModeBadgeView.state = .active keyboardCopyModeBadgeView.layer?.cornerRadius = 7 keyboardCopyModeBadgeView.layer?.masksToBounds = true keyboardCopyModeBadgeView.layer?.borderWidth = 1 keyboardCopyModeBadgeView.layer?.borderColor = cmuxAccentNSColor().withAlphaComponent(0.45).cgColor keyboardCopyModeBadgeView.alphaValue = 0.97 keyboardCopyModeBadgeLabel.translatesAutoresizingMaskIntoConstraints = false keyboardCopyModeBadgeLabel.textColor = NSColor.labelColor keyboardCopyModeBadgeLabel.lineBreakMode = .byClipping keyboardCopyModeBadgeView.addSubview(keyboardCopyModeBadgeLabel) NSLayoutConstraint.activate([ keyboardCopyModeBadgeLabel.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.leadingAnchor, constant: 8), keyboardCopyModeBadgeLabel.trailingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.trailingAnchor, constant: -8), keyboardCopyModeBadgeLabel.topAnchor.constraint(equalTo: keyboardCopyModeBadgeView.topAnchor, constant: 4), keyboardCopyModeBadgeLabel.bottomAnchor.constraint(equalTo: keyboardCopyModeBadgeView.bottomAnchor, constant: -4), ]) keyboardCopyModeBadgeView.isHidden = true addSubview(keyboardCopyModeBadgeView) NSLayoutConstraint.activate([ keyboardCopyModeBadgeView.topAnchor.constraint(equalTo: topAnchor, constant: 8), keyboardCopyModeBadgeView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), ]) scrollView.contentView.postsBoundsChangedNotifications = true observers.append(NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main ) { [weak self] _ in self?.handleScrollChange() }) observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.willStartLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.isLiveScrolling = true }) observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.didEndLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.isLiveScrolling = false }) observers.append(NotificationCenter.default.addObserver( forName: NSScrollView.didLiveScrollNotification, object: scrollView, queue: .main ) { [weak self] _ in self?.handleLiveScroll() }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidUpdateScrollbar, object: surfaceView, queue: .main ) { [weak self] notification in self?.handleScrollbarUpdate(notification) }) observers.append(NotificationCenter.default.addObserver( forName: .ghosttyDidUpdateCellSize, object: surfaceView, queue: .main ) { [weak self] _ in self?.synchronizeScrollView() }) } required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } deinit { #if DEBUG dlog( "surface.hosted.deinit surface=\(debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + "inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0) " + "hidden=\(isHidden ? 1 : 0) frame=\(String(format: "%.1fx%.1f", frame.width, frame.height))" ) #endif observers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.forEach { NotificationCenter.default.removeObserver($0) } cancelFocusRequest() } override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } // Avoid stealing focus on scroll; focus is managed explicitly by the surface view. override var acceptsFirstResponder: Bool { false } override func layout() { super.layout() synchronizeGeometryAndContent() } /// Reconcile AppKit geometry with ghostty surface geometry synchronously. /// Used after split topology mutations (close/split) to prevent a stale one-frame /// IOSurface size from being presented after pane expansion. func reconcileGeometryNow() { guard Thread.isMainThread else { DispatchQueue.main.async { [weak self] in self?.reconcileGeometryNow() } return } synchronizeGeometryAndContent() } /// Request an immediate terminal redraw after geometry updates so stale IOSurface /// contents do not remain stretched during live resize churn. func refreshSurfaceNow() { surfaceView.terminalSurface?.forceRefresh() } private func synchronizeGeometryAndContent() { CATransaction.begin() CATransaction.setDisableActions(true) defer { CATransaction.commit() } backgroundView.frame = bounds scrollView.frame = bounds let targetSize = scrollView.bounds.size surfaceView.frame.size = targetSize documentView.frame.size.width = scrollView.bounds.width inactiveOverlayView.frame = bounds if let zone = activeDropZone { dropZoneOverlayView.frame = dropZoneOverlayFrame(for: zone, in: bounds.size) } if let pending = pendingDropZone, bounds.width > 2, bounds.height > 2 { pendingDropZone = nil #if DEBUG let frame = dropZoneOverlayFrame(for: pending, in: bounds.size) logDropZoneOverlay(event: "flushPending", zone: pending, frame: frame) #endif // Reuse the normal show/update path so deferred overlays get the // same initial animation as direct drop-zone activation. setDropZoneOverlay(zone: pending) } notificationRingOverlayView.frame = bounds flashOverlayView.frame = bounds updateNotificationRingPath() updateFlashPath() synchronizeScrollView() synchronizeSurfaceView() synchronizeCoreSurface() } override func viewDidMoveToWindow() { super.viewDidMoveToWindow() windowObservers.forEach { NotificationCenter.default.removeObserver($0) } windowObservers.removeAll() guard let window else { return } windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main ) { [weak self] _ in self?.applyFirstResponderIfNeeded() }) windowObservers.append(NotificationCenter.default.addObserver( forName: NSWindow.didResignKeyNotification, object: window, queue: .main ) { [weak self] _ in guard let self, let window = self.window else { return } // Losing key window does not always trigger first-responder resignation, so force // the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync. if let fr = window.firstResponder as? NSView, fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) { window.makeFirstResponder(nil) } }) if window.isKeyWindow { applyFirstResponderIfNeeded() } } func attachSurface(_ terminalSurface: TerminalSurface) { surfaceView.attachSurface(terminalSurface) } func setFocusHandler(_ handler: (() -> Void)?) { surfaceView.onFocus = handler } func setTriggerFlashHandler(_ handler: (() -> Void)?) { surfaceView.onTriggerFlash = handler } func setBackgroundColor(_ color: NSColor) { guard let layer = backgroundView.layer else { return } let fillColor = Self.panelBackgroundFillColor(for: color) CATransaction.begin() CATransaction.setDisableActions(true) layer.backgroundColor = fillColor.cgColor layer.isOpaque = fillColor.alphaComponent >= 1.0 CATransaction.commit() } func setInactiveOverlay(color: NSColor, opacity: CGFloat, visible: Bool) { let clampedOpacity = max(0, min(1, opacity)) CATransaction.begin() CATransaction.setDisableActions(true) inactiveOverlayView.layer?.backgroundColor = color.withAlphaComponent(clampedOpacity).cgColor inactiveOverlayView.isHidden = !(visible && clampedOpacity > 0.0001) CATransaction.commit() } func setNotificationRing(visible: Bool) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.setNotificationRing(visible: visible) } return } CATransaction.begin() CATransaction.setDisableActions(true) notificationRingOverlayView.isHidden = !visible notificationRingLayer.opacity = visible ? 1 : 0 CATransaction.commit() } func setSearchOverlay(searchState: TerminalSurface.SearchState?) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.setSearchOverlay(searchState: searchState) } return } // Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view. // SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces. guard let terminalSurface = surfaceView.terminalSurface, let searchState else { searchOverlayHostingView?.removeFromSuperview() searchOverlayHostingView = nil return } let tabId = terminalSurface.tabId let surfaceId = terminalSurface.id let rootView = SurfaceSearchOverlay( tabId: tabId, surfaceId: surfaceId, searchState: searchState, onMoveFocusToTerminal: { [weak self] in self?.moveFocus() }, onNavigateSearch: { [weak terminalSurface] action in _ = terminalSurface?.performBindingAction(action) }, onClose: { [weak self, weak terminalSurface] in terminalSurface?.searchState = nil self?.moveFocus() } ) if let overlay = searchOverlayHostingView { overlay.rootView = rootView if overlay.superview !== self { overlay.removeFromSuperview() addSubview(overlay) NSLayoutConstraint.activate([ overlay.topAnchor.constraint(equalTo: topAnchor), overlay.bottomAnchor.constraint(equalTo: bottomAnchor), overlay.leadingAnchor.constraint(equalTo: leadingAnchor), overlay.trailingAnchor.constraint(equalTo: trailingAnchor), ]) } if !keyboardCopyModeBadgeView.isHidden { addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay) } return } let overlay = NSHostingView(rootView: rootView) overlay.translatesAutoresizingMaskIntoConstraints = false addSubview(overlay) NSLayoutConstraint.activate([ overlay.topAnchor.constraint(equalTo: topAnchor), overlay.bottomAnchor.constraint(equalTo: bottomAnchor), overlay.leadingAnchor.constraint(equalTo: leadingAnchor), overlay.trailingAnchor.constraint(equalTo: trailingAnchor), ]) if !keyboardCopyModeBadgeView.isHidden { addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay) } searchOverlayHostingView = overlay } func setKeyboardCopyModeIndicator(visible: Bool) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.setKeyboardCopyModeIndicator(visible: visible) } return } keyboardCopyModeBadgeView.isHidden = !visible if visible { if let overlay = searchOverlayHostingView { addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: overlay) } else { addSubview(keyboardCopyModeBadgeView, positioned: .above, relativeTo: nil) } } } private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { let padding: CGFloat = 4 switch zone { case .center: return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2) case .left: return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) case .right: return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) case .top: return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding) case .bottom: return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding) } } private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool { abs(lhs.origin.x - rhs.origin.x) <= epsilon && abs(lhs.origin.y - rhs.origin.y) <= epsilon && abs(lhs.size.width - rhs.size.width) <= epsilon && abs(lhs.size.height - rhs.size.height) <= epsilon } func setDropZoneOverlay(zone: DropZone?) { if !Thread.isMainThread { DispatchQueue.main.async { [weak self] in self?.setDropZoneOverlay(zone: zone) } return } if let zone, (bounds.width <= 2 || bounds.height <= 2) { pendingDropZone = zone #if DEBUG logDropZoneOverlay(event: "deferZeroBounds", zone: zone, frame: nil) #endif return } let previousZone = activeDropZone activeDropZone = zone pendingDropZone = nil let previousFrame = dropZoneOverlayView.frame if let zone { #if DEBUG if window == nil { logDropZoneOverlay(event: "showNoWindow", zone: zone, frame: nil) } #endif let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size) let isSameFrame = Self.rectApproximatelyEqual(previousFrame, targetFrame) let needsFrameUpdate = !isSameFrame let zoneChanged = previousZone != zone if !dropZoneOverlayView.isHidden && !needsFrameUpdate && !zoneChanged { return } dropZoneOverlayAnimationGeneration &+= 1 dropZoneOverlayView.layer?.removeAllAnimations() if dropZoneOverlayView.isHidden { dropZoneOverlayView.frame = targetFrame dropZoneOverlayView.alphaValue = 0 dropZoneOverlayView.isHidden = false #if DEBUG recordDropOverlayShowAnimation() #endif #if DEBUG logDropZoneOverlay(event: "show", zone: zone, frame: targetFrame) #endif NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) dropZoneOverlayView.animator().alphaValue = 1 } completionHandler: { [weak self] in #if DEBUG guard let self else { return } guard self.activeDropZone == zone else { return } self.logDropZoneOverlay(event: "showComplete", zone: zone, frame: targetFrame) #endif } return } #if DEBUG if needsFrameUpdate || zoneChanged { logDropZoneOverlay(event: "update", zone: zone, frame: targetFrame) } #endif NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) if needsFrameUpdate { dropZoneOverlayView.animator().frame = targetFrame } if dropZoneOverlayView.alphaValue < 1 { dropZoneOverlayView.animator().alphaValue = 1 } } } else { guard !dropZoneOverlayView.isHidden else { return } dropZoneOverlayAnimationGeneration &+= 1 let animationGeneration = dropZoneOverlayAnimationGeneration dropZoneOverlayView.layer?.removeAllAnimations() #if DEBUG logDropZoneOverlay(event: "hide", zone: nil, frame: nil) #endif NSAnimationContext.runAnimationGroup { context in context.duration = 0.14 context.timingFunction = CAMediaTimingFunction(name: .easeOut) dropZoneOverlayView.animator().alphaValue = 0 } completionHandler: { [weak self] in guard let self else { return } guard self.dropZoneOverlayAnimationGeneration == animationGeneration else { return } guard self.activeDropZone == nil else { return } self.dropZoneOverlayView.isHidden = true self.dropZoneOverlayView.alphaValue = 1 #if DEBUG self.logDropZoneOverlay(event: "hideComplete", zone: nil, frame: nil) #endif } } } #if DEBUG private func logDropZoneOverlay(event: String, zone: DropZone?, frame: CGRect?) { let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" let zoneText = zone.map { String(describing: $0) } ?? "none" let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height) let frameText: String if let frame { frameText = String( format: "%.1f,%.1f %.1fx%.1f", frame.origin.x, frame.origin.y, frame.width, frame.height ) } else { frameText = "-" } let signature = "\(event)|\(surface)|\(zoneText)|\(boundsText)|\(frameText)|\(dropZoneOverlayView.isHidden ? 1 : 0)" guard lastDropZoneOverlayLogSignature != signature else { return } lastDropZoneOverlayLogSignature = signature dlog( "terminal.dropOverlay event=\(event) surface=\(surface) zone=\(zoneText) " + "hidden=\(dropZoneOverlayView.isHidden ? 1 : 0) bounds=\(boundsText) frame=\(frameText)" ) } #endif func triggerFlash() { DispatchQueue.main.async { [weak self] in guard let self else { return } #if DEBUG if let surfaceId = self.surfaceView.terminalSurface?.id { Self.recordFlash(for: surfaceId) } #endif self.updateFlashPath() self.flashLayer.removeAllAnimations() self.flashLayer.opacity = 0 let animation = CAKeyframeAnimation(keyPath: "opacity") animation.values = FocusFlashPattern.values.map { NSNumber(value: $0) } animation.keyTimes = FocusFlashPattern.keyTimes.map { NSNumber(value: $0) } animation.duration = FocusFlashPattern.duration animation.timingFunctions = FocusFlashPattern.curves.map { curve in switch curve { case .easeIn: return CAMediaTimingFunction(name: .easeIn) case .easeOut: return CAMediaTimingFunction(name: .easeOut) } } self.flashLayer.add(animation, forKey: "cmux.flash") } } func setVisibleInUI(_ visible: Bool) { let wasVisible = surfaceView.isVisibleInUI surfaceView.setVisibleInUI(visible) isHidden = !visible #if DEBUG if wasVisible != visible { let transition = "\(wasVisible ? 1 : 0)->\(visible ? 1 : 0)" let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.visible", suffix: suffix ) } #endif if !visible { // If we were focused, yield first responder. if let window, let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { window.makeFirstResponder(nil) } } else { applyFirstResponderIfNeeded() } } func setActive(_ active: Bool) { let wasActive = isActive isActive = active #if DEBUG if wasActive != active { let transition = "\(wasActive ? 1 : 0)->\(active ? 1 : 0)" let suffix = debugVisibilityStateSuffix(transition: transition) debugLogWorkspaceSwitchTiming( event: "ws.term.active", suffix: suffix ) } #endif if active { applyFirstResponderIfNeeded() } else if let window, let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { window.makeFirstResponder(nil) } } #if DEBUG private func debugLogWorkspaceSwitchTiming(event: String, suffix: String) { guard let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() else { dlog("\(event) id=none \(suffix)") return } let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)") } private func debugFirstResponderLabel() -> String { guard let window, let firstResponder = window.firstResponder else { return "nil" } if let view = firstResponder as? NSView { if view === surfaceView { return "surfaceView" } if view.isDescendant(of: surfaceView) { return "surfaceDescendant" } return String(describing: type(of: view)) } return String(describing: type(of: firstResponder)) } private func debugVisibilityStateSuffix(transition: String) -> String { let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil" let hiddenInHierarchy = (isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor) ? 1 : 0 let inWindow = window != nil ? 1 : 0 let hasSuperview = superview != nil ? 1 : 0 let hostHidden = isHidden ? 1 : 0 let surfaceHidden = surfaceView.isHidden ? 1 : 0 let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height) let frameText = String(format: "%.1fx%.1f", frame.width, frame.height) let responder = debugFirstResponderLabel() return "surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " + "visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " + "hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " + "bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)" } #endif func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) { #if DEBUG dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")") #endif let work = { [weak self] in guard let self else { return } guard let window = self.window else { return } if let previous, previous !== self { _ = previous.surfaceView.resignFirstResponder() } window.makeFirstResponder(self.surfaceView) } if let delay, delay > 0 { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { work() } } else { if Thread.isMainThread { work() } else { DispatchQueue.main.async { work() } } } } #if DEBUG @discardableResult func debugSimulateFileDrop(paths: [String]) -> Bool { surfaceView.debugSimulateFileDrop(paths: paths) } func debugRegisteredDropTypes() -> [String] { surfaceView.debugRegisteredDropTypes() } func debugInactiveOverlayState() -> (isHidden: Bool, alpha: CGFloat) { ( inactiveOverlayView.isHidden, inactiveOverlayView.layer?.backgroundColor.flatMap { NSColor(cgColor: $0)?.alphaComponent } ?? 0 ) } func debugNotificationRingState() -> (isHidden: Bool, opacity: Float) { ( notificationRingOverlayView.isHidden, notificationRingLayer.opacity ) } func debugHasSearchOverlay() -> Bool { guard let overlay = searchOverlayHostingView else { return false } return overlay.superview === self && !overlay.isHidden } func debugHasKeyboardCopyModeIndicator() -> Bool { keyboardCopyModeBadgeView.superview === self && !keyboardCopyModeBadgeView.isHidden } #endif /// Handle file/URL drops, forwarding to the terminal as shell-escaped paths. func handleDroppedURLs(_ urls: [URL]) -> Bool { guard !urls.isEmpty else { return false } let content = urls .map { GhosttyNSView.escapeDropForShell($0.path) } .joined(separator: " ") #if DEBUG dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))") #endif surfaceView.terminalSurface?.sendText(content) return true } func terminalViewForDrop(at point: NSPoint) -> GhosttyNSView? { guard bounds.contains(point), !isHidden else { return nil } return surfaceView } #if DEBUG /// Sends a synthetic key press/release pair directly to the surface view. /// This exercises the same key path as real keyboard input (ghostty_surface_key), /// unlike sendText, which bypasses key translation. @discardableResult func debugSendSyntheticKeyPressAndReleaseForUITest( characters: String, charactersIgnoringModifiers: String, keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags = [] ) -> Bool { guard let window else { return false } window.makeFirstResponder(surfaceView) let timestamp = ProcessInfo.processInfo.systemUptime guard let keyDown = NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: modifierFlags, timestamp: timestamp, windowNumber: window.windowNumber, context: nil, characters: characters, charactersIgnoringModifiers: charactersIgnoringModifiers, isARepeat: false, keyCode: keyCode ) else { return false } guard let keyUp = NSEvent.keyEvent( with: .keyUp, location: .zero, modifierFlags: modifierFlags, timestamp: timestamp + 0.001, windowNumber: window.windowNumber, context: nil, characters: characters, charactersIgnoringModifiers: charactersIgnoringModifiers, isARepeat: false, keyCode: keyCode ) else { return false } surfaceView.keyDown(with: keyDown) surfaceView.keyUp(with: keyUp) return true } /// Sends a synthetic Ctrl+D key press directly to the surface view. /// This exercises the same key path as real keyboard input (ghostty_surface_key), /// unlike `sendText`, which bypasses key translation. @discardableResult func sendSyntheticCtrlDForUITest(modifierFlags: NSEvent.ModifierFlags = [.control]) -> Bool { debugSendSyntheticKeyPressAndReleaseForUITest( characters: "\u{04}", charactersIgnoringModifiers: "d", keyCode: 2, modifierFlags: modifierFlags ) } #endif func ensureFocus(for tabId: UUID, surfaceId: UUID, attemptsRemaining: Int = 3) { func retry() { guard attemptsRemaining > 0 else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in self?.ensureFocus(for: tabId, surfaceId: surfaceId, attemptsRemaining: attemptsRemaining - 1) } } let hasUsablePortalGeometry: Bool = { let size = bounds.size return size.width > 1 && size.height > 1 }() let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor guard isActive else { return } guard surfaceView.terminalSurface?.searchState == nil else { return } guard let window else { return } guard surfaceView.isVisibleInUI else { retry() return } guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG dlog( "focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " + "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) attempts=\(attemptsRemaining)" ) #endif retry() return } guard let delegate = AppDelegate.shared, let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager, tabManager.selectedTabId == tabId else { retry() return } guard let tab = tabManager.tabs.first(where: { $0.id == tabId }), let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId), let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface }) }) else { retry() return } guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface, tab.bonsplitController.focusedPaneId == paneId else { retry() return } if let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { return } if !window.isKeyWindow { window.makeKeyAndOrderFront(nil) } _ = window.makeFirstResponder(surfaceView) if !isSurfaceViewFirstResponder() { retry() } } /// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during /// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles. func suppressReparentFocus() { surfaceView.suppressingReparentFocus = true } func clearSuppressReparentFocus() { surfaceView.suppressingReparentFocus = false } /// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder. /// This is stricter than checking `hostedView` descendants, since the scroll view can sometimes become /// first responder transiently while focus is being applied. func isSurfaceViewFirstResponder() -> Bool { guard let window, let fr = window.firstResponder as? NSView else { return false } return fr === surfaceView || fr.isDescendant(of: surfaceView) } private func applyFirstResponderIfNeeded() { let hasUsablePortalGeometry: Bool = { let size = bounds.size return size.width > 1 && size.height > 1 }() let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor guard isActive else { return } guard surfaceView.isVisibleInUI else { return } guard !isHiddenForFocus, hasUsablePortalGeometry else { #if DEBUG dlog( "focus.apply.skip surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " + "reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))" ) #endif return } guard surfaceView.terminalSurface?.searchState == nil else { return } guard let window, window.isKeyWindow else { return } if let fr = window.firstResponder as? NSView, fr === surfaceView || fr.isDescendant(of: surfaceView) { return } window.makeFirstResponder(surfaceView) } #if DEBUG struct DebugRenderStats { let drawCount: Int let lastDrawTime: CFTimeInterval let metalDrawableCount: Int let metalLastDrawableTime: CFTimeInterval let presentCount: Int let lastPresentTime: CFTimeInterval let layerClass: String let layerContentsKey: String let inWindow: Bool let windowIsKey: Bool let windowOcclusionVisible: Bool let appIsActive: Bool let isActive: Bool let desiredFocus: Bool let isFirstResponder: Bool } func debugRenderStats() -> DebugRenderStats { let layerClass = surfaceView.layer.map { String(describing: type(of: $0)) } ?? "nil" let (metalCount, metalLast) = (surfaceView.layer as? GhosttyMetalLayer)?.debugStats() ?? (0, 0) let (drawCount, lastDraw): (Int, CFTimeInterval) = surfaceView.terminalSurface.map { terminalSurface in Self.drawStats(for: terminalSurface.id) } ?? (0, 0) let (presentCount, lastPresent, contentsKey): (Int, CFTimeInterval, String) = surfaceView.terminalSurface.map { terminalSurface in let stats = Self.updatePresentStats(surfaceId: terminalSurface.id, layer: surfaceView.layer) return (stats.count, stats.last, stats.key) } ?? (0, 0, Self.contentsKey(for: surfaceView.layer)) let inWindow = (window != nil) let windowIsKey = window?.isKeyWindow ?? false let windowOcclusionVisible = (window?.occlusionState.contains(.visible) ?? false) || (window?.isKeyWindow ?? false) let appIsActive = NSApp.isActive let fr = window?.firstResponder as? NSView let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false) return DebugRenderStats( drawCount: drawCount, lastDrawTime: lastDraw, metalDrawableCount: metalCount, metalLastDrawableTime: metalLast, presentCount: presentCount, lastPresentTime: lastPresent, layerClass: layerClass, layerContentsKey: contentsKey, inWindow: inWindow, windowIsKey: windowIsKey, windowOcclusionVisible: windowOcclusionVisible, appIsActive: appIsActive, isActive: isActive, desiredFocus: surfaceView.desiredFocus, isFirstResponder: isFirstResponder ) } #endif #if DEBUG struct DebugFrameSample { let sampleCount: Int let uniqueQuantized: Int let lumaStdDev: Double let modeFraction: Double let fingerprint: UInt64 let iosurfaceWidthPx: Int let iosurfaceHeightPx: Int let expectedWidthPx: Int let expectedHeightPx: Int let layerClass: String let layerContentsGravity: String let layerContentsKey: String var isProbablyBlank: Bool { (lumaStdDev < 3.5 && modeFraction > 0.985) || (uniqueQuantized <= 6 && modeFraction > 0.95) } } /// Create a CGImage from the terminal's IOSurface-backed layer contents. /// /// This avoids Screen Recording permissions (unlike CGWindowListCreateImage) and is therefore /// suitable for debug socket tests running in headless/VM contexts. func debugCopyIOSurfaceCGImage() -> CGImage? { guard let modelLayer = surfaceView.layer else { return nil } let layer = modelLayer.presentation() ?? modelLayer guard let contents = layer.contents else { return nil } let cf = contents as CFTypeRef guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { return nil } let surfaceRef = (contents as! IOSurfaceRef) let width = Int(IOSurfaceGetWidth(surfaceRef)) let height = Int(IOSurfaceGetHeight(surfaceRef)) let bytesPerRow = Int(IOSurfaceGetBytesPerRow(surfaceRef)) guard width > 0, height > 0, bytesPerRow > 0 else { return nil } IOSurfaceLock(surfaceRef, [], nil) defer { IOSurfaceUnlock(surfaceRef, [], nil) } let base = IOSurfaceGetBaseAddress(surfaceRef) let size = bytesPerRow * height let data = Data(bytes: base, count: size) guard let provider = CGDataProvider(data: data as CFData) else { return nil } let colorSpace = CGColorSpaceCreateDeviceRGB() let bitmapInfo = CGBitmapInfo.byteOrder32Little.union( CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue) ) return CGImage( width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: bytesPerRow, space: colorSpace, bitmapInfo: bitmapInfo, provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent ) } /// Sample the IOSurface backing the terminal layer (if any) to detect a transient blank frame /// without using screenshots/screen recording permissions. func debugSampleIOSurface(normalizedCrop: CGRect) -> DebugFrameSample? { guard let modelLayer = surfaceView.layer else { return nil } // Prefer the presentation layer to better match what the user sees on screen. let layer = modelLayer.presentation() ?? modelLayer let layerClass = String(describing: type(of: layer)) let layerContentsGravity = layer.contentsGravity.rawValue let contentsKey = Self.contentsKey(for: layer) let presentationScale = max(1.0, layer.contentsScale) let expectedWidthPx = Int((layer.bounds.width * presentationScale).rounded(.toNearestOrAwayFromZero)) let expectedHeightPx = Int((layer.bounds.height * presentationScale).rounded(.toNearestOrAwayFromZero)) // Ghostty uses a CoreAnimation layer whose `contents` is an IOSurface-backed object. // The concrete layer class is often `IOSurfaceLayer` (private), so avoid referencing it directly. guard let anySurface = layer.contents else { // Treat "no contents" as a blank frame: this is the visual regression we're guarding. return DebugFrameSample( sampleCount: 0, uniqueQuantized: 0, lumaStdDev: 0, modeFraction: 1, fingerprint: 0, iosurfaceWidthPx: 0, iosurfaceHeightPx: 0, expectedWidthPx: expectedWidthPx, expectedHeightPx: expectedHeightPx, layerClass: layerClass, layerContentsGravity: layerContentsGravity, layerContentsKey: contentsKey ) } // IOSurfaceLayer.contents is usually an IOSurface, but during mitigation we may // temporarily replace contents with a CGImage snapshot to avoid blank flashes. // Treat non-IOSurface contents as "non-blank" and avoid unsafe casts. let cf = anySurface as CFTypeRef guard CFGetTypeID(cf) == IOSurfaceGetTypeID() else { var fnv: UInt64 = 1469598103934665603 for b in contentsKey.utf8 { fnv ^= UInt64(b) fnv &*= 1099511628211 } return DebugFrameSample( sampleCount: 1, uniqueQuantized: 1, lumaStdDev: 999, modeFraction: 0, fingerprint: fnv, iosurfaceWidthPx: 0, iosurfaceHeightPx: 0, expectedWidthPx: expectedWidthPx, expectedHeightPx: expectedHeightPx, layerClass: layerClass, layerContentsGravity: layerContentsGravity, layerContentsKey: contentsKey ) } let surfaceRef = (anySurface as! IOSurfaceRef) let width = Int(IOSurfaceGetWidth(surfaceRef)) let height = Int(IOSurfaceGetHeight(surfaceRef)) if width <= 0 || height <= 0 { return nil } let cropPx = CGRect( x: max(0, min(CGFloat(width - 1), normalizedCrop.origin.x * CGFloat(width))), y: max(0, min(CGFloat(height - 1), normalizedCrop.origin.y * CGFloat(height))), width: max(1, min(CGFloat(width), normalizedCrop.width * CGFloat(width))), height: max(1, min(CGFloat(height), normalizedCrop.height * CGFloat(height))) ).integral let x0 = Int(cropPx.minX) let y0 = Int(cropPx.minY) let x1 = Int(min(CGFloat(width), cropPx.maxX)) let y1 = Int(min(CGFloat(height), cropPx.maxY)) if x1 <= x0 || y1 <= y0 { return nil } IOSurfaceLock(surfaceRef, [], nil) defer { IOSurfaceUnlock(surfaceRef, [], nil) } let base = IOSurfaceGetBaseAddress(surfaceRef) let bytesPerRow = IOSurfaceGetBytesPerRow(surfaceRef) if bytesPerRow <= 0 { return nil } // Assume 4 bytes/pixel BGRA (common for IOSurfaceLayer contents). let bytesPerPixel = 4 let step = 6 var hist = [UInt16: Int]() hist.reserveCapacity(256) var lumas = [Double]() lumas.reserveCapacity(((x1 - x0) / step) * ((y1 - y0) / step)) var count = 0 var fnv: UInt64 = 1469598103934665603 for y in stride(from: y0, to: y1, by: step) { let row = base.advanced(by: y * bytesPerRow) for x in stride(from: x0, to: x1, by: step) { let p = row.advanced(by: x * bytesPerPixel) let b = Double(p.load(fromByteOffset: 0, as: UInt8.self)) let g = Double(p.load(fromByteOffset: 1, as: UInt8.self)) let r = Double(p.load(fromByteOffset: 2, as: UInt8.self)) let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b lumas.append(luma) let rq = UInt16(UInt8(r) >> 4) let gq = UInt16(UInt8(g) >> 4) let bq = UInt16(UInt8(b) >> 4) let key = (rq << 8) | (gq << 4) | bq hist[key, default: 0] += 1 count += 1 let lq = UInt8(max(0, min(63, Int(luma / 4.0)))) fnv ^= UInt64(lq) fnv &*= 1099511628211 } } guard count > 0 else { return nil } let mean = lumas.reduce(0.0, +) / Double(lumas.count) let variance = lumas.reduce(0.0) { $0 + ($1 - mean) * ($1 - mean) } / Double(lumas.count) let stddev = sqrt(variance) let modeCount = hist.values.max() ?? 0 let modeFrac = Double(modeCount) / Double(count) return DebugFrameSample( sampleCount: count, uniqueQuantized: hist.count, lumaStdDev: stddev, modeFraction: modeFrac, fingerprint: fnv, iosurfaceWidthPx: width, iosurfaceHeightPx: height, expectedWidthPx: expectedWidthPx, expectedHeightPx: expectedHeightPx, layerClass: layerClass, layerContentsGravity: layerContentsGravity, layerContentsKey: contentsKey ) } #endif func cancelFocusRequest() { // Intentionally no-op (no retry loops). } private func synchronizeSurfaceView() { let visibleRect = scrollView.contentView.documentVisibleRect surfaceView.frame.origin = visibleRect.origin } /// Match upstream Ghostty behavior: use content area width (excluding non-content /// regions such as scrollbar space) when telling libghostty the terminal size. private func synchronizeCoreSurface() { let width = max(0, scrollView.contentSize.width - overlayScrollbarInsetWidth()) let height = surfaceView.frame.height guard width > 0, height > 0 else { return } surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) } /// Reserve overlay scrollbar gutter so wrapped text never sits underneath a visible scroller. private func overlayScrollbarInsetWidth() -> CGFloat { guard scrollView.hasVerticalScroller, scrollView.scrollerStyle == .overlay else { return 0 } // If AppKit already reserved non-content width in `contentSize`, avoid double-subtraction. let alreadyReserved = max(0, scrollView.bounds.width - scrollView.contentSize.width) if alreadyReserved > 0.5 { return 0 } let fallback = NSScroller.scrollerWidth(for: .regular, scrollerStyle: .overlay) guard let verticalScroller = scrollView.verticalScroller else { return fallback } let measuredWidth = verticalScroller.frame.width if measuredWidth > 0 { return max(measuredWidth, fallback) } let controlSizeWidth = NSScroller.scrollerWidth( for: verticalScroller.controlSize, scrollerStyle: .overlay ) return max(controlSizeWidth, fallback) } private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, bounds: notificationRingOverlayView.bounds, inset: 2, radius: 6 ) } private func updateFlashPath() { updateOverlayRingPath( layer: flashLayer, bounds: flashOverlayView.bounds, inset: CGFloat(FocusFlashPattern.ringInset), radius: CGFloat(FocusFlashPattern.ringCornerRadius) ) } private func updateOverlayRingPath( layer: CAShapeLayer, bounds: CGRect, inset: CGFloat, radius: CGFloat ) { layer.frame = bounds guard bounds.width > inset * 2, bounds.height > inset * 2 else { layer.path = nil return } let rect = bounds.insetBy(dx: inset, dy: inset) layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil) } private func synchronizeScrollView() { documentView.frame.size.height = documentHeight() if !isLiveScrolling { let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { let offsetY = CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY)) lastSentRow = Int(scrollbar.offset) } } scrollView.reflectScrolledClipView(scrollView.contentView) } private func handleScrollChange() { synchronizeSurfaceView() } private func handleLiveScroll() { let cellHeight = surfaceView.cellSize.height guard cellHeight > 0 else { return } let visibleRect = scrollView.contentView.documentVisibleRect let documentHeight = documentView.frame.height let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height let row = Int(scrollOffset / cellHeight) guard row != lastSentRow else { return } lastSentRow = row _ = surfaceView.performBindingAction("scroll_to_row:\(row)") } private func handleScrollbarUpdate(_ notification: Notification) { guard let scrollbar = notification.userInfo?[GhosttyNotificationKey.scrollbar] as? GhosttyScrollbar else { return } surfaceView.scrollbar = scrollbar synchronizeScrollView() } private func documentHeight() -> CGFloat { let contentHeight = scrollView.contentSize.height let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { let documentGridHeight = CGFloat(scrollbar.total) * cellHeight let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight) return documentGridHeight + padding } return contentHeight } } // MARK: - NSTextInputClient extension GhosttyNSView: NSTextInputClient { fileprivate func sendTextToSurface(_ chars: String) { guard let surface = surface else { return } #if DEBUG cmuxWriteChildExitProbe( [ "probeInsertTextCharsHex": cmuxScalarHex(chars), "probeInsertTextSurfaceId": terminalSurface?.id.uuidString ?? "", ], increments: ["probeInsertTextCount": 1] ) #endif chars.withCString { ptr in var keyEvent = ghostty_input_key_s() keyEvent.action = GHOSTTY_ACTION_PRESS keyEvent.keycode = 0 keyEvent.mods = GHOSTTY_MODS_NONE keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = ptr keyEvent.composing = false _ = ghostty_surface_key(surface, keyEvent) } } func hasMarkedText() -> Bool { return markedText.length > 0 } func markedRange() -> NSRange { guard markedText.length > 0 else { return NSRange(location: NSNotFound, length: 0) } return NSRange(location: 0, length: markedText.length) } func selectedRange() -> NSRange { return NSRange(location: NSNotFound, length: 0) } func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { switch string { case let v as NSAttributedString: markedText = NSMutableAttributedString(attributedString: v) case let v as String: markedText = NSMutableAttributedString(string: v) default: break } // If we're not in a keyDown event, sync preedit immediately. // This can happen due to external events like changing keyboard layouts // while composing. if keyTextAccumulator == nil { syncPreedit() } } func unmarkText() { if markedText.length > 0 { markedText.mutableString.setString("") syncPreedit() } } /// Sync the preedit state based on the markedText value to libghostty. /// This tells Ghostty about IME composition text so it can render the /// preedit overlay (e.g. for Korean, Japanese, Chinese input). private func syncPreedit(clearIfNeeded: Bool = true) { guard let surface = surface else { return } if markedText.length > 0 { let str = markedText.string let len = str.utf8CString.count if len > 0 { str.withCString { ptr in // Subtract 1 for the null terminator ghostty_surface_preedit(surface, ptr, UInt(len - 1)) } } } else if clearIfNeeded { // If we had marked text before but don't now, we're no longer // in a preedit state so we can clear it. ghostty_surface_preedit(surface, nil, 0) } } func validAttributesForMarkedText() -> [NSAttributedString.Key] { return [] } func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { return nil } func characterIndex(for point: NSPoint) -> Int { return 0 } func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { guard let window = self.window else { return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0) } // Use Ghostty's IME point API for accurate cursor position if available. var x: Double = 0 var y: Double = 0 var w: Double = cellSize.width var h: Double = cellSize.height #if DEBUG if let override = imePointOverrideForTesting { x = override.x y = override.y w = override.width h = override.height } else if let surface = surface { ghostty_surface_ime_point(surface, &x, &y, &w, &h) } #else if let surface = surface { ghostty_surface_ime_point(surface, &x, &y, &w, &h) } #endif // Ghostty coordinates are top-left origin; AppKit expects bottom-left. let viewRect = NSRect( x: x, y: frame.size.height - y, width: w, height: max(h, cellSize.height) ) let winRect = convert(viewRect, to: nil) return window.convertToScreen(winRect) } func insertText(_ string: Any, replacementRange: NSRange) { guard NSApp.currentEvent != nil else { return } // Get the string value var chars = "" switch string { case let v as NSAttributedString: chars = v.string case let v as String: chars = v default: return } // Clear marked text since we're inserting unmarkText() // If we have an accumulator, we're in a keyDown event - accumulate the text if keyTextAccumulator != nil { keyTextAccumulator?.append(chars) return } // Otherwise send directly to the terminal sendTextToSurface(chars) } } // MARK: - SwiftUI Wrapper struct GhosttyTerminalView: NSViewRepresentable { @Environment(\.paneDropZone) var paneDropZone let terminalSurface: TerminalSurface var isActive: Bool = true var isVisibleInUI: Bool = true var portalZPriority: Int = 0 var showsInactiveOverlay: Bool = false var showsUnreadNotificationRing: Bool = false var inactiveOverlayColor: NSColor = .clear var inactiveOverlayOpacity: Double = 0 var searchState: TerminalSurface.SearchState? = nil var reattachToken: UInt64 = 0 var onFocus: ((UUID) -> Void)? = nil var onTriggerFlash: (() -> Void)? = nil private final class HostContainerView: NSView { var onDidMoveToWindow: (() -> Void)? var onGeometryChanged: (() -> Void)? override func viewDidMoveToWindow() { super.viewDidMoveToWindow() onDidMoveToWindow?() onGeometryChanged?() } override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() onGeometryChanged?() } override func layout() { super.layout() onGeometryChanged?() } override func setFrameOrigin(_ newOrigin: NSPoint) { super.setFrameOrigin(newOrigin) onGeometryChanged?() } override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) onGeometryChanged?() } } final class Coordinator { var attachGeneration: Int = 0 // Track the latest desired state so attach retries can re-apply focus after re-parenting. var desiredIsActive: Bool = true var desiredIsVisibleInUI: Bool = true var desiredShowsUnreadNotificationRing: Bool = false var desiredPortalZPriority: Int = 0 var lastBoundHostId: ObjectIdentifier? var lastPaneDropZone: DropZone? weak var hostedView: GhosttySurfaceScrollView? } func makeCoordinator() -> Coordinator { Coordinator() } static func shouldApplyImmediateHostedStateUpdate( hostedViewHasSuperview: Bool, isBoundToCurrentHost: Bool ) -> Bool { // If this update originates from a stale/replaced host while the hosted view is // already attached elsewhere, do not mutate visibility/active state here. if isBoundToCurrentHost { return true } return !hostedViewHasSuperview } func makeNSView(context: Context) -> NSView { let container = HostContainerView() container.wantsLayer = false return container } func updateNSView(_ nsView: NSView, context: Context) { let hostedView = terminalSurface.hostedView let coordinator = context.coordinator let previousDesiredIsActive = coordinator.desiredIsActive let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority let desiredStateChanged = previousDesiredIsActive != isActive || previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredPortalZPriority != portalZPriority coordinator.desiredIsActive = isActive coordinator.desiredIsVisibleInUI = isVisibleInUI coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing coordinator.desiredPortalZPriority = portalZPriority coordinator.hostedView = hostedView #if DEBUG if desiredStateChanged { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + "surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " + "active=\(isActive ? 1 : 0) z=\(portalZPriority) " + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } else { dlog( "ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " + "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " + "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } } #endif // Keep the surface lifecycle and handlers updated even if we defer re-parenting. hostedView.attachSurface(terminalSurface) hostedView.setInactiveOverlay( color: inactiveOverlayColor, opacity: CGFloat(inactiveOverlayOpacity), visible: showsInactiveOverlay ) hostedView.setNotificationRing(visible: showsUnreadNotificationRing) hostedView.setSearchOverlay(searchState: searchState) hostedView.setKeyboardCopyModeIndicator(visible: terminalSurface.keyboardCopyModeActive) hostedView.setFocusHandler { onFocus?(terminalSurface.id) } hostedView.setTriggerFlashHandler(onTriggerFlash) let portalExpectedSurfaceId = terminalSurface.id let portalExpectedGeneration = terminalSurface.portalBindingGeneration() let forwardedDropZone = isVisibleInUI ? paneDropZone : nil #if DEBUG if coordinator.lastPaneDropZone != paneDropZone { let oldZone = coordinator.lastPaneDropZone.map { String(describing: $0) } ?? "none" let newZone = paneDropZone.map { String(describing: $0) } ?? "none" dlog( "terminal.paneDropZone surface=\(terminalSurface.id.uuidString.prefix(5)) " + "old=\(oldZone) new=\(newZone) " + "active=\(isActive ? 1 : 0) visible=\(isVisibleInUI ? 1 : 0) " + "inWindow=\(hostedView.window != nil ? 1 : 0)" ) coordinator.lastPaneDropZone = paneDropZone } if paneDropZone != nil, !isVisibleInUI { dlog( "terminal.paneDropZone.suppress surface=\(terminalSurface.id.uuidString.prefix(5)) " + "requested=\(String(describing: paneDropZone!)) visible=0 active=\(isActive ? 1 : 0)" ) } #endif hostedView.setDropZoneOverlay(zone: forwardedDropZone) coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration let hostContainer = nsView as? HostContainerView if let host = hostContainer { host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } guard host.window != nil else { return } TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, visibleInUI: coordinator.desiredIsVisibleInUI, zPriority: coordinator.desiredPortalZPriority, expectedSurfaceId: portalExpectedSurfaceId, expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = ObjectIdentifier(host) hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } host.onGeometryChanged = { [weak host, weak hostedView, weak coordinator] in guard let host, let hostedView, let coordinator else { return } guard coordinator.attachGeneration == generation else { return } guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return } if host.window != nil, !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) { #if DEBUG dlog( "ws.hostState.rebindOnGeometry surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=portalEntryMissing visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority)" ) #endif TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, visibleInUI: coordinator.desiredIsVisibleInUI, zPriority: coordinator.desiredPortalZPriority, expectedSurfaceId: portalExpectedSurfaceId, expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = ObjectIdentifier(host) hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI) hostedView.setActive(coordinator.desiredIsActive) hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing) } TerminalWindowPortalRegistry.synchronizeForAnchor(host) } if host.window != nil { let hostId = ObjectIdentifier(host) let shouldBindNow = coordinator.lastBoundHostId != hostId || hostedView.superview == nil || previousDesiredIsVisibleInUI != isVisibleInUI || previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing || previousDesiredPortalZPriority != portalZPriority if shouldBindNow { TerminalWindowPortalRegistry.bind( hostedView: hostedView, to: host, visibleInUI: coordinator.desiredIsVisibleInUI, zPriority: coordinator.desiredPortalZPriority, expectedSurfaceId: portalExpectedSurfaceId, expectedGeneration: portalExpectedGeneration ) coordinator.lastBoundHostId = hostId } TerminalWindowPortalRegistry.synchronizeForAnchor(host) } else { // Bind is deferred until host moves into a window. Update the // existing portal entry's visibleInUI now so that any portal sync // that runs before the deferred bind completes won't hide the view. #if DEBUG if desiredStateChanged { dlog( "ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " + "active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " + "hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)" ) } #endif TerminalWindowPortalRegistry.updateEntryVisibility( for: hostedView, visibleInUI: coordinator.desiredIsVisibleInUI ) } } let hostWindowAttached = hostContainer?.window != nil let isBoundToCurrentHost = hostContainer.map { host in TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host) } ?? true let shouldApplyImmediateHostedState = Self.shouldApplyImmediateHostedStateUpdate( hostedViewHasSuperview: hostedView.superview != nil, isBoundToCurrentHost: isBoundToCurrentHost ) if shouldApplyImmediateHostedState { hostedView.setVisibleInUI(isVisibleInUI) hostedView.setActive(isActive) } else { // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates. // The currently bound host remains authoritative for immediate visible/active state. #if DEBUG if desiredStateChanged { dlog( "ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " + "reason=staleHostBinding hostWindow=\(hostWindowAttached ? 1 : 0) " + "boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " + "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)" ) } #endif } } static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachGeneration += 1 coordinator.desiredIsActive = false coordinator.desiredIsVisibleInUI = false coordinator.desiredShowsUnreadNotificationRing = false coordinator.desiredPortalZPriority = 0 coordinator.lastBoundHostId = nil let hostedView = coordinator.hostedView #if DEBUG if let hostedView { if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() { let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000 dlog( "ws.swiftui.dismantle id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " + "surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + "inWindow=\(hostedView.window != nil ? 1 : 0)" ) } else { dlog( "ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil") " + "inWindow=\(hostedView.window != nil ? 1 : 0)" ) } } #endif if let host = nsView as? HostContainerView { host.onDidMoveToWindow = nil host.onGeometryChanged = nil } // SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split // tree updates. Do not force visible/active false here; that causes avoidable blackouts // when the same hosted view is rebound moments later. hostedView?.setFocusHandler(nil) hostedView?.setTriggerFlashHandler(nil) hostedView?.setDropZoneOverlay(zone: nil) coordinator.hostedView = nil nsView.subviews.forEach { $0.removeFromSuperview() } } }