cmux/Sources/GhosttyTerminalView.swift
atani 12e91aa4fe fix: address review feedback for CJK font fallback
- Split Unicode ranges by language to avoid mapping Hangul to Hiragino
  Sans or Kana to Apple SD Gothic Neo. Shared CJK ranges (ideographs,
  symbols, fullwidth forms) use the first CJK language's font, while
  script-specific ranges (Kana, Hangul) only map to their own font.
- Use UUID-based temp file path to prevent race conditions on concurrent
  launches.
- Move fallback injection after ghostty_config_load_recursive_files so
  that config-file includes are already loaded when checking for
  existing font-codepoint-map entries.
- Follow config-file directives when scanning for existing
  font-codepoint-map entries.
- Extract test helper withTempConfig to reduce duplication.
- Add tests for multi-language mappings and config-file includes.
- Replace placeholder issue URL with actual PR link.
2026-03-06 23:16:15 +09:00

7114 lines
286 KiB
Swift

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 }
if let text = stringContents(from: pasteboard), !text.isEmpty { return true }
return clipboardHasImageOnly()
}
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)
}
static func escapeForShell(_ value: String) -> String {
var result = value
for char in shellEscapeCharacters {
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
}
return result
}
private static let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
/// Quick check: does the clipboard have image data and no text?
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 shell-escaped file path. Returns nil
/// if the clipboard contains text or no image.
static func saveClipboardImageIfNeeded() -> String? {
let pb = NSPasteboard.general
let types = pb.types ?? []
// If pasteboard has text/HTML, this is a normal copy.
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 escapeForShell(path)
}
}
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 scrollHalfPage(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
var pendingG = false
mutating func reset() {
countPrefix = nil
pendingYankLine = false
pendingG = 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) : .scrollHalfPage(-1)
}
if chars == "d" || chars == "\u{04}" {
return hasSelection ? .adjustSelection(.pageDown) : .scrollHalfPage(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
}
// Bare "g" is a prefix key (e.g. gg); handled in resolve.
return nil
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 state.pendingG {
if chars == "g", normalized.isEmpty {
let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1)
let action: TerminalKeyboardCopyModeAction = hasSelection ? .adjustSelection(.home) : .scrollToTop
state.reset()
return .perform(action, count: count)
}
// Not `gg`, cancel and treat as fresh command.
state.pendingG = 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
}
if chars == "g", normalized.isEmpty {
state.pendingG = 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..<count {
let diag = ghostty_config_get_diagnostic(config, UInt32(i))
let msg = diag.message.flatMap { String(cString: $0) } ?? "(null)"
initLog(" [\(i)] \(msg)")
}
}
#endif
private func initializeGhostty() {
// Ensure TUI apps can use colors even if NO_COLOR is set in the launcher env.
if getenv("NO_COLOR") != nil {
unsetenv("NO_COLOR")
}
// Initialize Ghostty library first
let result = ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv)
if result != GHOSTTY_SUCCESS {
print("Failed to initialize ghostty: \(result)")
return
}
// Load config
guard let primaryConfig = ghostty_config_new() else {
print("Failed to create ghostty config")
return
}
// Load default config (includes user config). If this fails hard (e.g. due to
// invalid user config), ghostty_app_new may return nil; we fall back below.
loadDefaultConfigFilesWithLegacyFallback(primaryConfig)
updateDefaultBackground(from: primaryConfig, source: "initialize.primaryConfig")
// Create runtime config with callbacks
var runtimeConfig = ghostty_runtime_config_s()
runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
runtimeConfig.supports_selection_clipboard = true
runtimeConfig.wakeup_cb = { userdata in
DispatchQueue.main.async {
GhosttyApp.shared.tick()
}
}
runtimeConfig.action_cb = { app, target, action in
return GhosttyApp.shared.handleAction(target: target, action: action)
}
runtimeConfig.read_clipboard_cb = { userdata, location, state in
// Read clipboard
guard let callbackContext = GhosttyApp.callbackContext(from: userdata),
let surface = callbackContext.runtimeSurface else { return }
let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location)
var value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? ""
// When clipboard has only image data (e.g. screenshot), save as temp
// PNG and paste the file path so CLI tools can receive images.
if value.isEmpty, let imagePath = GhosttyPasteboardHelper.saveClipboardImageIfNeeded() {
value = imagePath
}
value.withCString { ptr in
ghostty_surface_complete_clipboard_request(surface, ptr, state, false)
}
}
runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in
guard let content else { return }
guard let callbackContext = GhosttyApp.callbackContext(from: userdata),
let surface = callbackContext.runtimeSurface else { return }
ghostty_surface_complete_clipboard_request(surface, content, state, true)
}
runtimeConfig.write_clipboard_cb = { _, location, content, len, _ in
// Write clipboard
guard let content = content, len > 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)
loadCJKFontFallbackIfNeeded(config)
ghostty_config_finalize(config)
}
/// When the user has not configured `font-codepoint-map` for CJK ranges,
/// macOS Core Text may pick an inappropriate fallback font (e.g. LingWai,
/// a decorative calligraphic font) for CJK characters. This injects a
/// sensible default based on the system's preferred languages.
///
/// See: https://github.com/manaflow-ai/cmux/pull/1017
private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) {
if Self.userConfigContainsCJKCodepointMap() { return }
guard let mappings = Self.cjkFontMappings() else { return }
let lines = mappings.map { range, font in
"font-codepoint-map = \(range)=\(font)"
}.joined(separator: "\n")
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf")
do {
try lines.write(to: tmpURL, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmpURL) }
tmpURL.path.withCString { path in
ghostty_config_load_file(config, path)
}
} catch {
#if DEBUG
Self.initLog("failed to write CJK font fallback config: \(error)")
#endif
}
}
/// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms).
private static let sharedCJKRanges = [
"U+3000-U+303F", // CJK Symbols and Punctuation
"U+4E00-U+9FFF", // CJK Unified Ideographs
"U+F900-U+FAFF", // CJK Compatibility Ideographs
"U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms
"U+3400-U+4DBF", // CJK Unified Ideographs Extension A
]
/// Unicode ranges specific to Japanese (kana).
private static let japaneseRanges = [
"U+3040-U+309F", // Hiragana
"U+30A0-U+30FF", // Katakana
]
/// Unicode ranges specific to Korean (Hangul).
private static let koreanRanges = [
"U+AC00-U+D7AF", // Hangul Syllables
"U+1100-U+11FF", // Hangul Jamo
]
/// Returns (range, font) pairs for CJK font fallback based on the system's
/// preferred languages, or nil if no CJK language is detected. Each language
/// only maps its own script ranges to avoid assigning glyphs to a font that
/// lacks coverage (e.g. Hangul to Hiragino Sans).
static func cjkFontMappings(
preferredLanguages: [String] = Locale.preferredLanguages
) -> [(String, String)]? {
var mappings: [(String, String)] = []
var coveredShared = false
for lang in preferredLanguages {
let lower = lang.lowercased()
let font: String
var langRanges: [String] = []
if lower.hasPrefix("ja") {
font = "Hiragino Sans"
langRanges = japaneseRanges
} else if lower.hasPrefix("ko") {
font = "Apple SD Gothic Neo"
langRanges = koreanRanges
} else if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") {
font = "PingFang TC"
} else if lower.hasPrefix("zh") {
font = "PingFang SC"
} else {
continue
}
if !coveredShared {
for range in sharedCJKRanges {
mappings.append((range, font))
}
coveredShared = true
}
for range in langRanges {
mappings.append((range, font))
}
}
return mappings.isEmpty ? nil : mappings
}
/// Checks whether the user's Ghostty config files already contain
/// a `font-codepoint-map` entry covering CJK ranges.
static func userConfigContainsCJKCodepointMap(
configPaths: [String] = [
"~/.config/ghostty/config",
"~/.config/ghostty/config.ghostty",
"~/Library/Application Support/com.mitchellh.ghostty/config",
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
]
) -> Bool {
for rawPath in configPaths {
let path = NSString(string: rawPath).expandingTildeInPath
if Self.configFileContainsCodepointMap(atPath: path) {
return true
}
}
return false
}
/// Scans a single config file (and any files it includes) for
/// `font-codepoint-map` entries.
private static func configFileContainsCodepointMap(atPath path: String) -> Bool {
guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else {
return false
}
for line in contents.components(separatedBy: .newlines) {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("#") { continue }
if trimmed.hasPrefix("font-codepoint-map") {
return true
}
if trimmed.hasPrefix("config-file") {
let parts = trimmed.split(separator: "=", maxSplits: 1)
if parts.count == 2 {
let includePath = parts[1]
.trimmingCharacters(in: .whitespaces)
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
let resolved = NSString(string: includePath).expandingTildeInPath
if configFileContainsCodepointMap(atPath: resolved) {
return true
}
}
}
}
return false
}
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<T>(_ 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<GhosttySurfaceCallbackContext>.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)
}
}
let sourceWorkspaceId = callbackTabId ?? surfaceView.tabId
let sourcePanelId = callbackSurfaceId ?? surfaceView.terminalSurface?.id
guard let sourceWorkspaceId,
let sourcePanelId 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=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)"
)
#endif
return performOnMain {
guard let app = AppDelegate.shared,
let resolved = app.workspaceContainingPanel(
panelId: sourcePanelId,
preferredWorkspaceId: sourceWorkspaceId
) else {
#if DEBUG
dlog(
"link.openURL embedded but workspace lookup failed " +
"tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)"
)
#endif
return false
}
let workspace = resolved.workspace
#if DEBUG
if workspace.id != sourceWorkspaceId {
dlog(
"link.openURL workspace.remap sourceTab=\(sourceWorkspaceId) " +
"resolvedTab=\(workspace.id) surfaceId=\(sourcePanelId)"
)
}
#endif
if let targetPane = workspace.preferredBrowserTargetPane(fromPanelId: sourcePanelId) {
#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=\(sourcePanelId)")
#endif
return workspace.newBrowserSplit(from: sourcePanelId, 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<GhosttySurfaceCallbackContext>?
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()
#if DEBUG
dlog("find.searchState created tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))")
#endif
searchNeedleCancellable = searchState.$needle
.removeDuplicates()
.map { needle -> AnyPublisher<String, Never> 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
#if DEBUG
dlog("find.needle updated tab=\(self?.tabId.uuidString.prefix(5) ?? "?") surface=\(self?.id.uuidString.prefix(5) ?? "?") chars=\(needle.count)")
#endif
_ = self?.performBindingAction("search:\(needle)")
}
} else if oldValue != nil {
searchNeedleCancellable = nil
#if DEBUG
dlog("find.searchState cleared tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))")
#endif
_ = 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
}
/// Explicitly free the Ghostty runtime surface. Idempotent safe to call
/// before deinit; deinit will skip the free if already torn down.
@MainActor
func teardownSurface() {
markPortalLifecycleClosed(reason: "teardown")
let callbackContext = surfaceCallbackContext
surfaceCallbackContext = nil
let surfaceToFree = surface
surface = nil
guard let surfaceToFree else {
callbackContext?.release()
return
}
Task { @MainActor in
// Keep free behavior aligned with deinit: perform the runtime teardown on
// the next main-actor turn so SIGHUP delivery is deterministic but non-reentrant.
ghostty_surface_free(surfaceToFree)
callbackContext?.release()
}
}
#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<CChar>, UnsafeMutablePointer<CChar>)] = []
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..<count {
let item = existingEnv[i]
if let key = String(cString: item.key, encoding: .utf8),
let value = String(cString: item.value, encoding: .utf8) {
env[key] = value
}
}
}
}
env["CMUX_SURFACE_ID"] = id.uuidString
env["CMUX_WORKSPACE_ID"] = tabId.uuidString
// Backward-compatible shell integration keys used by existing scripts/tests.
env["CMUX_PANEL_ID"] = id.uuidString
env["CMUX_TAB_ID"] = tabId.uuidString
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
env["CMUX_BUNDLE_ID"] = bundleId
}
// Port range for this workspace (base/range snapshotted once per app session)
do {
let startPort = Self.sessionPortBase + portOrdinal * Self.sessionPortRangeSize
env["CMUX_PORT"] = String(startPort)
env["CMUX_PORT_END"] = String(startPort + Self.sessionPortRangeSize - 1)
env["CMUX_PORT_RANGE"] = String(Self.sessionPortRangeSize)
}
let claudeHooksEnabled = ClaudeCodeIntegrationSettings.hooksEnabled()
if !claudeHooksEnabled {
env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1"
}
if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path {
let currentPath = env["PATH"]
?? getenv("PATH").map { String(cString: $0) }
?? ProcessInfo.processInfo.environment["PATH"]
?? ""
if !currentPath.split(separator: ":").contains(Substring(cliBinPath)) {
let separator = currentPath.isEmpty ? "" : ":"
env["PATH"] = "\(cliBinPath)\(separator)\(currentPath)"
}
}
// Shell integration: inject ZDOTDIR wrapper for zsh shells.
let shellIntegrationEnabled = UserDefaults.standard.object(forKey: "sidebarShellIntegration") as? Bool ?? true
if shellIntegrationEnabled,
let integrationDir = Bundle.main.resourceURL?.appendingPathComponent("shell-integration").path {
env["CMUX_SHELL_INTEGRATION"] = "1"
env["CMUX_SHELL_INTEGRATION_DIR"] = integrationDir
let shell = (env["SHELL"]?.isEmpty == false ? env["SHELL"] : nil)
?? getenv("SHELL").map { String(cString: $0) }
?? ProcessInfo.processInfo.environment["SHELL"]
?? "/bin/zsh"
let shellName = URL(fileURLWithPath: shell).lastPathComponent
if shellName == "zsh" {
let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil)
?? getenv("ZDOTDIR").map { String(cString: $0) }
?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil)
if let candidateZdotdir, !candidateZdotdir.isEmpty {
var isGhosttyInjected = false
let ghosttyResources = (env["GHOSTTY_RESOURCES_DIR"]?.isEmpty == false ? env["GHOSTTY_RESOURCES_DIR"] : nil)
?? getenv("GHOSTTY_RESOURCES_DIR").map { String(cString: $0) }
?? (ProcessInfo.processInfo.environment["GHOSTTY_RESOURCES_DIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["GHOSTTY_RESOURCES_DIR"] : nil)
if let ghosttyResources {
let ghosttyZdotdir = URL(fileURLWithPath: ghosttyResources)
.appendingPathComponent("shell-integration/zsh").path
isGhosttyInjected = (candidateZdotdir == ghosttyZdotdir)
}
if !isGhosttyInjected {
env["CMUX_ZSH_ZDOTDIR"] = candidateZdotdir
}
}
env["ZDOTDIR"] = integrationDir
}
}
if !additionalEnvironment.isEmpty {
for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty {
env[key] = value
}
}
if !env.isEmpty {
envVars.reserveCapacity(env.count)
envStorage.reserveCapacity(env.count)
for (key, value) in env {
guard let keyPtr = strdup(key), let valuePtr = strdup(value) else { continue }
envStorage.append((keyPtr, valuePtr))
envVars.append(ghostty_env_var_s(key: keyPtr, value: valuePtr))
}
}
let createSurface = { [self] in
if !envVars.isEmpty {
let envVarsCount = envVars.count
envVars.withUnsafeMutableBufferPointer { buffer in
surfaceConfig.env_vars = buffer.baseAddress
surfaceConfig.env_var_count = envVarsCount
self.surface = ghostty_surface_new(app, &surfaceConfig)
}
} else {
self.surface = ghostty_surface_new(app, &surfaceConfig)
}
}
if let workingDirectory, !workingDirectory.isEmpty {
workingDirectory.withCString { cWorkingDir in
surfaceConfig.working_directory = cWorkingDir
createSurface()
}
} else {
createSurface()
}
if surface == nil {
surfaceCallbackContext?.release()
surfaceCallbackContext = nil
print("Failed to create ghostty surface")
#if DEBUG
Self.surfaceLog("createSurface FAILED surface=\(id.uuidString): ghostty_surface_new returned nil")
if let cfg = GhosttyApp.shared.config {
let count = Int(ghostty_config_diagnostics_count(cfg))
Self.surfaceLog("createSurface diagnostics count=\(count)")
for i in 0..<count {
let diag = ghostty_config_get_diagnostic(cfg, UInt32(i))
let msg = diag.message.flatMap { String(cString: $0) } ?? "(null)"
Self.surfaceLog(" [\(i)] \(msg)")
}
} else {
Self.surfaceLog("createSurface diagnostics: config=nil")
}
#endif
return
}
guard let createdSurface = surface else { return }
// For vsync-driven rendering, Ghostty needs to know which display we're on so it can
// start a CVDisplayLink with the right refresh rate. If we don't set this early, the
// renderer can believe vsync is "running" but never deliver frames, which looks like a
// frozen terminal until focus/visibility changes force a synchronous draw.
//
// `view.window?.screen` can be transiently nil during early attachment; fall back to the
// primary screen so we always set *some* display ID, then update again on screen changes.
if let screen = view.window?.screen ?? NSScreen.main,
let displayID = screen.displayID,
displayID != 0 {
ghostty_surface_set_display_id(createdSurface, displayID)
}
ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y)
let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size
let wpx = pixelDimension(from: backingSize.width)
let hpx = pixelDimension(from: backingSize.height)
if wpx > 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<NSPasteboard.PasteboardType> = [
.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<UInt16> = []
private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState()
private var keyboardCopyModeViewportRow: Int?
/// Tracks whether the user has explicitly entered visual selection mode (v).
/// Separate from Ghostty's `has_selection` because copy mode always maintains
/// a 1-cell selection as a visible cursor. This flag determines whether
/// movements should extend the selection (visual) or scroll the viewport.
private var keyboardCopyModeVisualActive = false
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?
private var isFindEscapeSuppressionArmed = false
#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))"
)
}
}
}
// Theme/background application is window-local. During cross-window workspace
// switches (e.g. jump-to-unread), the global active tab manager can lag behind.
// Prefer the owning window's selected workspace when available.
static func shouldApplyWindowBackground(
surfaceTabId: UUID?,
owningManagerExists: Bool,
owningSelectedTabId: UUID?,
activeSelectedTabId: UUID?
) -> Bool {
guard let surfaceTabId else { return true }
if owningManagerExists {
guard let owningSelectedTabId else { return true }
return owningSelectedTabId == surfaceTabId
}
if let activeSelectedTabId {
return activeSelectedTabId == surfaceTabId
}
return true
}
func applyWindowBackgroundIfActive() {
guard let window else { return }
let appDelegate = AppDelegate.shared
let owningManager = tabId.flatMap { appDelegate?.tabManagerFor(tabId: $0) }
let owningSelectedTabId = owningManager?.selectedTabId
let activeSelectedTabId = owningManager == nil ? appDelegate?.tabManager?.selectedTabId : nil
guard Self.shouldApplyWindowBackground(
surfaceTabId: tabId,
owningManagerExists: owningManager != nil,
owningSelectedTabId: owningSelectedTabId,
activeSelectedTabId: activeSelectedTabId
) else {
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()
keyboardCopyModeVisualActive = false
keyboardCopyModeActive = active
if active, let surface {
keyboardCopyModeViewportRow = keyboardCopyModeSelectionAnchor(surface: surface)?.row
_ = ghostty_surface_clear_selection(surface)
if keyboardCopyModeViewportRow == nil {
keyboardCopyModeViewportRow = keyboardCopyModeImeViewportRow(surface: surface)
}
// Create a 1-cell selection at the terminal cursor to serve as a
// visible cursor indicator in copy mode.
_ = ghostty_surface_select_cursor_cell(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) {
// In visual mode the user owns the selection range; don't disturb it.
// Outside visual mode we keep a 1-cell cursor selection for visibility,
// so we still need to refresh the viewport row after scrolling.
guard !keyboardCopyModeVisualActive else { return }
guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return }
keyboardCopyModeViewportRow = anchor.row
// Preserve the visible cursor indicator.
_ = ghostty_surface_select_cursor_cell(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
}
// Use the visual-mode flag instead of raw has_selection so that the
// 1-cell cursor selection doesn't make every motion behave as visual.
let hasSelection = keyboardCopyModeVisualActive
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:
keyboardCopyModeVisualActive = true
case .clearSelection:
keyboardCopyModeVisualActive = false
_ = ghostty_surface_clear_selection(surface)
// Re-create 1-cell cursor at terminal cursor position.
_ = ghostty_surface_select_cursor_cell(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 let .scrollHalfPage(delta):
let fraction = delta > 0 ? 0.5 : -0.5
performBindingAction("scroll_page_fractional:\(fraction)", 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 paste
@IBAction func paste(_ sender: Any?) {
_ = 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)
case #selector(pasteAsPlainText(_:)):
return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD)
case #selector(splitHorizontally(_:)), #selector(splitVertically(_:)):
return canSplitCurrentSurface()
default:
return true
}
}
// MARK: - Accessibility
/// Expose the terminal surface as an editable accessibility element.
/// Voice input tools frequently target AX text areas for text insertion.
override func isAccessibilityElement() -> Bool {
true
}
override func accessibilityRole() -> NSAccessibility.Role? {
.textArea
}
override func accessibilityHelp() -> String? {
"Terminal content area"
}
override func accessibilityValue() -> Any? {
// We don't keep a full terminal text snapshot in this layer.
// Expose selected text when available; otherwise provide an empty value
// so AX clients still treat this as an editable text area.
accessibilitySelectedText() ?? ""
}
override func setAccessibilityValue(_ value: Any?) {
let content: String
switch value {
case let v as NSAttributedString:
content = v.string
case let v as String:
content = v
default:
return
}
guard !content.isEmpty else { return }
#if DEBUG
dlog("ime.ax.setValue len=\(content.count)")
#endif
let inject = {
self.insertText(content, replacementRange: NSRange(location: NSNotFound, length: 0))
}
if Thread.isMainThread {
inject()
} else {
DispatchQueue.main.async(execute: inject)
}
}
override func accessibilitySelectedTextRange() -> NSRange {
selectedRange()
}
override func accessibilitySelectedText() -> String? {
guard let surface = 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) }
guard let ptr = text.text, text.text_len > 0 else { return nil }
let selectedData = Data(bytes: ptr, count: Int(text.text_len))
let selected = String(decoding: selectedData, as: UTF8.self)
return selected.isEmpty ? nil : selected
}
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
}
/// Some third-party voice input apps inject committed text by sending the
/// responder-chain `insertText:` action (single-argument form).
/// Route that into our NSTextInputClient path so text lands in the terminal.
override func insertText(_ insertString: Any) {
insertText(insertString, replacementRange: NSRange(location: NSNotFound, length: 0))
}
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 event.keyCode != 53 {
endFindEscapeSuppression()
}
if shouldConsumeSuppressedFindEscape(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) && !hasMarkedText() {
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 event.keyCode != 53 {
endFindEscapeSuppression()
}
if shouldConsumeSuppressedFindEscape(event) {
endFindEscapeSuppression()
return
}
if event.keyCode == 53 {
endFindEscapeSuppression()
}
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)
}
func beginFindEscapeSuppression() {
isFindEscapeSuppressionArmed = true
}
private func endFindEscapeSuppression() {
isFindEscapeSuppressionArmed = false
}
private func shouldConsumeSuppressedFindEscape(_ event: NSEvent) -> Bool {
guard event.keyCode == 53 else { return false }
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
guard flags.isEmpty else { return false }
return isFindEscapeSuppressionArmed
}
/// 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")
static let browserSearchFocus = Notification.Name("browserSearchFocus")
}
// 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<SurfaceSearchOverlay>?
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.
/// Tracks whether keyboard focus should go to the search field or the terminal
/// when the window becomes key while the find bar is open.
enum SearchFocusTarget {
case searchField
case terminal
}
private(set) var searchFocusTarget: SearchFocusTarget = .searchField
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: .ghosttySearchFocus,
object: nil,
queue: .main
) { [weak self] notification in
guard let self,
let surface = notification.object as? TerminalSurface,
surface === self.surfaceView.terminalSurface else { return }
self.searchFocusTarget = .searchField
// Explicitly unfocus the terminal so the cursor stops blinking
// when the search field takes over.
surface.setFocus(false)
})
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
guard let self else { return }
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
#if DEBUG
dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))")
#endif
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 }
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
// 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) {
#if DEBUG
dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) resigningFirstResponder")
#endif
window.makeFirstResponder(nil)
} else {
#if DEBUG
dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) firstResponder=\(String(describing: window.firstResponder)) (not terminal, skipping)")
#endif
}
})
if window.isKeyWindow { applyFirstResponderIfNeeded() }
}
func attachSurface(_ terminalSurface: TerminalSurface) {
surfaceView.attachSurface(terminalSurface)
}
func setFocusHandler(_ handler: (() -> Void)?) {
guard let handler else {
surfaceView.onFocus = nil
return
}
surfaceView.onFocus = { [weak self] in
// When the terminal surface gains focus (click, tab, etc.), update the
// search focus target so window reactivation restores terminal focus.
if self?.surfaceView.terminalSurface?.searchState != nil {
self?.searchFocusTarget = .terminal
}
handler()
}
}
func beginFindEscapeSuppression() {
surfaceView.beginFindEscapeSuppression()
}
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 {
let hadOverlay = searchOverlayHostingView != nil
#if DEBUG
dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)")
#endif
searchOverlayHostingView?.removeFromSuperview()
searchOverlayHostingView = nil
searchFocusTarget = .searchField
return
}
let hadOverlay = searchOverlayHostingView != nil
#if DEBUG
dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")")
#endif
let tabId = terminalSurface.tabId
let surfaceId = terminalSurface.id
let rootView = SurfaceSearchOverlay(
tabId: tabId,
surfaceId: surfaceId,
searchState: searchState,
onMoveFocusToTerminal: { [weak self] in
self?.searchFocusTarget = .terminal
self?.moveFocus()
},
onNavigateSearch: { [weak terminalSurface] action in
_ = terminalSurface?.performBindingAction(action)
},
onFieldDidFocus: { [weak self, weak terminalSurface] in
self?.searchFocusTarget = .searchField
terminalSurface?.setFocus(false)
},
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
}
searchFocusTarget = .searchField
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
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
dlog("find.moveFocus to=\(surfaceShort) searchState=\(searchActive ? "active" : "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 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
}
// Search focus restoration only after confirming this is the active tab/pane.
if surfaceView.terminalSurface?.searchState != nil {
restoreSearchFocus(window: window)
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
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
guard isActive else { return }
guard surfaceView.isVisibleInUI else { return }
guard !isHiddenForFocus, hasUsablePortalGeometry else {
#if DEBUG
dlog(
"focus.apply.skip surface=\(surfaceShort) " +
"reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))"
)
#endif
return
}
guard let window, window.isKeyWindow else { return }
if surfaceView.terminalSurface?.searchState != nil {
// Find bar is open. Restore focus based on what the user last intended.
restoreSearchFocus(window: window)
return
}
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
return
}
// Don't steal focus from a search overlay on another surface in this window.
if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) {
#if DEBUG
dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused")
#endif
return
}
#if DEBUG
dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))")
#endif
window.makeFirstResponder(surfaceView)
}
/// Restore focus when window becomes key and the find bar is open.
/// Respects `searchFocusTarget` so Escape-to-terminal intent is preserved across window switches.
private func restoreSearchFocus(window: NSWindow) {
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
switch searchFocusTarget {
case .searchField:
// Explicitly unfocus the terminal so cursor stops blinking immediately.
// The notification observer also does this, but it runs async when posted from main.
surfaceView.terminalSurface?.setFocus(false)
// Post notification SearchTextFieldRepresentable's Coordinator
// observes it and calls makeFirstResponder on the native NSTextField.
if let terminalSurface = surfaceView.terminalSurface {
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
}
#if DEBUG
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=searchField via=notification")
#endif
case .terminal:
window.makeFirstResponder(surfaceView)
#if DEBUG
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal")
#endif
}
}
/// Check if a responder is inside a search overlay hosting view.
/// Handles the AppKit field-editor case: when an NSTextField is being edited,
/// window.firstResponder is the shared NSTextView field editor, not the text field.
private func isSearchOverlayOrDescendant(_ responder: NSResponder) -> Bool {
// If the responder is a field editor, follow its delegate back to the owning control.
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
return isSearchOverlayOrDescendant(editedView)
}
guard let view = responder as? NSView else { return false }
var current: NSView? = view
while let v = current {
if v is NSHostingView<SurfaceSearchOverlay> { return true }
current = v.superview
}
return false
}
#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) {
// 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()
// Some IME/input-method paths call insertText with an empty payload to
// flush state. There is no terminal text to send in that case.
guard !chars.isEmpty else { return }
#if DEBUG
if NSApp.currentEvent == nil {
dlog("ime.insertText.noEvent len=\(chars.count)")
}
#endif
// 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
// The actual terminal surface lives in the AppKit portal layer above SwiftUI.
// This empty placeholder should not be walked by the accessibility subsystem.
container.setAccessibilityRole(.none)
container.setAccessibilityElement(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() }
}
}