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