10595 lines
428 KiB
Swift
10595 lines
428 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import AppKit
|
|
import Metal
|
|
import QuartzCore
|
|
import Combine
|
|
import CoreText
|
|
import Darwin
|
|
import Sentry
|
|
import Bonsplit
|
|
import IOSurface
|
|
import UniformTypeIdentifiers
|
|
|
|
#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
|
|
|
|
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"
|
|
private static let temporaryImageFilenamePrefix = "clipboard-"
|
|
private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!)
|
|
private static let temporaryImageOwnershipLock = NSLock()
|
|
private static var ownedTemporaryImagePaths: Set<String> = []
|
|
|
|
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
|
|
}
|
|
|
|
if let value = pasteboard.string(forType: utf8PlainTextType) {
|
|
return value
|
|
}
|
|
|
|
if hasImageData(in: pasteboard),
|
|
let html = pasteboard.string(forType: .html),
|
|
htmlHasNoVisibleText(html) {
|
|
return nil
|
|
}
|
|
|
|
if let htmlText = attributedStringContents(from: pasteboard, type: .html, documentType: .html) {
|
|
return htmlText
|
|
}
|
|
|
|
if let rtfText = attributedStringContents(from: pasteboard, type: .rtf, documentType: .rtf) {
|
|
return rtfText
|
|
}
|
|
|
|
return attributedStringContents(from: pasteboard, type: .rtfd, documentType: .rtfd)
|
|
}
|
|
|
|
static func hasString(for location: ghostty_clipboard_e) -> Bool {
|
|
guard let pasteboard = pasteboard(for: location) else { return false }
|
|
let types = pasteboard.types ?? []
|
|
if types.contains(.fileURL) || types.contains(.string) || types.contains(utf8PlainTextType)
|
|
|| types.contains(.html) || types.contains(.rtf) || types.contains(.rtfd) {
|
|
return true
|
|
}
|
|
return hasImageData(in: pasteboard)
|
|
}
|
|
|
|
static func writeString(_ string: String, to location: ghostty_clipboard_e) {
|
|
guard let pasteboard = pasteboard(for: location) else { return }
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(string, forType: .string)
|
|
}
|
|
|
|
static func escapeForShell(_ value: String) -> String {
|
|
if value.contains(where: { $0 == "\n" || $0 == "\r" }) {
|
|
return shellSingleQuoted(value)
|
|
}
|
|
var result = value
|
|
for char in shellEscapeCharacters {
|
|
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func shellSingleQuoted(_ value: String) -> String {
|
|
let escaped = value.replacingOccurrences(of: "'", with: "'\\''")
|
|
return "'\(escaped)'"
|
|
}
|
|
|
|
private static func attributedStringContents(
|
|
from pasteboard: NSPasteboard,
|
|
type: NSPasteboard.PasteboardType,
|
|
documentType: NSAttributedString.DocumentType
|
|
) -> String? {
|
|
let attributed = attributedString(
|
|
from: pasteboard,
|
|
type: type,
|
|
documentType: documentType
|
|
)
|
|
|
|
let sanitized = attributed?.string
|
|
.split(separator: objectReplacementCharacter, omittingEmptySubsequences: false)
|
|
.joined(separator: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
guard let sanitized, !sanitized.isEmpty else { return nil }
|
|
return sanitized
|
|
}
|
|
|
|
private static func attributedString(
|
|
from pasteboard: NSPasteboard,
|
|
type: NSPasteboard.PasteboardType,
|
|
documentType: NSAttributedString.DocumentType
|
|
) -> NSAttributedString? {
|
|
let data =
|
|
pasteboard.data(forType: type)
|
|
?? pasteboard.string(forType: type)?.data(using: .utf8)
|
|
guard let data else { return nil }
|
|
|
|
return try? NSAttributedString(
|
|
data: data,
|
|
options: [
|
|
.documentType: documentType,
|
|
.characterEncoding: String.Encoding.utf8.rawValue
|
|
],
|
|
documentAttributes: nil
|
|
)
|
|
}
|
|
|
|
private static func rtfdAttachmentImageRepresentation(
|
|
in pasteboard: NSPasteboard
|
|
) -> (data: Data, fileExtension: String)? {
|
|
guard let attributed = attributedString(
|
|
from: pasteboard,
|
|
type: .rtfd,
|
|
documentType: .rtfd
|
|
) else { return nil }
|
|
|
|
var result: (data: Data, fileExtension: String)?
|
|
attributed.enumerateAttribute(
|
|
.attachment,
|
|
in: NSRange(location: 0, length: attributed.length)
|
|
) { value, _, stop in
|
|
guard let attachment = value as? NSTextAttachment else { return }
|
|
|
|
if let fileWrapper = attachment.fileWrapper,
|
|
let data = fileWrapper.regularFileContents,
|
|
let imageRepresentation = imageAttachmentRepresentation(
|
|
data: data,
|
|
preferredFilename: fileWrapper.preferredFilename
|
|
) {
|
|
result = imageRepresentation
|
|
stop.pointee = true
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private static func imageAttachmentRepresentation(
|
|
data: Data,
|
|
preferredFilename: String?
|
|
) -> (data: Data, fileExtension: String)? {
|
|
let pathExtension =
|
|
(preferredFilename as NSString?)?.pathExtension.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
?? ""
|
|
if let type = !pathExtension.isEmpty ? UTType(filenameExtension: pathExtension) : nil,
|
|
type.conforms(to: .image),
|
|
let fileExtension = type.preferredFilenameExtension ?? nonEmpty(pathExtension) {
|
|
return (data, fileExtension)
|
|
}
|
|
|
|
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
|
let typeIdentifier = CGImageSourceGetType(imageSource) as String?,
|
|
let type = UTType(typeIdentifier),
|
|
type.conforms(to: .image),
|
|
let fileExtension = type.preferredFilenameExtension else { return nil }
|
|
return (data, fileExtension)
|
|
}
|
|
|
|
private static func nonEmpty(_ value: String) -> String? {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private static func hasImageData(in pasteboard: NSPasteboard) -> Bool {
|
|
let types = pasteboard.types ?? []
|
|
if types.contains(.tiff) || types.contains(.png) {
|
|
return true
|
|
}
|
|
|
|
return types.contains { type in
|
|
guard let utType = UTType(type.rawValue) else { return false }
|
|
return utType.conforms(to: .image)
|
|
}
|
|
}
|
|
|
|
private static func directImageRepresentation(
|
|
in pasteboard: NSPasteboard
|
|
) -> (data: Data, fileExtension: String)? {
|
|
if let pngData = pasteboard.data(forType: .png) {
|
|
return (pngData, "png")
|
|
}
|
|
|
|
for type in pasteboard.types ?? [] {
|
|
guard type != .png,
|
|
type != .tiff,
|
|
let utType = UTType(type.rawValue),
|
|
utType.conforms(to: .image),
|
|
let imageData = pasteboard.data(forType: type),
|
|
let fileExtension = utType.preferredFilenameExtension,
|
|
!fileExtension.isEmpty else { continue }
|
|
return (imageData, fileExtension)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func htmlHasNoVisibleText(_ html: String) -> Bool {
|
|
let withoutComments = html.replacingOccurrences(
|
|
of: "<!--[\\s\\S]*?-->",
|
|
with: " ",
|
|
options: .regularExpression
|
|
)
|
|
let withoutTags = withoutComments.replacingOccurrences(
|
|
of: "<[^>]+>",
|
|
with: " ",
|
|
options: .regularExpression
|
|
)
|
|
let normalized = withoutTags
|
|
.replacingOccurrences(of: " ", with: " ")
|
|
.replacingOccurrences(of: " ", with: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return normalized.isEmpty
|
|
}
|
|
|
|
/// When the clipboard contains only image data (or rich text that resolves to
|
|
/// an attachment-only image), saves it as a temporary image file and returns the
|
|
/// file URL. Returns nil if the clipboard contains text or no image.
|
|
static func saveImageFileURLIfNeeded(
|
|
from pasteboard: NSPasteboard = .general,
|
|
assumeNoText: Bool = false
|
|
) -> URL? {
|
|
if !assumeNoText && stringContents(from: pasteboard) != nil { return nil }
|
|
|
|
let imageData: Data
|
|
let fileExtension: String
|
|
if let directImage = directImageRepresentation(in: pasteboard) {
|
|
imageData = directImage.data
|
|
fileExtension = directImage.fileExtension
|
|
} else if let rtfdAttachment = rtfdAttachmentImageRepresentation(in: pasteboard) {
|
|
imageData = rtfdAttachment.data
|
|
fileExtension = rtfdAttachment.fileExtension
|
|
} else {
|
|
guard hasImageData(in: pasteboard),
|
|
let image = NSImage(pasteboard: pasteboard),
|
|
let tiffData = image.tiffRepresentation,
|
|
let bitmap = NSBitmapImageRep(data: tiffData),
|
|
let pngData = bitmap.representation(using: .png, properties: [:]) else { return nil }
|
|
imageData = pngData
|
|
fileExtension = "png"
|
|
}
|
|
|
|
let maxClipboardImageSize = 10 * 1024 * 1024 // 10 MB
|
|
guard imageData.count <= maxClipboardImageSize else {
|
|
#if DEBUG
|
|
dlog("terminal.paste.image.rejected reason=tooLarge bytes=\(imageData.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 = "\(temporaryImageFilenamePrefix)\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
|
|
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
|
|
|
do {
|
|
try imageData.write(to: fileURL)
|
|
} catch {
|
|
#if DEBUG
|
|
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
|
|
#endif
|
|
return nil
|
|
}
|
|
|
|
registerOwnedTemporaryImageFile(fileURL)
|
|
return fileURL
|
|
}
|
|
|
|
/// When the clipboard contains only image data (or rich text that resolves to
|
|
/// an attachment-only image), saves it as a temporary image file and returns the
|
|
/// shell-escaped file path. Returns nil if the clipboard contains text or no image.
|
|
static func saveClipboardImageIfNeeded(
|
|
from pasteboard: NSPasteboard = .general,
|
|
assumeNoText: Bool = false
|
|
) -> String? {
|
|
saveImageFileURLIfNeeded(from: pasteboard, assumeNoText: assumeNoText)
|
|
.map { escapeForShell($0.path) }
|
|
}
|
|
|
|
static func cleanupTransferredTemporaryImageFiles(_ fileURLs: [URL]) {
|
|
for fileURL in fileURLs {
|
|
let normalizedURL = fileURL.standardizedFileURL
|
|
guard normalizedURL.isFileURL,
|
|
consumeOwnedTemporaryImageFile(normalizedURL) else {
|
|
continue
|
|
}
|
|
try? FileManager.default.removeItem(at: normalizedURL)
|
|
}
|
|
}
|
|
|
|
private static func registerOwnedTemporaryImageFile(_ fileURL: URL) {
|
|
let normalizedPath = fileURL.standardizedFileURL.path
|
|
temporaryImageOwnershipLock.lock()
|
|
ownedTemporaryImagePaths.insert(normalizedPath)
|
|
temporaryImageOwnershipLock.unlock()
|
|
}
|
|
|
|
private static func consumeOwnedTemporaryImageFile(_ fileURL: URL) -> Bool {
|
|
let normalizedPath = fileURL.standardizedFileURL.path
|
|
temporaryImageOwnershipLock.lock()
|
|
let didOwnFile = ownedTemporaryImagePaths.remove(normalizedPath) != nil
|
|
temporaryImageOwnershipLock.unlock()
|
|
return didOwnFile
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> String? {
|
|
GhosttyPasteboardHelper.stringContents(from: pasteboard)
|
|
}
|
|
|
|
func cmuxPasteboardImageFileURLForTesting(_ pasteboard: NSPasteboard) -> URL? {
|
|
GhosttyPasteboardHelper.saveImageFileURLIfNeeded(from: pasteboard)
|
|
}
|
|
|
|
func cmuxPasteboardImagePathForTesting(_ pasteboard: NSPasteboard) -> String? {
|
|
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: pasteboard)
|
|
}
|
|
#endif
|
|
|
|
enum TerminalOpenURLTarget: Equatable {
|
|
case embeddedBrowser(URL)
|
|
case external(URL)
|
|
|
|
var url: URL {
|
|
switch self {
|
|
case let .embeddedBrowser(url), let .external(url):
|
|
return url
|
|
}
|
|
}
|
|
}
|
|
|
|
enum GhosttyDefaultBackgroundUpdateScope: Int {
|
|
case unscoped = 0
|
|
case app = 1
|
|
case surface = 2
|
|
|
|
var logLabel: String {
|
|
switch self {
|
|
case .unscoped: return "unscoped"
|
|
case .app: return "app"
|
|
case .surface: return "surface"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Coalesces Ghostty background notifications so consumers only observe
|
|
/// the latest runtime background for a burst of updates.
|
|
final class GhosttyDefaultBackgroundNotificationDispatcher {
|
|
private let coalescer: NotificationBurstCoalescer
|
|
private let postNotification: ([AnyHashable: Any]) -> Void
|
|
private var pendingUserInfo: [AnyHashable: Any]?
|
|
private var pendingEventId: UInt64 = 0
|
|
private var pendingSource: String = "unspecified"
|
|
private let logEvent: ((String) -> Void)?
|
|
|
|
init(
|
|
delay: TimeInterval = 1.0 / 30.0,
|
|
logEvent: ((String) -> Void)? = nil,
|
|
postNotification: @escaping ([AnyHashable: Any]) -> Void = { userInfo in
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDefaultBackgroundDidChange,
|
|
object: nil,
|
|
userInfo: userInfo
|
|
)
|
|
}
|
|
) {
|
|
coalescer = NotificationBurstCoalescer(delay: delay)
|
|
self.logEvent = logEvent
|
|
self.postNotification = postNotification
|
|
}
|
|
|
|
func signal(backgroundColor: NSColor, opacity: Double, eventId: UInt64, source: String) {
|
|
let signalOnMain = { [self] in
|
|
pendingEventId = eventId
|
|
pendingSource = source
|
|
pendingUserInfo = [
|
|
GhosttyNotificationKey.backgroundColor: backgroundColor,
|
|
GhosttyNotificationKey.backgroundOpacity: opacity,
|
|
GhosttyNotificationKey.backgroundEventId: NSNumber(value: eventId),
|
|
GhosttyNotificationKey.backgroundSource: source
|
|
]
|
|
logEvent?(
|
|
"bg notify queued id=\(eventId) source=\(source) color=\(backgroundColor.hexString()) opacity=\(String(format: "%.3f", opacity))"
|
|
)
|
|
coalescer.signal { [self] in
|
|
guard let userInfo = pendingUserInfo else { return }
|
|
let eventId = pendingEventId
|
|
let source = pendingSource
|
|
pendingUserInfo = nil
|
|
logEvent?("bg notify flushed id=\(eventId) source=\(source)")
|
|
logEvent?("bg notify posting id=\(eventId) source=\(source)")
|
|
postNotification(userInfo)
|
|
logEvent?("bg notify posted id=\(eventId) source=\(source)")
|
|
}
|
|
}
|
|
|
|
if Thread.isMainThread {
|
|
signalOnMain()
|
|
} else {
|
|
DispatchQueue.main.async(execute: signalOnMain)
|
|
}
|
|
}
|
|
}
|
|
|
|
func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? {
|
|
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
#if DEBUG
|
|
dlog("link.resolve input=\(trimmed)")
|
|
#endif
|
|
guard !trimmed.isEmpty else {
|
|
#if DEBUG
|
|
dlog("link.resolve result=nil (empty)")
|
|
#endif
|
|
return nil
|
|
}
|
|
|
|
if NSString(string: trimmed).isAbsolutePath {
|
|
#if DEBUG
|
|
dlog("link.resolve result=external(absolutePath) url=\(trimmed)")
|
|
#endif
|
|
return .external(URL(fileURLWithPath: trimmed))
|
|
}
|
|
|
|
if let parsed = URL(string: trimmed),
|
|
let scheme = parsed.scheme?.lowercased() {
|
|
if scheme == "http" || scheme == "https" {
|
|
guard BrowserInsecureHTTPSettings.normalizeHost(parsed.host ?? "") != nil else {
|
|
#if DEBUG
|
|
dlog("link.resolve result=external(invalidHost) url=\(parsed)")
|
|
#endif
|
|
return .external(parsed)
|
|
}
|
|
#if DEBUG
|
|
dlog("link.resolve result=embeddedBrowser url=\(parsed)")
|
|
#endif
|
|
return .embeddedBrowser(parsed)
|
|
}
|
|
#if DEBUG
|
|
dlog("link.resolve result=external(scheme=\(scheme)) url=\(parsed)")
|
|
#endif
|
|
return .external(parsed)
|
|
}
|
|
|
|
if let webURL = resolveBrowserNavigableURL(trimmed) {
|
|
guard BrowserInsecureHTTPSettings.normalizeHost(webURL.host ?? "") != nil else {
|
|
#if DEBUG
|
|
dlog("link.resolve result=external(bareHost-invalidHost) url=\(webURL)")
|
|
#endif
|
|
return .external(webURL)
|
|
}
|
|
#if DEBUG
|
|
dlog("link.resolve result=embeddedBrowser(bareHost) url=\(webURL)")
|
|
#endif
|
|
return .embeddedBrowser(webURL)
|
|
}
|
|
|
|
guard let fallback = URL(string: trimmed) else {
|
|
#if DEBUG
|
|
dlog("link.resolve result=nil (unparseable)")
|
|
#endif
|
|
return nil
|
|
}
|
|
#if DEBUG
|
|
dlog("link.resolve result=external(fallback) url=\(fallback)")
|
|
#endif
|
|
return .external(fallback)
|
|
}
|
|
|
|
enum TerminalKeyboardCopyModeSelectionMove: String, Equatable {
|
|
case left
|
|
case right
|
|
case up
|
|
case down
|
|
case pageUp = "page_up"
|
|
case pageDown = "page_down"
|
|
case home
|
|
case end
|
|
case beginningOfLine = "beginning_of_line"
|
|
case endOfLine = "end_of_line"
|
|
}
|
|
|
|
enum TerminalKeyboardCopyModeAction: Equatable {
|
|
case exit
|
|
case startSelection
|
|
case clearSelection
|
|
case copyAndExit
|
|
case copyLineAndExit
|
|
case scrollLines(Int)
|
|
case scrollPage(Int)
|
|
case scrollHalfPage(Int)
|
|
case scrollToTop
|
|
case scrollToBottom
|
|
case jumpToPrompt(Int)
|
|
case startSearch
|
|
case searchNext
|
|
case searchPrevious
|
|
case adjustSelection(TerminalKeyboardCopyModeSelectionMove)
|
|
}
|
|
|
|
struct TerminalKeyboardCopyModeInputState: Equatable {
|
|
var countPrefix: Int?
|
|
var pendingYankLine = false
|
|
var pendingG = false
|
|
|
|
mutating func reset() {
|
|
countPrefix = nil
|
|
pendingYankLine = false
|
|
pendingG = false
|
|
}
|
|
}
|
|
|
|
enum TerminalKeyboardCopyModeResolution: Equatable {
|
|
case perform(TerminalKeyboardCopyModeAction, count: Int)
|
|
case consume
|
|
}
|
|
|
|
private let terminalKeyboardCopyModeMaxCount = 9_999
|
|
|
|
private var terminalKeyboardCopyModeIndicatorText: String {
|
|
String(localized: "ghostty.copy-mode.indicator", defaultValue: "vim")
|
|
}
|
|
|
|
private var terminalKeyTableIndicatorDefaultText: String {
|
|
String(localized: "ghostty.key-table.indicator", defaultValue: "key table")
|
|
}
|
|
|
|
private var terminalKeyTableIndicatorAccessibilityLabel: String {
|
|
String(localized: "ghostty.key-table.icon.accessibility", defaultValue: "Key table")
|
|
}
|
|
|
|
private func terminalKeyboardCopyModeClampCount(_ value: Int) -> Int {
|
|
min(max(value, 1), terminalKeyboardCopyModeMaxCount)
|
|
}
|
|
|
|
private func terminalKeyTableIndicatorText(_ name: String) -> String {
|
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
switch trimmed.lowercased() {
|
|
case "", "set":
|
|
return terminalKeyTableIndicatorDefaultText
|
|
case "vi", "vim":
|
|
return terminalKeyboardCopyModeIndicatorText
|
|
default:
|
|
let normalized = trimmed
|
|
.replacingOccurrences(of: "_", with: " ")
|
|
.replacingOccurrences(of: "-", with: " ")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return normalized.isEmpty ? terminalKeyTableIndicatorDefaultText : normalized
|
|
}
|
|
}
|
|
|
|
func terminalKeyboardCopyModeInitialViewportRow(
|
|
rows: Int,
|
|
imePointY: Double,
|
|
imeCellHeight: Double,
|
|
topPadding: Double = 0
|
|
) -> Int {
|
|
let clampedRows = max(rows, 1)
|
|
guard imeCellHeight > 0 else { return clampedRows - 1 }
|
|
|
|
// `ghostty_surface_ime_point` returns a top-origin Y coordinate at the
|
|
// cursor baseline plus one cell-height. Convert that to a zero-based row.
|
|
let estimatedRow = Int(floor(((imePointY - topPadding) / imeCellHeight) - 1))
|
|
return max(0, min(clampedRows - 1, estimatedRow))
|
|
}
|
|
|
|
private func terminalKeyboardCopyModeNormalizedModifiers(
|
|
_ modifierFlags: NSEvent.ModifierFlags
|
|
) -> NSEvent.ModifierFlags {
|
|
modifierFlags
|
|
.intersection(.deviceIndependentFlagsMask)
|
|
.subtracting([.numericPad, .function, .capsLock])
|
|
}
|
|
|
|
private func terminalKeyboardCopyModeChars(
|
|
_ charactersIgnoringModifiers: String?
|
|
) -> String {
|
|
guard let scalar = charactersIgnoringModifiers?.unicodeScalars.first else {
|
|
return ""
|
|
}
|
|
return String(scalar).lowercased()
|
|
}
|
|
|
|
func terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: NSEvent.ModifierFlags) -> Bool {
|
|
let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
|
|
return normalized.contains(.command)
|
|
}
|
|
|
|
func terminalKeyboardCopyModeAction(
|
|
keyCode: UInt16,
|
|
charactersIgnoringModifiers: String?,
|
|
modifierFlags: NSEvent.ModifierFlags,
|
|
hasSelection: Bool
|
|
) -> TerminalKeyboardCopyModeAction? {
|
|
let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
|
|
let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers)
|
|
|
|
if keyCode == 53 { // Escape
|
|
return .exit
|
|
}
|
|
|
|
switch keyCode {
|
|
case 126: // Up
|
|
return hasSelection ? .adjustSelection(.up) : .scrollLines(-1)
|
|
case 125: // Down
|
|
return hasSelection ? .adjustSelection(.down) : .scrollLines(1)
|
|
case 123: // Left
|
|
return hasSelection ? .adjustSelection(.left) : nil
|
|
case 124: // Right
|
|
return hasSelection ? .adjustSelection(.right) : nil
|
|
case 116: // Page Up
|
|
return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1)
|
|
case 121: // Page Down
|
|
return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1)
|
|
case 115: // Home
|
|
return hasSelection ? .adjustSelection(.home) : .scrollToTop
|
|
case 119: // End
|
|
return hasSelection ? .adjustSelection(.end) : .scrollToBottom
|
|
default:
|
|
break
|
|
}
|
|
|
|
if normalized == [.control] {
|
|
if chars == "u" || chars == "\u{15}" {
|
|
return hasSelection ? .adjustSelection(.pageUp) : .scrollHalfPage(-1)
|
|
}
|
|
if chars == "d" || chars == "\u{04}" {
|
|
return hasSelection ? .adjustSelection(.pageDown) : .scrollHalfPage(1)
|
|
}
|
|
if chars == "b" || chars == "\u{02}" {
|
|
return hasSelection ? .adjustSelection(.pageUp) : .scrollPage(-1)
|
|
}
|
|
if chars == "f" || chars == "\u{06}" {
|
|
return hasSelection ? .adjustSelection(.pageDown) : .scrollPage(1)
|
|
}
|
|
if chars == "y" || chars == "\u{19}" {
|
|
return hasSelection ? .adjustSelection(.up) : .scrollLines(-1)
|
|
}
|
|
if chars == "e" || chars == "\u{05}" {
|
|
return hasSelection ? .adjustSelection(.down) : .scrollLines(1)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
guard normalized.isEmpty || normalized == [.shift] else { return nil }
|
|
|
|
switch chars {
|
|
case "q":
|
|
return .exit
|
|
case "v":
|
|
return hasSelection ? .clearSelection : .startSelection
|
|
case "y":
|
|
if normalized == [.shift], !hasSelection {
|
|
return .copyLineAndExit
|
|
}
|
|
return hasSelection ? .copyAndExit : nil
|
|
case "j":
|
|
return hasSelection ? .adjustSelection(.down) : .scrollLines(1)
|
|
case "k":
|
|
return hasSelection ? .adjustSelection(.up) : .scrollLines(-1)
|
|
case "h":
|
|
return hasSelection ? .adjustSelection(.left) : nil
|
|
case "l":
|
|
return hasSelection ? .adjustSelection(.right) : nil
|
|
case "g":
|
|
if normalized == [.shift] {
|
|
return hasSelection ? .adjustSelection(.end) : .scrollToBottom
|
|
}
|
|
// Bare "g" is a prefix key (e.g. gg); handled in resolve.
|
|
return nil
|
|
case "0", "^":
|
|
return hasSelection ? .adjustSelection(.beginningOfLine) : nil
|
|
case "$", "4":
|
|
guard chars == "$" || normalized == [.shift] else { return nil }
|
|
return hasSelection ? .adjustSelection(.endOfLine) : nil
|
|
case "{", "[":
|
|
guard chars == "{" || normalized == [.shift] else { return nil }
|
|
return .jumpToPrompt(-1)
|
|
case "}", "]":
|
|
guard chars == "}" || normalized == [.shift] else { return nil }
|
|
return .jumpToPrompt(1)
|
|
case "/":
|
|
return .startSearch
|
|
case "n":
|
|
return normalized == [.shift] ? .searchPrevious : .searchNext
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func terminalKeyboardCopyModeResolve(
|
|
keyCode: UInt16,
|
|
charactersIgnoringModifiers: String?,
|
|
modifierFlags: NSEvent.ModifierFlags,
|
|
hasSelection: Bool,
|
|
state: inout TerminalKeyboardCopyModeInputState
|
|
) -> TerminalKeyboardCopyModeResolution {
|
|
let normalized = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
|
|
let chars = terminalKeyboardCopyModeChars(charactersIgnoringModifiers)
|
|
|
|
if keyCode == 53 { // Escape
|
|
state.reset()
|
|
return .perform(.exit, count: 1)
|
|
}
|
|
|
|
if state.pendingYankLine {
|
|
if chars == "y", normalized.isEmpty || normalized == [.shift] {
|
|
let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1)
|
|
state.reset()
|
|
return .perform(.copyLineAndExit, count: count)
|
|
}
|
|
// Only `yy`/`Y` are supported as line-yank operators, so cancel the
|
|
// pending yank and treat this key as a fresh command.
|
|
state.pendingYankLine = false
|
|
}
|
|
|
|
if state.pendingG {
|
|
if chars == "g", normalized.isEmpty {
|
|
let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1)
|
|
let action: TerminalKeyboardCopyModeAction = hasSelection ? .adjustSelection(.home) : .scrollToTop
|
|
state.reset()
|
|
return .perform(action, count: count)
|
|
}
|
|
// Not `gg`, cancel and treat as fresh command.
|
|
state.pendingG = false
|
|
}
|
|
|
|
if normalized.isEmpty,
|
|
let scalar = chars.unicodeScalars.first,
|
|
scalar.isASCII,
|
|
scalar.value >= 48,
|
|
scalar.value <= 57 {
|
|
let digit = Int(scalar.value - 48)
|
|
if digit == 0 {
|
|
if let currentCount = state.countPrefix {
|
|
state.countPrefix = terminalKeyboardCopyModeClampCount(currentCount * 10)
|
|
return .consume
|
|
}
|
|
} else {
|
|
let currentCount = state.countPrefix ?? 0
|
|
state.countPrefix = terminalKeyboardCopyModeClampCount((currentCount * 10) + digit)
|
|
return .consume
|
|
}
|
|
}
|
|
|
|
if !hasSelection, chars == "y", normalized.isEmpty {
|
|
state.pendingYankLine = true
|
|
return .consume
|
|
}
|
|
|
|
if chars == "g", normalized.isEmpty {
|
|
state.pendingG = true
|
|
return .consume
|
|
}
|
|
|
|
guard let action = terminalKeyboardCopyModeAction(
|
|
keyCode: keyCode,
|
|
charactersIgnoringModifiers: charactersIgnoringModifiers,
|
|
modifierFlags: modifierFlags,
|
|
hasSelection: hasSelection
|
|
) else {
|
|
state.reset()
|
|
return .consume
|
|
}
|
|
|
|
let count = terminalKeyboardCopyModeClampCount(state.countPrefix ?? 1)
|
|
state.reset()
|
|
return .perform(action, count: count)
|
|
}
|
|
|
|
private final class GhosttySurfaceCallbackContext {
|
|
weak var surfaceView: GhosttyNSView?
|
|
weak var terminalSurface: TerminalSurface?
|
|
let surfaceId: UUID
|
|
|
|
init(surfaceView: GhosttyNSView, terminalSurface: TerminalSurface) {
|
|
self.surfaceView = surfaceView
|
|
self.terminalSurface = terminalSurface
|
|
self.surfaceId = terminalSurface.id
|
|
}
|
|
|
|
var tabId: UUID? {
|
|
terminalSurface?.tabId ?? surfaceView?.tabId
|
|
}
|
|
|
|
var runtimeSurface: ghostty_surface_t? {
|
|
terminalSurface?.surface ?? surfaceView?.terminalSurface?.surface
|
|
}
|
|
}
|
|
|
|
// Minimal Ghostty wrapper for terminal rendering
|
|
// This uses libghostty (GhosttyKit.xcframework) for actual terminal emulation
|
|
|
|
// MARK: - Ghostty App Singleton
|
|
|
|
class GhosttyApp {
|
|
static let shared = GhosttyApp()
|
|
private static let releaseBundleIdentifier = "com.cmuxterm.app"
|
|
private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter
|
|
}()
|
|
|
|
private(set) var app: ghostty_app_t?
|
|
private(set) var config: ghostty_config_t?
|
|
/// Coalesce wakeup → tick dispatches. The I/O thread may fire wakeup_cb
|
|
/// thousands of times per second during bulk output. We only need one
|
|
/// pending tick on the main queue at any time.
|
|
private var _tickScheduled = false
|
|
private let _tickLock = NSLock()
|
|
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 bellAudioSound: NSSound?
|
|
private var backgroundEventCounter: UInt64 = 0
|
|
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
|
|
private var defaultBackgroundScopeSource: String = "initialize"
|
|
private(set) var userConfigDefinesShiftEnterBinding = false
|
|
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(Data(line.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
|
|
GhosttyApp.shared.scheduleTick()
|
|
}
|
|
runtimeConfig.action_cb = { app, target, action in
|
|
return GhosttyApp.shared.handleAction(target: target, action: action)
|
|
}
|
|
runtimeConfig.read_clipboard_cb = { userdata, location, state in
|
|
guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
guard let requestSurface = callbackContext.runtimeSurface else { return }
|
|
|
|
func completeClipboardRequest(with text: String) {
|
|
let finish = {
|
|
guard callbackContext.runtimeSurface == requestSurface else { return }
|
|
text.withCString { ptr in
|
|
ghostty_surface_complete_clipboard_request(requestSurface, ptr, state, false)
|
|
}
|
|
}
|
|
if Thread.isMainThread {
|
|
finish()
|
|
} else {
|
|
DispatchQueue.main.async(execute: finish)
|
|
}
|
|
}
|
|
|
|
guard let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location) else {
|
|
completeClipboardRequest(with: "")
|
|
return
|
|
}
|
|
|
|
let preparedContent = TerminalImageTransferPlanner.prepare(
|
|
pasteboard: pasteboard,
|
|
mode: .paste
|
|
)
|
|
|
|
switch preparedContent {
|
|
case .reject:
|
|
completeClipboardRequest(with: "")
|
|
case .insertText(let text):
|
|
completeClipboardRequest(with: text)
|
|
case .fileURLs(let fileURLs):
|
|
let operation = TerminalImageTransferOperation()
|
|
MainActor.assumeIsolated {
|
|
callbackContext.terminalSurface?.hostedView.beginImageTransferIndicator(
|
|
for: operation,
|
|
onCancel: {
|
|
completeClipboardRequest(with: "")
|
|
}
|
|
)
|
|
}
|
|
|
|
let target = MainActor.assumeIsolated {
|
|
callbackContext.terminalSurface?.resolvedImageTransferTarget() ?? .local
|
|
}
|
|
let plan = TerminalImageTransferPlanner.plan(
|
|
fileURLs: fileURLs,
|
|
target: target
|
|
)
|
|
|
|
TerminalImageTransferPlanner.execute(
|
|
plan: plan,
|
|
operation: operation,
|
|
uploadWorkspaceRemote: { fileURLs, operation, finish in
|
|
guard let workspace = MainActor.assumeIsolated({
|
|
callbackContext.terminalSurface?.owningWorkspace()
|
|
}) else {
|
|
finish(.failure(NSError(domain: "cmux.remote.paste", code: 3)))
|
|
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
|
|
return
|
|
}
|
|
workspace.uploadDroppedFilesForRemoteTerminal(
|
|
fileURLs,
|
|
operation: operation,
|
|
completion: { result in
|
|
finish(result)
|
|
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
|
|
}
|
|
)
|
|
},
|
|
uploadDetectedSSH: { session, fileURLs, operation, finish in
|
|
session.uploadDroppedFiles(
|
|
fileURLs,
|
|
operation: operation,
|
|
completion: { result in
|
|
finish(result)
|
|
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
|
|
}
|
|
)
|
|
},
|
|
insertText: { text in
|
|
MainActor.assumeIsolated {
|
|
callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(
|
|
for: operation
|
|
)
|
|
}
|
|
completeClipboardRequest(with: text)
|
|
},
|
|
onFailure: { _ in
|
|
MainActor.assumeIsolated {
|
|
callbackContext.terminalSurface?.hostedView.endImageTransferIndicator(
|
|
for: operation
|
|
)
|
|
}
|
|
NSSound.beep()
|
|
#if DEBUG
|
|
dlog("terminal.remotePasteUpload.failed surface=\(callbackContext.surfaceId.uuidString.prefix(5))")
|
|
#endif
|
|
completeClipboardRequest(with: "")
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
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 loadInlineGhosttyConfig(
|
|
_ contents: String,
|
|
into config: ghostty_config_t,
|
|
prefix: String,
|
|
logLabel: String
|
|
) {
|
|
let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
|
|
let tmpURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("\(prefix)-\(UUID().uuidString).conf")
|
|
do {
|
|
try trimmed.write(to: tmpURL, atomically: true, encoding: .utf8)
|
|
defer { try? FileManager.default.removeItem(at: tmpURL) }
|
|
tmpURL.path.withCString { path in
|
|
ghostty_config_load_file(config, path)
|
|
}
|
|
} catch {
|
|
#if DEBUG
|
|
dlog("ghostty.config.inlineLoad.failed label=\(logLabel) error=\(error.localizedDescription)")
|
|
#endif
|
|
}
|
|
}
|
|
|
|
private func loadCopyOnSelectOverride(_ config: ghostty_config_t) {
|
|
loadInlineGhosttyConfig(
|
|
TerminalCopyOnSelectSettings.overrideConfigLine(),
|
|
into: config,
|
|
prefix: "cmux-copy-on-select",
|
|
logLabel: "copy-on-select override"
|
|
)
|
|
}
|
|
|
|
private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) {
|
|
ghostty_config_load_default_files(config)
|
|
loadLegacyGhosttyConfigIfNeeded(config)
|
|
ghostty_config_load_recursive_files(config)
|
|
loadCmuxAppSupportGhosttyConfigIfNeeded(config)
|
|
userConfigDefinesShiftEnterBinding = Self.userConfigDefinesShiftEnterBinding()
|
|
loadCopyOnSelectOverride(config)
|
|
loadCJKFontFallbackIfNeeded(config)
|
|
ghostty_config_finalize(config)
|
|
}
|
|
|
|
/// When the user has not configured `font-codepoint-map` for CJK ranges
|
|
/// and has not already provided an explicit multi-entry `font-family`
|
|
/// fallback chain, Ghostty's `CTFontCollection` scoring may pick an
|
|
/// inappropriate fallback font for Hiragana, Katakana, and CJK symbols.
|
|
/// The scoring prioritizes monospace fonts, so decorative fonts with
|
|
/// monospace attributes (e.g. AB_appare from Adobe CC, or LingWai) can be
|
|
/// selected depending on what is installed. This injects a sensible
|
|
/// default based on the system's preferred languages without overriding
|
|
/// user-managed fallback chains or configured fonts that already cover
|
|
/// the affected CJK ranges.
|
|
///
|
|
/// See: https://github.com/manaflow-ai/cmux/pull/1017
|
|
private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) {
|
|
guard let mappings = Self.autoInjectedCJKFontMappings() else { return }
|
|
|
|
let lines = mappings.map { range, font in
|
|
"font-codepoint-map = \(range)=\(font)"
|
|
}.joined(separator: "\n")
|
|
loadInlineGhosttyConfig(
|
|
lines,
|
|
into: config,
|
|
prefix: "cmux-cjk-font-fallback",
|
|
logLabel: "CJK font fallback"
|
|
)
|
|
}
|
|
|
|
/// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms).
|
|
private static let sharedCJKRanges = [
|
|
"U+3000-U+303F", // CJK Symbols and Punctuation
|
|
"U+4E00-U+9FFF", // CJK Unified Ideographs
|
|
"U+F900-U+FAFF", // CJK Compatibility Ideographs
|
|
"U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms
|
|
"U+3400-U+4DBF", // CJK Unified Ideographs Extension A
|
|
]
|
|
|
|
/// Unicode ranges specific to Japanese (kana).
|
|
private static let japaneseRanges = [
|
|
"U+3040-U+309F", // Hiragana
|
|
"U+30A0-U+30FF", // Katakana
|
|
]
|
|
|
|
/// Representative scalars used to detect whether the configured primary
|
|
/// font already covers the ranges cmux would otherwise auto-map.
|
|
private static let cjkCoverageSampleCharactersByRange: [String: [UniChar]] = [
|
|
"U+3000-U+303F": [0x3001, 0x300C],
|
|
"U+4E00-U+9FFF": [0x4E00, 0x65E5, 0x6C34],
|
|
"U+F900-U+FAFF": [0xF900],
|
|
"U+FF00-U+FFEF": [0xFF10, 0xFF21],
|
|
"U+3400-U+4DBF": [0x3400],
|
|
"U+1100-U+11FF": [0x1100, 0x1161],
|
|
"U+3130-U+318F": [0x3131, 0x314F],
|
|
"U+3040-U+309F": [0x3042, 0x3093],
|
|
"U+30A0-U+30FF": [0x30A2, 0x30F3],
|
|
"U+AC00-U+D7AF": [0xAC00, 0xD55C],
|
|
]
|
|
|
|
private struct UserFontConfigSummary {
|
|
var containsCodepointMap = false
|
|
var effectiveFontFamilies: [String] = []
|
|
|
|
var hasExplicitFontFamilyFallbackChain: Bool {
|
|
effectiveFontFamilies.count > 1
|
|
}
|
|
|
|
mutating func applyFontCodepointMap(_ value: String) {
|
|
if value.isEmpty {
|
|
containsCodepointMap = false
|
|
return
|
|
}
|
|
|
|
guard value.contains("=") else {
|
|
return
|
|
}
|
|
|
|
containsCodepointMap = true
|
|
}
|
|
|
|
mutating func recordFontFamily(_ value: String) {
|
|
if value.isEmpty {
|
|
effectiveFontFamilies.removeAll()
|
|
return
|
|
}
|
|
|
|
guard !effectiveFontFamilies.contains(value) else {
|
|
return
|
|
}
|
|
|
|
effectiveFontFamilies.append(value)
|
|
}
|
|
}
|
|
|
|
/// Returns (range, font) pairs for CJK font fallback based on the system's
|
|
/// preferred languages, or nil if no CJK language is detected. Each language
|
|
/// only maps its own script ranges to avoid assigning glyphs to a font that
|
|
/// lacks coverage (e.g. Hangul to Hiragino Sans).
|
|
static func cjkFontMappings(
|
|
preferredLanguages: [String] = Locale.preferredLanguages
|
|
) -> [(String, String)]? {
|
|
var mappings: [(String, String)] = []
|
|
var coveredShared = false
|
|
|
|
for lang in preferredLanguages {
|
|
let lower = lang.lowercased()
|
|
let font: String
|
|
var langRanges: [String] = []
|
|
|
|
if lower.hasPrefix("ja") {
|
|
font = "Hiragino Sans"
|
|
langRanges = japaneseRanges
|
|
} else if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") {
|
|
font = "PingFang TC"
|
|
} else if lower.hasPrefix("zh") {
|
|
font = "PingFang SC"
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
if !coveredShared {
|
|
for range in sharedCJKRanges {
|
|
mappings.append((range, font))
|
|
}
|
|
coveredShared = true
|
|
}
|
|
|
|
for range in langRanges {
|
|
mappings.append((range, font))
|
|
}
|
|
}
|
|
|
|
return mappings.isEmpty ? nil : mappings
|
|
}
|
|
|
|
/// Returns only the CJK mappings cmux should auto-inject after respecting
|
|
/// explicit user overrides and the glyph coverage of the configured
|
|
/// primary font family.
|
|
static func autoInjectedCJKFontMappings(
|
|
preferredLanguages: [String] = Locale.preferredLanguages,
|
|
configPaths: [String] = loadedCJKScanPaths(),
|
|
rangeCoverageProbe: ((String, String) -> Bool)? = nil
|
|
) -> [(String, String)]? {
|
|
guard var mappings = cjkFontMappings(preferredLanguages: preferredLanguages) else { return nil }
|
|
|
|
let summary = userFontConfigSummary(configPaths: configPaths)
|
|
if summary.containsCodepointMap || summary.hasExplicitFontFamilyFallbackChain {
|
|
return nil
|
|
}
|
|
|
|
guard let configuredFontFamily = summary.effectiveFontFamilies.first else {
|
|
return mappings
|
|
}
|
|
|
|
if let rangeCoverageProbe {
|
|
mappings.removeAll { range, _ in
|
|
rangeCoverageProbe(configuredFontFamily, range)
|
|
}
|
|
} else if let configuredFont = configuredCTFont(named: configuredFontFamily) {
|
|
mappings.removeAll { range, _ in
|
|
fontContainsGlyphs(configuredFont, forRange: range)
|
|
}
|
|
}
|
|
|
|
return mappings.isEmpty ? nil : mappings
|
|
}
|
|
|
|
/// Checks whether the user's Ghostty config files already contain
|
|
/// a `font-codepoint-map` entry covering CJK ranges. Also checks
|
|
/// application-support config paths that cmux may load at runtime.
|
|
static func userConfigContainsCJKCodepointMap(
|
|
configPaths: [String] = loadedCJKScanPaths()
|
|
) -> Bool {
|
|
userFontConfigSummary(configPaths: configPaths).containsCodepointMap
|
|
}
|
|
|
|
static func userConfigHasExplicitFontFamilyFallbackChain(
|
|
configPaths: [String] = loadedCJKScanPaths()
|
|
) -> Bool {
|
|
userFontConfigSummary(configPaths: configPaths).hasExplicitFontFamilyFallbackChain
|
|
}
|
|
|
|
static func shouldInjectCJKFontFallback(
|
|
preferredLanguages: [String] = Locale.preferredLanguages,
|
|
configPaths: [String] = loadedCJKScanPaths(),
|
|
rangeCoverageProbe: ((String, String) -> Bool)? = nil
|
|
) -> Bool {
|
|
autoInjectedCJKFontMappings(
|
|
preferredLanguages: preferredLanguages,
|
|
configPaths: configPaths,
|
|
rangeCoverageProbe: rangeCoverageProbe
|
|
) != nil
|
|
}
|
|
|
|
static func userConfigDefinesShiftEnterBinding(
|
|
configPaths: [String] = loadedCJKScanPaths()
|
|
) -> Bool {
|
|
userShiftEnterConfigSummary(configPaths: configPaths).containsExplicitShiftEnterDirective
|
|
}
|
|
|
|
private static func configuredCTFont(
|
|
named name: String,
|
|
size: CGFloat = 12
|
|
) -> CTFont? {
|
|
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
|
|
let font = CTFontCreateWithName(trimmed as CFString, size, nil)
|
|
let normalizedRequestedName = normalizedFontName(trimmed)
|
|
let resolvedNames = [
|
|
kCTFontFamilyNameKey,
|
|
kCTFontFullNameKey,
|
|
kCTFontPostScriptNameKey,
|
|
].compactMap { CTFontCopyName(font, $0) as String? }
|
|
|
|
guard resolvedNames.contains(where: { normalizedFontName($0) == normalizedRequestedName }) else {
|
|
return nil
|
|
}
|
|
|
|
return font
|
|
}
|
|
|
|
private static func fontContainsGlyphs(
|
|
_ font: CTFont,
|
|
forRange range: String
|
|
) -> Bool {
|
|
guard let characters = cjkCoverageSampleCharactersByRange[range] else {
|
|
return false
|
|
}
|
|
|
|
var glyphs = Array(repeating: CGGlyph(), count: characters.count)
|
|
let hasGlyphs = CTFontGetGlyphsForCharacters(font, characters, &glyphs, characters.count)
|
|
return hasGlyphs && !glyphs.contains(0)
|
|
}
|
|
|
|
private static func normalizedFontName(_ name: String) -> String {
|
|
name
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.split(whereSeparator: \.isWhitespace)
|
|
.joined(separator: " ")
|
|
.folding(options: [.diacriticInsensitive, .widthInsensitive], locale: Locale(identifier: "en_US_POSIX"))
|
|
.lowercased()
|
|
}
|
|
|
|
private static func userFontConfigSummary(
|
|
configPaths: [String] = loadedCJKScanPaths()
|
|
) -> UserFontConfigSummary {
|
|
var summary = UserFontConfigSummary()
|
|
var recursiveConfigPaths: [String] = []
|
|
|
|
for path in configPaths.map({ NSString(string: $0).expandingTildeInPath }) {
|
|
scanFontConfigFile(
|
|
atPath: path,
|
|
summary: &summary,
|
|
recursiveConfigPaths: &recursiveConfigPaths
|
|
)
|
|
}
|
|
|
|
var loadedRecursivePaths = Set<String>()
|
|
var index = 0
|
|
while index < recursiveConfigPaths.count {
|
|
let path = recursiveConfigPaths[index]
|
|
index += 1
|
|
let resolved = (path as NSString).standardizingPath
|
|
guard !loadedRecursivePaths.contains(resolved) else { continue }
|
|
loadedRecursivePaths.insert(resolved)
|
|
|
|
scanFontConfigFile(
|
|
atPath: path,
|
|
summary: &summary,
|
|
recursiveConfigPaths: &recursiveConfigPaths
|
|
)
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
private struct UserShiftEnterConfigSummary {
|
|
var containsExplicitShiftEnterDirective = false
|
|
|
|
mutating func recordKeybind(_ value: String) {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if trimmed.isEmpty || trimmed == "clear" {
|
|
containsExplicitShiftEnterDirective = false
|
|
return
|
|
}
|
|
if GhosttyApp.keybindDirectiveTargetsShiftEnter(value) {
|
|
containsExplicitShiftEnterDirective = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func userShiftEnterConfigSummary(
|
|
configPaths: [String] = loadedCJKScanPaths()
|
|
) -> UserShiftEnterConfigSummary {
|
|
var summary = UserShiftEnterConfigSummary()
|
|
var recursiveConfigPaths: [String] = []
|
|
|
|
for path in configPaths.map({ NSString(string: $0).expandingTildeInPath }) {
|
|
scanShiftEnterConfigFile(
|
|
atPath: path,
|
|
summary: &summary,
|
|
recursiveConfigPaths: &recursiveConfigPaths
|
|
)
|
|
}
|
|
|
|
var loadedRecursivePaths = Set<String>()
|
|
var index = 0
|
|
while index < recursiveConfigPaths.count {
|
|
let path = NSString(string: recursiveConfigPaths[index]).expandingTildeInPath
|
|
index += 1
|
|
|
|
let resolved = (path as NSString).standardizingPath
|
|
guard !loadedRecursivePaths.contains(resolved) else { continue }
|
|
loadedRecursivePaths.insert(resolved)
|
|
|
|
scanShiftEnterConfigFile(
|
|
atPath: path,
|
|
summary: &summary,
|
|
recursiveConfigPaths: &recursiveConfigPaths
|
|
)
|
|
}
|
|
|
|
return summary
|
|
}
|
|
|
|
/// Returns the top-level config paths that cmux will actually load before
|
|
/// recursive `config-file` processing.
|
|
static func loadedCJKScanPaths(
|
|
currentBundleIdentifier: String? = Bundle.main.bundleIdentifier,
|
|
appSupportDirectory: URL? = FileManager.default.urls(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask
|
|
).first
|
|
) -> [String] {
|
|
var paths = [
|
|
"~/.config/ghostty/config",
|
|
"~/.config/ghostty/config.ghostty",
|
|
]
|
|
|
|
guard let bundleId = currentBundleIdentifier,
|
|
!bundleId.isEmpty,
|
|
let appSupportDirectory else { return paths }
|
|
|
|
let appSupportConfigURLs = cmuxAppSupportConfigURLs(
|
|
currentBundleIdentifier: bundleId,
|
|
appSupportDirectory: appSupportDirectory
|
|
)
|
|
paths.append(contentsOf: appSupportConfigURLs.map(\.path))
|
|
|
|
let releaseDir = appSupportDirectory.appendingPathComponent(releaseBundleIdentifier, isDirectory: true)
|
|
let releaseLegacyConfig = releaseDir.appendingPathComponent("config", isDirectory: false)
|
|
let releaseConfig = releaseDir.appendingPathComponent("config.ghostty", isDirectory: false)
|
|
|
|
let releaseConfigSize = configFileSize(at: releaseConfig)
|
|
let releaseLegacyConfigSize = configFileSize(at: releaseLegacyConfig)
|
|
|
|
if shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: releaseConfigSize,
|
|
legacyConfigFileSize: releaseLegacyConfigSize
|
|
), !paths.contains(releaseLegacyConfig.path) {
|
|
paths.append(releaseLegacyConfig.path)
|
|
}
|
|
|
|
return paths
|
|
}
|
|
|
|
private static func configFileSize(at url: URL) -> Int? {
|
|
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
|
let size = attrs[.size] as? NSNumber else { return nil }
|
|
return size.intValue
|
|
}
|
|
|
|
/// Scans a single config file for font settings relevant to cmux's
|
|
/// injected CJK fallback and updates the pending recursive config-file
|
|
/// queue using Ghostty's repeatable path semantics.
|
|
private static func scanFontConfigFile(
|
|
atPath path: String,
|
|
summary: inout UserFontConfigSummary,
|
|
recursiveConfigPaths: inout [String]
|
|
) {
|
|
let resolved = (path as NSString).standardizingPath
|
|
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
|
|
return
|
|
}
|
|
let parentDir = (resolved as NSString).deletingLastPathComponent
|
|
|
|
for line in contents.components(separatedBy: .newlines) {
|
|
guard let entry = parsedConfigEntry(from: line) else { continue }
|
|
|
|
switch entry.key {
|
|
case "font-codepoint-map":
|
|
guard let value = entry.value else { continue }
|
|
summary.applyFontCodepointMap(value)
|
|
case "font-family":
|
|
guard let value = entry.value else { continue }
|
|
summary.recordFontFamily(value)
|
|
case "config-file":
|
|
guard let value = entry.value else { continue }
|
|
applyConfigFileDirective(
|
|
value,
|
|
parentDir: parentDir,
|
|
recursiveConfigPaths: &recursiveConfigPaths
|
|
)
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func scanShiftEnterConfigFile(
|
|
atPath path: String,
|
|
summary: inout UserShiftEnterConfigSummary,
|
|
recursiveConfigPaths: inout [String]
|
|
) {
|
|
let resolved = (path as NSString).standardizingPath
|
|
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
|
|
return
|
|
}
|
|
let parentDir = (resolved as NSString).deletingLastPathComponent
|
|
|
|
for line in contents.components(separatedBy: .newlines) {
|
|
guard let entry = parsedConfigEntry(from: line) else { continue }
|
|
|
|
switch entry.key {
|
|
case "keybind":
|
|
guard let value = entry.value else { continue }
|
|
summary.recordKeybind(value)
|
|
case "config-file":
|
|
guard let value = entry.value else { continue }
|
|
applyConfigFileDirective(
|
|
value,
|
|
parentDir: parentDir,
|
|
recursiveConfigPaths: &recursiveConfigPaths
|
|
)
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func parsedConfigEntry(
|
|
from rawLine: String
|
|
) -> (key: String, value: String?)? {
|
|
var trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.hasPrefix("\u{FEFF}") {
|
|
trimmed.removeFirst()
|
|
}
|
|
if trimmed.isEmpty || trimmed.hasPrefix("#") { return nil }
|
|
|
|
guard let separatorIndex = trimmed.firstIndex(of: "=") else {
|
|
return (trimmed.trimmingCharacters(in: .whitespacesAndNewlines), nil)
|
|
}
|
|
|
|
let key = trimmed[..<separatorIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
|
var value = trimmed[trimmed.index(after: separatorIndex)...]
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") {
|
|
value.removeFirst()
|
|
value.removeLast()
|
|
}
|
|
|
|
return (String(key), String(value))
|
|
}
|
|
|
|
private static func applyConfigFileDirective(
|
|
_ value: String,
|
|
parentDir: String,
|
|
recursiveConfigPaths: inout [String]
|
|
) {
|
|
if value.isEmpty {
|
|
recursiveConfigPaths.removeAll()
|
|
return
|
|
}
|
|
|
|
var includePath = value
|
|
if includePath.hasPrefix("?") {
|
|
includePath.removeFirst()
|
|
}
|
|
if includePath.count >= 2, includePath.hasPrefix("\""), includePath.hasSuffix("\"") {
|
|
includePath.removeFirst()
|
|
includePath.removeLast()
|
|
}
|
|
guard !includePath.isEmpty else { return }
|
|
|
|
let expanded = NSString(string: includePath).expandingTildeInPath
|
|
let absolute = (expanded as NSString).isAbsolutePath
|
|
? expanded
|
|
: (parentDir as NSString).appendingPathComponent(expanded)
|
|
recursiveConfigPaths.append(absolute)
|
|
}
|
|
|
|
private static func keybindDirectiveTargetsShiftEnter(_ value: String) -> Bool {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty,
|
|
trimmed.lowercased() != "clear",
|
|
let separatorIndex = trimmed.firstIndex(of: "=") else {
|
|
return false
|
|
}
|
|
|
|
let triggerExpression = String(trimmed[..<separatorIndex])
|
|
for rawPart in triggerExpression.split(separator: ">") {
|
|
var part = String(rawPart).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !part.isEmpty else { continue }
|
|
|
|
if let slashIndex = part.lastIndex(of: "/") {
|
|
part = String(part[part.index(after: slashIndex)...])
|
|
}
|
|
|
|
for prefix in ["all:", "global:", "unconsumed:", "performable:"] {
|
|
while part.hasPrefix(prefix) {
|
|
part.removeFirst(prefix.count)
|
|
}
|
|
}
|
|
|
|
let tokens = part
|
|
.split(separator: "+")
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
|
|
guard tokens.count == 2, tokens.contains("shift") else { continue }
|
|
guard let keyToken = tokens.first(where: { $0 != "shift" }) else { continue }
|
|
switch keyToken {
|
|
case "enter", "return", "kp_enter", "physical:enter", "physical:return", "physical:kp_enter":
|
|
return true
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
static func shouldRemapShiftEnterForTmux(
|
|
keyCode: UInt16,
|
|
modifierFlags: NSEvent.ModifierFlags,
|
|
isInsideTmux: Bool,
|
|
userConfigDefinesShiftEnterBinding: Bool,
|
|
ghosttyHasBinding: Bool,
|
|
hasMarkedText: Bool
|
|
) -> Bool {
|
|
guard isInsideTmux else { return false }
|
|
guard !userConfigDefinesShiftEnterBinding else { return false }
|
|
guard !ghosttyHasBinding else { return false }
|
|
guard !hasMarkedText else { return false }
|
|
|
|
let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(modifierFlags)
|
|
guard normalizedModifiers == [.shift] else { return false }
|
|
return keyCode == 36 || keyCode == 76
|
|
}
|
|
|
|
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 cmuxAppSupportConfigURLs(
|
|
currentBundleIdentifier: String?,
|
|
appSupportDirectory: URL,
|
|
fileManager: FileManager = .default
|
|
) -> [URL] {
|
|
guard let currentBundleIdentifier, !currentBundleIdentifier.isEmpty else { return [] }
|
|
|
|
func existingConfigURLs(for bundleIdentifier: String) -> [URL] {
|
|
let directory = appSupportDirectory.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
|
return [
|
|
directory.appendingPathComponent("config", isDirectory: false),
|
|
directory.appendingPathComponent("config.ghostty", isDirectory: false)
|
|
].filter { url in
|
|
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
|
|
let type = attrs[.type] as? FileAttributeType,
|
|
type == .typeRegular,
|
|
let size = attrs[.size] as? NSNumber else {
|
|
return false
|
|
}
|
|
return size.intValue > 0
|
|
}
|
|
}
|
|
|
|
let currentURLs = existingConfigURLs(for: currentBundleIdentifier)
|
|
if !currentURLs.isEmpty {
|
|
return currentURLs
|
|
}
|
|
if SocketControlSettings.isDebugLikeBundleIdentifier(currentBundleIdentifier) {
|
|
let releaseURLs = existingConfigURLs(for: releaseBundleIdentifier)
|
|
if !releaseURLs.isEmpty {
|
|
return releaseURLs
|
|
}
|
|
}
|
|
return []
|
|
}
|
|
|
|
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 loadCmuxAppSupportGhosttyConfigIfNeeded(_ 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 urls = Self.cmuxAppSupportConfigURLs(
|
|
currentBundleIdentifier: currentBundleIdentifier,
|
|
appSupportDirectory: appSupport,
|
|
fileManager: fm
|
|
)
|
|
guard !urls.isEmpty else { return }
|
|
|
|
for url in urls {
|
|
url.path.withCString { path in
|
|
ghostty_config_load_file(config, path)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"loaded cmux app support ghostty config from: \(urls.map(\.path).joined(separator: ", "))"
|
|
)
|
|
#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
|
|
}
|
|
|
|
/// Schedule a single tick on the main queue, coalescing multiple wakeups.
|
|
func scheduleTick() {
|
|
_tickLock.lock()
|
|
defer { _tickLock.unlock() }
|
|
guard !_tickScheduled else { return }
|
|
_tickScheduled = true
|
|
DispatchQueue.main.async {
|
|
self.tick()
|
|
}
|
|
}
|
|
|
|
func tick() {
|
|
_tickLock.lock()
|
|
_tickScheduled = false
|
|
_tickLock.unlock()
|
|
|
|
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
|
|
}
|
|
|
|
func appleScriptAutomationEnabled() -> Bool {
|
|
guard let config else { return false }
|
|
var enabled = false
|
|
let key = "macos-applescript"
|
|
_ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8)))
|
|
return enabled
|
|
}
|
|
|
|
fileprivate func shellIntegrationMode() -> String {
|
|
guard let config else { return "detect" }
|
|
var value: UnsafePointer<Int8>?
|
|
let key = "shell-integration"
|
|
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
|
|
let value else {
|
|
return "detect"
|
|
}
|
|
return String(cString: value)
|
|
}
|
|
|
|
private func bellFeatures() -> CUnsignedInt {
|
|
guard let config else { return 0 }
|
|
var features: CUnsignedInt = 0
|
|
let key = "bell-features"
|
|
_ = ghostty_config_get(config, &features, key, UInt(key.lengthOfBytes(using: .utf8)))
|
|
return features
|
|
}
|
|
|
|
private func bellAudioPath() -> String? {
|
|
guard let config else { return nil }
|
|
var value = ghostty_config_path_s()
|
|
let key = "bell-audio-path"
|
|
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
|
|
let rawPath = value.path else {
|
|
return nil
|
|
}
|
|
let path = String(cString: rawPath)
|
|
return path.isEmpty ? nil : path
|
|
}
|
|
|
|
private func bellAudioVolume() -> Float {
|
|
guard let config else { return 0.5 }
|
|
var value: Double = 0.5
|
|
let key = "bell-audio-volume"
|
|
_ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8)))
|
|
return Float(min(1.0, max(0.0, value)))
|
|
}
|
|
|
|
private func ringBell() {
|
|
let features = bellFeatures()
|
|
|
|
if (features & (1 << 0)) != 0 {
|
|
NSSound.beep()
|
|
}
|
|
|
|
if (features & (1 << 1)) != 0,
|
|
let path = bellAudioPath(),
|
|
let sound = NSSound(contentsOfFile: path, byReference: false) {
|
|
sound.volume = bellAudioVolume()
|
|
bellAudioSound = sound
|
|
if !sound.play() {
|
|
bellAudioSound = nil
|
|
}
|
|
}
|
|
|
|
if (features & (1 << 2)) != 0 {
|
|
NSApp.requestUserAttention(.informationalRequest)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
// Suppress OSC notifications for workspaces with active Claude hook sessions.
|
|
// The hook system manages notifications with proper lifecycle tracking;
|
|
// raw OSC notifications would duplicate or outlive the structured hooks.
|
|
let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager
|
|
if let workspace = owningManager.tabs.first(where: { $0.id == tabId }),
|
|
workspace.agentPIDs["claude_code"] != nil {
|
|
return true
|
|
}
|
|
let tabTitle = owningManager.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_RING_BELL {
|
|
performOnMain {
|
|
self.ringBell()
|
|
}
|
|
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 app = AppDelegate.shared,
|
|
let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else {
|
|
return false
|
|
}
|
|
return tabManager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
|
}
|
|
case GHOSTTY_ACTION_RING_BELL:
|
|
performOnMain {
|
|
self.ringBell()
|
|
}
|
|
return true
|
|
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.enqueueScrollbarUpdate(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)
|
|
)
|
|
DispatchQueue.main.async {
|
|
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?.tabManagerFor(tabId: tabId)?.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 {
|
|
// Suppress OSC notifications for workspaces with active Claude hook sessions.
|
|
let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager
|
|
if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }),
|
|
workspace.agentPIDs["claude_code"] != nil {
|
|
return
|
|
}
|
|
let tabTitle = owningManager?.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
|
|
let newColor = 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=\(newColor.hexString()) default=\(defaultBackgroundColor.hexString()) source=action.color_change.surface"
|
|
)
|
|
}
|
|
DispatchQueue.main.async { [self] in
|
|
surfaceView.backgroundColor = newColor
|
|
surfaceView.applySurfaceBackground()
|
|
if backgroundLogEnabled {
|
|
logBackground("OSC background change tab=\(surfaceView.tabId?.uuidString ?? "unknown") color=\(surfaceView.backgroundColor?.description ?? "nil")")
|
|
}
|
|
surfaceView.applyWindowBackgroundIfActive()
|
|
}
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_CONFIG_CHANGE:
|
|
DispatchQueue.main.async { [self] in
|
|
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()
|
|
surfaceView.applyWindowBackgroundIfActive()
|
|
}
|
|
}
|
|
updateDefaultBackground(
|
|
from: action.action.config_change.config,
|
|
source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")",
|
|
scope: .surface
|
|
)
|
|
if backgroundLogEnabled {
|
|
logBackground(
|
|
"surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString())"
|
|
)
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_RELOAD_CONFIG:
|
|
let soft = action.action.reload_config.soft
|
|
logThemeAction(
|
|
"reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)"
|
|
)
|
|
return performOnMain {
|
|
// Keep all runtime theme/default-background state in the same path.
|
|
GhosttyApp.shared.reloadConfiguration(
|
|
soft: soft,
|
|
source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")"
|
|
)
|
|
return true
|
|
}
|
|
case GHOSTTY_ACTION_KEY_SEQUENCE:
|
|
return performOnMain {
|
|
surfaceView.updateKeySequence(action.action.key_sequence)
|
|
return true
|
|
}
|
|
case GHOSTTY_ACTION_KEY_TABLE:
|
|
return performOnMain {
|
|
surfaceView.updateKeyTable(action.action.key_table)
|
|
return true
|
|
}
|
|
case GHOSTTY_ACTION_OPEN_URL:
|
|
let openUrl = action.action.open_url
|
|
guard let cstr = openUrl.url else { return false }
|
|
let urlString = String(
|
|
data: Data(bytes: cstr, count: Int(openUrl.len)),
|
|
encoding: .utf8
|
|
) ?? ""
|
|
#if DEBUG
|
|
dlog("link.openURL raw=\(urlString)")
|
|
#endif
|
|
guard let target = resolveTerminalOpenURLTarget(urlString) else {
|
|
#if DEBUG
|
|
dlog("link.openURL resolve failed, returning false")
|
|
#endif
|
|
return false
|
|
}
|
|
if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() {
|
|
#if DEBUG
|
|
dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(target.url)
|
|
}
|
|
}
|
|
switch target {
|
|
case let .external(url):
|
|
#if DEBUG
|
|
dlog("link.openURL target=external, opening externally url=\(url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
case let .embeddedBrowser(url):
|
|
if BrowserLinkOpenSettings.shouldOpenExternally(url) {
|
|
#if DEBUG
|
|
dlog("link.openURL target=embedded but shouldOpenExternally=true url=\(url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
|
|
#if DEBUG
|
|
dlog("link.openURL target=embedded but normalizeHost=nil host=\(url.host ?? "nil") url=\(url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
// If a host whitelist is configured and this host isn't in it, open externally.
|
|
if !BrowserLinkOpenSettings.hostMatchesWhitelist(host) {
|
|
#if DEBUG
|
|
dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
let sourceWorkspaceId = callbackTabId ?? surfaceView.tabId
|
|
let sourcePanelId = callbackSurfaceId ?? surfaceView.terminalSurface?.id
|
|
guard let sourceWorkspaceId,
|
|
let sourcePanelId else {
|
|
#if DEBUG
|
|
dlog("link.openURL target=embedded but tabId/surfaceId=nil")
|
|
#endif
|
|
return false
|
|
}
|
|
#if DEBUG
|
|
dlog(
|
|
"link.openURL target=embedded, opening in browser pane " +
|
|
"host=\(host) url=\(url) tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)"
|
|
)
|
|
#endif
|
|
return performOnMain {
|
|
guard let app = AppDelegate.shared,
|
|
let resolved = app.workspaceContainingPanel(
|
|
panelId: sourcePanelId,
|
|
preferredWorkspaceId: sourceWorkspaceId
|
|
) else {
|
|
#if DEBUG
|
|
dlog(
|
|
"link.openURL embedded but workspace lookup failed " +
|
|
"tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)"
|
|
)
|
|
#endif
|
|
return false
|
|
}
|
|
let workspace = resolved.workspace
|
|
#if DEBUG
|
|
if workspace.id != sourceWorkspaceId {
|
|
dlog(
|
|
"link.openURL workspace.remap sourceTab=\(sourceWorkspaceId) " +
|
|
"resolvedTab=\(workspace.id) surfaceId=\(sourcePanelId)"
|
|
)
|
|
}
|
|
#endif
|
|
if let targetPane = workspace.preferredBrowserTargetPane(fromPanelId: sourcePanelId) {
|
|
#if DEBUG
|
|
dlog("link.openURL opening in existing browser pane=\(targetPane)")
|
|
#endif
|
|
return workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil
|
|
} else {
|
|
#if DEBUG
|
|
dlog("link.openURL opening as new browser split from surface=\(sourcePanelId)")
|
|
#endif
|
|
return workspace.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, url: url) != nil
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func applyBackgroundToKeyWindow() {
|
|
guard let window = activeMainWindow() else { return }
|
|
if cmuxShouldUseClearWindowBackground(for: defaultBackgroundOpacity) {
|
|
window.backgroundColor = cmuxTransparentWindowBaseColor()
|
|
window.isOpaque = false
|
|
applyWindowBlurIfNeeded(window)
|
|
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))")
|
|
}
|
|
}
|
|
}
|
|
|
|
func applyWindowBlurIfNeeded(_ window: NSWindow) {
|
|
guard let app = self.app else { return }
|
|
// ghostty_set_window_background_blur reads background-blur and
|
|
// background-opacity from the app config internally and calls
|
|
// CGSSetWindowBackgroundBlurRadius — a compositor-level setter that is
|
|
// idempotent. It is a no-op when opacity >= 1.0 or blur is disabled,
|
|
// so we can call it unconditionally whenever the window is transparent.
|
|
ghostty_set_window_background_blur(app, Unmanaged.passUnretained(window).toOpaque())
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
final class TerminalSurfaceRegistry {
|
|
static let shared = TerminalSurfaceRegistry()
|
|
|
|
private let lock = NSLock()
|
|
private let surfaces = NSHashTable<AnyObject>.weakObjects()
|
|
private var runtimeSurfaceOwners: [UInt: UUID] = [:]
|
|
|
|
private init() {}
|
|
|
|
func register(_ surface: TerminalSurface) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
surfaces.add(surface)
|
|
}
|
|
|
|
func registerRuntimeSurface(_ surface: ghostty_surface_t, ownerId: UUID) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
runtimeSurfaceOwners[UInt(bitPattern: surface)] = ownerId
|
|
}
|
|
|
|
func unregisterRuntimeSurface(_ surface: ghostty_surface_t, ownerId: UUID) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
let key = UInt(bitPattern: surface)
|
|
guard runtimeSurfaceOwners[key] == ownerId else { return }
|
|
runtimeSurfaceOwners.removeValue(forKey: key)
|
|
}
|
|
|
|
func runtimeSurfaceOwnerId(_ surface: ghostty_surface_t) -> UUID? {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return runtimeSurfaceOwners[UInt(bitPattern: surface)]
|
|
}
|
|
|
|
func allSurfaces() -> [TerminalSurface] {
|
|
lock.lock()
|
|
let objects = surfaces.allObjects.compactMap { $0 as? TerminalSurface }
|
|
lock.unlock()
|
|
return objects.sorted { lhs, rhs in
|
|
lhs.id.uuidString < rhs.id.uuidString
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 runtime Ghostty surface exists and has not begun teardown.
|
|
///
|
|
/// Use this as a quick availability check. Before passing `surface` to
|
|
/// Ghostty C APIs that dereference the pointer (e.g.
|
|
/// `ghostty_surface_inherited_config`, `ghostty_surface_quicklook_font`),
|
|
/// call `liveSurfaceForGhosttyAccess(reason:)` so stale freed pointers are
|
|
/// rejected and quarantined.
|
|
var hasLiveSurface: Bool { surface != nil && portalLifecycleState == .live }
|
|
|
|
/// 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: CmuxSurfaceConfigTemplate?
|
|
private let workingDirectory: String?
|
|
private let initialCommand: String?
|
|
private let initialEnvironmentOverrides: [String: String]
|
|
var requestedWorkingDirectory: String? { workingDirectory }
|
|
private var 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 let debugMetadataLock = NSLock()
|
|
private let createdAt: Date = Date()
|
|
private var runtimeSurfaceCreatedAt: Date?
|
|
private var teardownRequestedAt: Date?
|
|
private var teardownRequestReason: String?
|
|
private var pendingTextQueue: [Data] = []
|
|
private var pendingTextBytes: Int = 0
|
|
private let maxPendingTextBytes = 1_048_576
|
|
private var backgroundSurfaceStartQueued = false
|
|
private var surfaceCallbackContext: Unmanaged<GhosttySurfaceCallbackContext>?
|
|
/// The desired focus state for the Ghostty C surface. May be set before the
|
|
/// C surface exists (e.g. during layout restoration); `createSurface` syncs
|
|
/// it on creation. Also used as a dedup guard to avoid redundant
|
|
/// `ghostty_surface_set_focus` calls (prevents prompt redraws with P10k).
|
|
/// Initialized to `true` to match Ghostty's default (Terminal.zig focused=true).
|
|
private var desiredFocusState: Bool = true
|
|
#if DEBUG
|
|
private var needsConfirmCloseOverrideForTesting: Bool?
|
|
private var runtimeSurfaceFreedOutOfBandForTesting = false
|
|
#endif
|
|
private enum PortalLifecycleState: String {
|
|
case live
|
|
case closing
|
|
case closed
|
|
}
|
|
private struct PortalHostLease {
|
|
let hostId: ObjectIdentifier
|
|
let paneId: UUID
|
|
let instanceSerial: UInt64
|
|
let inWindow: Bool
|
|
let area: CGFloat
|
|
}
|
|
private var portalLifecycleState: PortalLifecycleState = .live
|
|
private var portalLifecycleGeneration: UInt64 = 1
|
|
private var activePortalHostLease: PortalHostLease?
|
|
@Published var searchState: SearchState? = nil {
|
|
didSet {
|
|
if let searchState {
|
|
hostedView.cancelFocusRequest()
|
|
#if DEBUG
|
|
dlog("find.searchState created tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))")
|
|
#endif
|
|
searchNeedleCancellable = searchState.$needle
|
|
.removeDuplicates()
|
|
.map { needle -> AnyPublisher<String, Never> in
|
|
if needle.isEmpty || needle.count >= 3 {
|
|
return Just(needle).eraseToAnyPublisher()
|
|
}
|
|
|
|
return Just(needle)
|
|
.delay(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
.switchToLatest()
|
|
.sink { [weak self] needle in
|
|
#if DEBUG
|
|
dlog("find.needle updated tab=\(self?.tabId.uuidString.prefix(5) ?? "?") surface=\(self?.id.uuidString.prefix(5) ?? "?") chars=\(needle.count)")
|
|
#endif
|
|
_ = self?.performBindingAction("search:\(needle)")
|
|
}
|
|
} else if oldValue != nil {
|
|
searchNeedleCancellable = nil
|
|
#if DEBUG
|
|
dlog("find.searchState cleared tab=\(tabId.uuidString.prefix(5)) surface=\(id.uuidString.prefix(5))")
|
|
#endif
|
|
_ = performBindingAction("end_search")
|
|
}
|
|
}
|
|
}
|
|
@Published private(set) var keyboardCopyModeActive: Bool = false
|
|
private var searchNeedleCancellable: AnyCancellable?
|
|
var currentKeyStateIndicatorText: String? { surfaceView.currentKeyStateIndicatorText }
|
|
|
|
init(
|
|
tabId: UUID,
|
|
context: ghostty_surface_context_e,
|
|
configTemplate: CmuxSurfaceConfigTemplate?,
|
|
workingDirectory: String? = nil,
|
|
initialCommand: String? = nil,
|
|
initialEnvironmentOverrides: [String: String] = [:],
|
|
additionalEnvironment: [String: String] = [:]
|
|
) {
|
|
self.id = UUID()
|
|
self.tabId = tabId
|
|
self.surfaceContext = context
|
|
self.configTemplate = configTemplate
|
|
self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmedCommand = initialCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.initialCommand = (trimmedCommand?.isEmpty == false) ? trimmedCommand : nil
|
|
self.initialEnvironmentOverrides = Self.mergedNormalizedEnvironment(base: [:], overrides: initialEnvironmentOverrides)
|
|
self.additionalEnvironment = Self.mergedNormalizedEnvironment(base: [:], overrides: 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)
|
|
TerminalSurfaceRegistry.shared.register(self)
|
|
}
|
|
|
|
|
|
func updateWorkspaceId(_ newTabId: UUID) {
|
|
tabId = newTabId
|
|
attachedView?.tabId = newTabId
|
|
surfaceView.tabId = newTabId
|
|
}
|
|
|
|
private static func mergedNormalizedEnvironment(
|
|
base: [String: String],
|
|
overrides: [String: String]
|
|
) -> [String: String] {
|
|
var merged: [String: String] = [:]
|
|
merged.reserveCapacity(base.count + overrides.count)
|
|
for (rawKey, value) in base {
|
|
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !key.isEmpty else { continue }
|
|
merged[key] = value
|
|
}
|
|
for (rawKey, value) in overrides {
|
|
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !key.isEmpty else { continue }
|
|
merged[key] = value
|
|
}
|
|
return merged
|
|
}
|
|
|
|
static func mergedStartupEnvironment(
|
|
base: [String: String],
|
|
protectedKeys: Set<String>,
|
|
additionalEnvironment: [String: String],
|
|
initialEnvironmentOverrides: [String: String]
|
|
) -> [String: String] {
|
|
var merged = base
|
|
for (key, value) in additionalEnvironment where !key.isEmpty && !value.isEmpty && !protectedKeys.contains(key) {
|
|
merged[key] = value
|
|
}
|
|
for (key, value) in initialEnvironmentOverrides where !protectedKeys.contains(key) {
|
|
merged[key] = value
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func isAttached(to view: GhosttyNSView) -> Bool {
|
|
attachedView === view && surface != nil
|
|
}
|
|
|
|
func portalBindingGeneration() -> UInt64 {
|
|
portalLifecycleGeneration
|
|
}
|
|
|
|
func portalBindingStateLabel() -> String {
|
|
portalLifecycleState.rawValue
|
|
}
|
|
|
|
private func withDebugMetadataLock<T>(_ body: () -> T) -> T {
|
|
debugMetadataLock.lock()
|
|
defer { debugMetadataLock.unlock() }
|
|
return body()
|
|
}
|
|
|
|
func debugCreatedAt() -> Date {
|
|
withDebugMetadataLock { createdAt }
|
|
}
|
|
|
|
func debugRuntimeSurfaceCreatedAt() -> Date? {
|
|
withDebugMetadataLock { runtimeSurfaceCreatedAt }
|
|
}
|
|
|
|
func debugTeardownRequest() -> (requestedAt: Date?, reason: String?) {
|
|
withDebugMetadataLock { (teardownRequestedAt, teardownRequestReason) }
|
|
}
|
|
|
|
func debugLastKnownWorkspaceId() -> UUID {
|
|
tabId
|
|
}
|
|
|
|
func debugSurfaceContextLabel() -> String {
|
|
cmuxSurfaceContextName(surfaceContext)
|
|
}
|
|
|
|
func debugInitialCommand() -> String? {
|
|
initialCommand
|
|
}
|
|
|
|
func debugPortalHostLease() -> (hostId: String?, paneId: UUID?, inWindow: Bool?, area: CGFloat?) {
|
|
guard let activePortalHostLease else {
|
|
return (nil, nil, nil, nil)
|
|
}
|
|
return (
|
|
hostId: String(describing: activePortalHostLease.hostId),
|
|
paneId: activePortalHostLease.paneId,
|
|
inWindow: activePortalHostLease.inWindow,
|
|
area: activePortalHostLease.area
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
@MainActor
|
|
func liveSurfaceForGhosttyAccess(reason: String) -> ghostty_surface_t? {
|
|
guard hasLiveSurface, let surface else { return nil }
|
|
let registry = TerminalSurfaceRegistry.shared
|
|
let registeredOwnerId = registry.runtimeSurfaceOwnerId(surface)
|
|
guard registeredOwnerId == id,
|
|
cmuxSurfacePointerAppearsLive(surface) else {
|
|
let callbackContext = surfaceCallbackContext
|
|
surfaceCallbackContext = nil
|
|
registry.unregisterRuntimeSurface(surface, ownerId: id)
|
|
self.surface = nil
|
|
activePortalHostLease = nil
|
|
recordTeardownRequest(reason: reason)
|
|
markPortalLifecycleClosed(reason: reason)
|
|
#if DEBUG
|
|
let registeredOwnerToken = registeredOwnerId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
|
dlog(
|
|
"surface.lifecycle.stale surface=\(id.uuidString.prefix(5)) " +
|
|
"workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " +
|
|
"registryOwner=\(registeredOwnerToken)"
|
|
)
|
|
#endif
|
|
callbackContext?.release()
|
|
return nil
|
|
}
|
|
return surface
|
|
}
|
|
|
|
private static let portalHostAreaThreshold: CGFloat = 4
|
|
|
|
private static func portalHostArea(for bounds: CGRect) -> CGFloat {
|
|
max(0, bounds.width) * max(0, bounds.height)
|
|
}
|
|
|
|
private static func portalHostIsUsable(_ lease: PortalHostLease) -> Bool {
|
|
lease.inWindow && lease.area > portalHostAreaThreshold
|
|
}
|
|
|
|
@discardableResult
|
|
func preparePortalHostReplacementIfOwned(hostId: ObjectIdentifier, reason: String) -> Bool {
|
|
guard let current = activePortalHostLease, current.hostId == hostId else { return false }
|
|
// SwiftUI can tear down and rebuild the host NSView during split churn. Keep the
|
|
// existing portal binding alive, but make the old lease non-usable so the next
|
|
// distinct host in the same pane can claim immediately instead of waiting for a
|
|
// later layout-follow-up retry.
|
|
activePortalHostLease = PortalHostLease(
|
|
hostId: current.hostId,
|
|
paneId: current.paneId,
|
|
instanceSerial: current.instanceSerial,
|
|
inWindow: false,
|
|
area: current.area
|
|
)
|
|
#if DEBUG
|
|
dlog(
|
|
"terminal.portal.host.rearm surface=\(id.uuidString.prefix(5)) " +
|
|
"reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " +
|
|
"area=\(String(format: "%.1f", current.area))"
|
|
)
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
func claimPortalHost(
|
|
hostId: ObjectIdentifier,
|
|
paneId: PaneID,
|
|
instanceSerial: UInt64,
|
|
inWindow: Bool,
|
|
bounds: CGRect,
|
|
reason: String
|
|
) -> Bool {
|
|
let next = PortalHostLease(
|
|
hostId: hostId,
|
|
paneId: paneId.id,
|
|
instanceSerial: instanceSerial,
|
|
inWindow: inWindow,
|
|
area: Self.portalHostArea(for: bounds)
|
|
)
|
|
|
|
if let current = activePortalHostLease {
|
|
if current.hostId == hostId {
|
|
activePortalHostLease = next
|
|
return true
|
|
}
|
|
|
|
let currentUsable = Self.portalHostIsUsable(current)
|
|
let nextUsable = Self.portalHostIsUsable(next)
|
|
// During split churn SwiftUI can briefly keep the old host alive while the new
|
|
// host for the same pane is already in the window. Prefer the newer live host
|
|
// immediately so the surface moves with the pane instead of waiting for a later
|
|
// update from unrelated focus/layout work.
|
|
let newerSamePaneHostReady =
|
|
current.paneId == paneId.id &&
|
|
nextUsable &&
|
|
next.instanceSerial > current.instanceSerial
|
|
// A dragged terminal must hand off immediately when it moves to a different pane.
|
|
// Waiting for the old host to become "worse" leaves the moved pane blank/stale.
|
|
let shouldReplace =
|
|
current.paneId != paneId.id ||
|
|
!currentUsable ||
|
|
newerSamePaneHostReady
|
|
|
|
if shouldReplace {
|
|
#if DEBUG
|
|
dlog(
|
|
"terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " +
|
|
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
|
"inWin=\(inWindow ? 1 : 0) " +
|
|
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
|
"replacingHost=\(current.hostId) replacingPane=\(current.paneId.uuidString.prefix(5)) " +
|
|
"replacingInWin=\(current.inWindow ? 1 : 0) " +
|
|
"replacingArea=\(String(format: "%.1f", current.area))"
|
|
)
|
|
#endif
|
|
activePortalHostLease = next
|
|
return true
|
|
}
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"terminal.portal.host.skip surface=\(id.uuidString.prefix(5)) " +
|
|
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
|
"inWin=\(inWindow ? 1 : 0) " +
|
|
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
|
"ownerHost=\(current.hostId) ownerPane=\(current.paneId.uuidString.prefix(5)) " +
|
|
"ownerInWin=\(current.inWindow ? 1 : 0) " +
|
|
"ownerArea=\(String(format: "%.1f", current.area))"
|
|
)
|
|
#endif
|
|
return false
|
|
}
|
|
|
|
activePortalHostLease = next
|
|
#if DEBUG
|
|
dlog(
|
|
"terminal.portal.host.claim surface=\(id.uuidString.prefix(5)) " +
|
|
"reason=\(reason) host=\(hostId) pane=\(paneId.id.uuidString.prefix(5)) " +
|
|
"inWin=\(inWindow ? 1 : 0) " +
|
|
"size=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) replacingHost=nil"
|
|
)
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
func releasePortalHostIfOwned(hostId: ObjectIdentifier, reason: String) {
|
|
guard let current = activePortalHostLease, current.hostId == hostId else { return }
|
|
activePortalHostLease = nil
|
|
#if DEBUG
|
|
dlog(
|
|
"terminal.portal.host.release surface=\(id.uuidString.prefix(5)) " +
|
|
"reason=\(reason) host=\(hostId) pane=\(current.paneId.uuidString.prefix(5)) " +
|
|
"inWin=\(current.inWindow ? 1 : 0) " +
|
|
"area=\(String(format: "%.1f", current.area))"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
private func recordTeardownRequest(reason: String) {
|
|
withDebugMetadataLock {
|
|
if teardownRequestedAt == nil {
|
|
teardownRequestedAt = Date()
|
|
}
|
|
if let existing = teardownRequestReason, !existing.isEmpty {
|
|
return
|
|
}
|
|
teardownRequestReason = reason
|
|
}
|
|
}
|
|
|
|
private func recordRuntimeSurfaceCreation() {
|
|
withDebugMetadataLock {
|
|
runtimeSurfaceCreatedAt = Date()
|
|
}
|
|
}
|
|
|
|
func beginPortalCloseLifecycle(reason: String) {
|
|
guard portalLifecycleState != .closed else { return }
|
|
guard portalLifecycleState != .closing else { return }
|
|
recordTeardownRequest(reason: reason)
|
|
portalLifecycleState = .closing
|
|
portalLifecycleGeneration &+= 1
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.lifecycle.close.begin surface=\(id.uuidString.prefix(5)) " +
|
|
"workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " +
|
|
"generation=\(portalLifecycleGeneration)"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
private func markPortalLifecycleClosed(reason: String) {
|
|
guard portalLifecycleState != .closed else { return }
|
|
portalLifecycleState = .closed
|
|
portalLifecycleGeneration &+= 1
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.lifecycle.close.sealed surface=\(id.uuidString.prefix(5)) " +
|
|
"workspace=\(tabId.uuidString.prefix(5)) reason=\(reason) " +
|
|
"generation=\(portalLifecycleGeneration)"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
/// Explicitly free the Ghostty runtime surface. Idempotent — safe to call
|
|
/// before deinit; deinit will skip the free if already torn down.
|
|
@MainActor
|
|
func teardownSurface() {
|
|
recordTeardownRequest(reason: "surface.teardown")
|
|
markPortalLifecycleClosed(reason: "teardown")
|
|
|
|
let callbackContext = surfaceCallbackContext
|
|
surfaceCallbackContext = nil
|
|
|
|
let surfaceToFree = surface
|
|
if let surfaceToFree {
|
|
TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
|
|
}
|
|
surface = nil
|
|
|
|
guard let surfaceToFree else {
|
|
callbackContext?.release()
|
|
return
|
|
}
|
|
|
|
#if DEBUG
|
|
if runtimeSurfaceFreedOutOfBandForTesting {
|
|
runtimeSurfaceFreedOutOfBandForTesting = false
|
|
callbackContext?.release()
|
|
return
|
|
}
|
|
#endif
|
|
|
|
Task { @MainActor in
|
|
// Keep free behavior aligned with deinit: perform the runtime teardown on
|
|
// the next main-actor turn so SIGHUP delivery is deterministic but non-reentrant.
|
|
ghostty_surface_free(surfaceToFree)
|
|
callbackContext?.release()
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
private static let surfaceLogPath = "/tmp/cmux-ghostty-surface.log"
|
|
private static let sizeLogPath = "/tmp/cmux-ghostty-size.log"
|
|
|
|
func debugCurrentPixelSize() -> (width: UInt32, height: UInt32) {
|
|
(lastPixelWidth, lastPixelHeight)
|
|
}
|
|
|
|
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(Data(line.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(Data(line.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.
|
|
// SwiftUI also re-enters this path for ordinary state propagation (drag hover, active
|
|
// markers, visibility flags), so avoid forcing a geometry refresh when the attachment
|
|
// itself is unchanged.
|
|
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)
|
|
}
|
|
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)
|
|
|
|
let baseConfig = configTemplate ?? CmuxSurfaceConfigTemplate()
|
|
var surfaceConfig = ghostty_surface_config_new()
|
|
surfaceConfig.font_size = baseConfig.fontSize
|
|
surfaceConfig.wait_after_command = baseConfig.waitAfterCommand
|
|
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 = baseConfig.environmentVariables
|
|
|
|
var protectedStartupEnvironmentKeys: Set<String> = []
|
|
func setManagedEnvironmentValue(_ key: String, _ value: String) {
|
|
env[key] = value
|
|
protectedStartupEnvironmentKeys.insert(key)
|
|
}
|
|
|
|
setManagedEnvironmentValue("CMUX_SURFACE_ID", id.uuidString)
|
|
setManagedEnvironmentValue("CMUX_WORKSPACE_ID", tabId.uuidString)
|
|
// Backward-compatible shell integration keys used by existing scripts/tests.
|
|
setManagedEnvironmentValue("CMUX_PANEL_ID", id.uuidString)
|
|
setManagedEnvironmentValue("CMUX_TAB_ID", tabId.uuidString)
|
|
let socketPath = SocketControlSettings.socketPath()
|
|
setManagedEnvironmentValue("CMUX_SOCKET_PATH", socketPath)
|
|
// Backward-compatible alias expected by older scripts and third-party integrations.
|
|
setManagedEnvironmentValue("CMUX_SOCKET", socketPath)
|
|
if let bundledCLIURL = Bundle.main.resourceURL?.appendingPathComponent("bin/cmux"),
|
|
FileManager.default.isExecutableFile(atPath: bundledCLIURL.path) {
|
|
setManagedEnvironmentValue("CMUX_BUNDLED_CLI_PATH", bundledCLIURL.path)
|
|
}
|
|
if let bundleId = Bundle.main.bundleIdentifier, !bundleId.isEmpty {
|
|
setManagedEnvironmentValue("CMUX_BUNDLE_ID", bundleId)
|
|
}
|
|
|
|
// Port range for this workspace (base/range snapshotted once per app session)
|
|
do {
|
|
let startPort = Self.sessionPortBase + portOrdinal * Self.sessionPortRangeSize
|
|
setManagedEnvironmentValue("CMUX_PORT", String(startPort))
|
|
setManagedEnvironmentValue("CMUX_PORT_END", String(startPort + Self.sessionPortRangeSize - 1))
|
|
setManagedEnvironmentValue("CMUX_PORT_RANGE", String(Self.sessionPortRangeSize))
|
|
}
|
|
|
|
let claudeHooksEnabled = ClaudeCodeIntegrationSettings.hooksEnabled()
|
|
if !claudeHooksEnabled {
|
|
setManagedEnvironmentValue("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 ? "" : ":"
|
|
setManagedEnvironmentValue("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 {
|
|
setManagedEnvironmentValue("CMUX_SHELL_INTEGRATION", "1")
|
|
setManagedEnvironmentValue("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" {
|
|
if GhosttyApp.shared.shellIntegrationMode() != "none" {
|
|
setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION", "1")
|
|
}
|
|
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 {
|
|
setManagedEnvironmentValue("CMUX_ZSH_ZDOTDIR", candidateZdotdir)
|
|
}
|
|
}
|
|
|
|
setManagedEnvironmentValue("ZDOTDIR", integrationDir)
|
|
} else if shellName == "bash" {
|
|
if GhosttyApp.shared.shellIntegrationMode() != "none" {
|
|
setManagedEnvironmentValue("CMUX_LOAD_GHOSTTY_BASH_INTEGRATION", "1")
|
|
}
|
|
// macOS ships /bin/bash 3.2, where Ghostty's automatic bash
|
|
// integration is unsupported and HOME-based wrapper startup is
|
|
// not reliable. Bootstrap cmux bash integration on the first
|
|
// interactive prompt instead.
|
|
setManagedEnvironmentValue("PROMPT_COMMAND", """
|
|
unset PROMPT_COMMAND; \
|
|
if [[ "${CMUX_LOAD_GHOSTTY_BASH_INTEGRATION:-0}" == "1" && -n "${GHOSTTY_RESOURCES_DIR:-}" ]]; then \
|
|
_cmux_ghostty_bash="$GHOSTTY_RESOURCES_DIR/shell-integration/bash/ghostty.bash"; \
|
|
[[ -r "$_cmux_ghostty_bash" ]] && source "$_cmux_ghostty_bash"; \
|
|
fi; \
|
|
if [[ "${CMUX_SHELL_INTEGRATION:-1}" != "0" && -n "${CMUX_SHELL_INTEGRATION_DIR:-}" ]]; then \
|
|
_cmux_bash_integration="$CMUX_SHELL_INTEGRATION_DIR/cmux-bash-integration.bash"; \
|
|
[[ -r "$_cmux_bash_integration" ]] && source "$_cmux_bash_integration"; \
|
|
fi; \
|
|
unset _cmux_ghostty_bash _cmux_bash_integration; \
|
|
if declare -F _cmux_prompt_command >/dev/null 2>&1; then _cmux_prompt_command; fi
|
|
""")
|
|
}
|
|
}
|
|
env = Self.mergedStartupEnvironment(
|
|
base: env,
|
|
protectedKeys: protectedStartupEnvironmentKeys,
|
|
additionalEnvironment: additionalEnvironment,
|
|
initialEnvironmentOverrides: initialEnvironmentOverrides
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
let resolvedWorkingDirectory: String? = {
|
|
if let workingDirectory, !workingDirectory.isEmpty {
|
|
return workingDirectory
|
|
}
|
|
return baseConfig.workingDirectory
|
|
}()
|
|
let resolvedCommand: String? = {
|
|
if let initialCommand, !initialCommand.isEmpty {
|
|
return initialCommand
|
|
}
|
|
return baseConfig.command
|
|
}()
|
|
let resolvedInitialInput = baseConfig.initialInput
|
|
func withOptionalCString<T>(_ value: String?, _ body: (UnsafePointer<CChar>?) -> T) -> T {
|
|
guard let value else {
|
|
return body(nil)
|
|
}
|
|
return value.withCString(body)
|
|
}
|
|
|
|
let createWithCommandAndWorkingDirectory = {
|
|
withOptionalCString(resolvedCommand) { cCommand in
|
|
surfaceConfig.command = cCommand
|
|
withOptionalCString(resolvedWorkingDirectory) { cWorkingDir in
|
|
surfaceConfig.working_directory = cWorkingDir
|
|
withOptionalCString(resolvedInitialInput) { cInitialInput in
|
|
surfaceConfig.initial_input = cInitialInput
|
|
createSurface()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
createWithCommandAndWorkingDirectory()
|
|
|
|
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 }
|
|
TerminalSurfaceRegistry.shared.registerRuntimeSurface(createdSurface, ownerId: id)
|
|
recordRuntimeSurfaceCreation()
|
|
|
|
// Session scrollback replay must be one-shot. Reusing it on a later runtime
|
|
// surface recreation would inject stale restored output into a live shell.
|
|
additionalEnvironment.removeValue(forKey: SessionScrollbackReplayStore.environmentKey)
|
|
|
|
// 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?.fontSize,
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Sync the desired focus state to the newly created C surface. Ghostty
|
|
// surfaces default to focused=true, but this surface may have been
|
|
// logically unfocused before the C surface existed (e.g. during layout
|
|
// restoration). Always sync unconditionally so we don't couple to
|
|
// Ghostty's default.
|
|
ghostty_surface_set_focus(createdSurface, desiredFocusState)
|
|
|
|
NotificationCenter.default.post(
|
|
name: .terminalSurfaceDidBecomeReady,
|
|
object: self,
|
|
userInfo: [
|
|
"surfaceId": id,
|
|
"workspaceId": tabId
|
|
]
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
@discardableResult
|
|
func updateSize(
|
|
width: CGFloat,
|
|
height: CGFloat,
|
|
xScale: CGFloat,
|
|
yScale: CGFloat,
|
|
layerScale: CGFloat,
|
|
backingSize: CGSize? = nil
|
|
) -> Bool {
|
|
guard let surface = surface else { return false }
|
|
_ = 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 false }
|
|
|
|
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 false }
|
|
|
|
#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.
|
|
return true
|
|
}
|
|
|
|
/// Force a full size recalculation and surface redraw.
|
|
func forceRefresh(reason: String = "unspecified") {
|
|
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
|
|
dlog("forceRefresh: \(id) reason=\(reason) \(viewState)")
|
|
#endif
|
|
guard let view = attachedView,
|
|
let surface,
|
|
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()
|
|
}
|
|
|
|
/// Keep `desiredFocusState` in sync when the hosted view's responder chain
|
|
/// calls `ghostty_surface_set_focus` directly (bypassing `setFocus`).
|
|
/// Without this, `createSurface` would replay a stale state on recreation.
|
|
func recordExternalFocusState(_ focused: Bool) {
|
|
desiredFocusState = focused
|
|
}
|
|
|
|
func setFocus(_ focused: Bool) {
|
|
// Only send focus events when the state changes to avoid redundant
|
|
// prompt redraws with zsh themes like Powerlevel10k.
|
|
guard focused != desiredFocusState else { return }
|
|
desiredFocusState = focused
|
|
// Track desired state even before the C surface exists (e.g. during
|
|
// layout restoration). createSurface syncs the state once created.
|
|
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 {
|
|
#if DEBUG
|
|
if let needsConfirmCloseOverrideForTesting {
|
|
return needsConfirmCloseOverrideForTesting
|
|
}
|
|
#endif
|
|
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)
|
|
}
|
|
|
|
/// Send text with control characters (Return, Tab, etc.) delivered as key
|
|
/// events so the shell processes them, while regular text is sent via the
|
|
/// normal key-text path. Mirrors `TerminalController.sendSocketText`.
|
|
func sendInput(_ text: String) {
|
|
guard let surface = surface else { return }
|
|
var bufferedText = ""
|
|
var previousWasCR = false
|
|
for scalar in text.unicodeScalars {
|
|
switch scalar.value {
|
|
case 0x0A: // \n — skip if preceded by \r (already sent Return)
|
|
if !previousWasCR {
|
|
flushText(&bufferedText, surface: surface)
|
|
sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return
|
|
}
|
|
previousWasCR = false
|
|
case 0x0D:
|
|
flushText(&bufferedText, surface: surface)
|
|
sendKeyEvent(surface: surface, keycode: 0x24) // kVK_Return
|
|
previousWasCR = true
|
|
case 0x09:
|
|
flushText(&bufferedText, surface: surface)
|
|
sendKeyEvent(surface: surface, keycode: 0x30) // kVK_Tab
|
|
previousWasCR = false
|
|
case 0x1B:
|
|
flushText(&bufferedText, surface: surface)
|
|
sendKeyEvent(surface: surface, keycode: 0x35) // kVK_Escape
|
|
previousWasCR = false
|
|
default:
|
|
bufferedText.unicodeScalars.append(scalar)
|
|
previousWasCR = false
|
|
}
|
|
}
|
|
flushText(&bufferedText, surface: surface)
|
|
}
|
|
|
|
private func flushText(_ buffer: inout String, surface: ghostty_surface_t) {
|
|
guard !buffer.isEmpty else { return }
|
|
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.unshifted_codepoint = 0
|
|
keyEvent.composing = false
|
|
buffer.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
buffer.removeAll(keepingCapacity: true)
|
|
}
|
|
|
|
private func sendKeyEvent(surface: ghostty_surface_t, keycode: UInt32) {
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = keycode
|
|
keyEvent.mods = GHOSTTY_MODS_NONE
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.unshifted_codepoint = 0
|
|
keyEvent.composing = false
|
|
keyEvent.text = nil
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
|
|
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.syncKeyStateIndicator(text: surfaceView.currentKeyStateIndicatorText)
|
|
}
|
|
|
|
func hasSelection() -> Bool {
|
|
guard let surface = surface else { return false }
|
|
return ghostty_surface_has_selection(surface)
|
|
}
|
|
|
|
#if DEBUG
|
|
@MainActor
|
|
func setNeedsConfirmCloseOverrideForTesting(_ value: Bool?) {
|
|
needsConfirmCloseOverrideForTesting = value
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
|
|
surface = nil
|
|
ghostty_surface_free(surfaceToFree)
|
|
callbackContext?.release()
|
|
}
|
|
|
|
/// Test-only helper to simulate a stale Swift wrapper whose native surface
|
|
/// was already freed out-of-band.
|
|
@MainActor
|
|
func replaceSurfaceWithFreedPointerForTesting() {
|
|
guard !runtimeSurfaceFreedOutOfBandForTesting else { return }
|
|
|
|
let callbackContext = surfaceCallbackContext
|
|
surfaceCallbackContext = nil
|
|
|
|
guard let surfaceToFree = surface else {
|
|
callbackContext?.release()
|
|
return
|
|
}
|
|
|
|
TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
|
|
ghostty_surface_free(surfaceToFree)
|
|
runtimeSurfaceFreedOutOfBandForTesting = true
|
|
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
|
|
if let surfaceToFree {
|
|
TerminalSurfaceRegistry.shared.unregisterRuntimeSurface(surfaceToFree, ownerId: id)
|
|
}
|
|
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
|
|
if runtimeSurfaceFreedOutOfBandForTesting {
|
|
runtimeSurfaceFreedOutOfBandForTesting = false
|
|
callbackContext?.release()
|
|
return
|
|
}
|
|
#endif
|
|
|
|
#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
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TerminalSurface {
|
|
@MainActor
|
|
func owningWorkspace() -> Workspace? {
|
|
AppDelegate.shared?.workspaceFor(tabId: tabId)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}()
|
|
internal enum DropPlan: Equatable {
|
|
case insertText(String)
|
|
case uploadFiles([URL])
|
|
case reject
|
|
}
|
|
|
|
private static let dropTypes: Set<NSPasteboard.PasteboardType> = [
|
|
.string,
|
|
.fileURL,
|
|
.URL,
|
|
.png,
|
|
.tiff,
|
|
NSPasteboard.PasteboardType(UTType.jpeg.identifier),
|
|
NSPasteboard.PasteboardType(UTType.gif.identifier),
|
|
NSPasteboard.PasteboardType(UTType.heic.identifier),
|
|
NSPasteboard.PasteboardType(UTType.heif.identifier)
|
|
]
|
|
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
|
|
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
|
|
|
|
fileprivate static func focusLog(_ message: String) {
|
|
guard focusDebugEnabled else { return }
|
|
FocusLogStore.shared.append(message)
|
|
NSLog("[FOCUSDBG] %@", message)
|
|
}
|
|
|
|
weak var terminalSurface: TerminalSurface?
|
|
var scrollbar: GhosttyScrollbar?
|
|
/// Pending scrollbar value written from the action callback thread;
|
|
/// read and cleared on the main thread by `flushPendingScrollbar()`.
|
|
/// Access is guarded by `_scrollbarLock` because the action callback
|
|
/// fires on Ghostty's I/O thread while the flush runs on main.
|
|
private var _pendingScrollbar: GhosttyScrollbar?
|
|
private var _scrollbarFlushScheduled = false
|
|
private let _scrollbarLock = NSLock()
|
|
var cellSize: CGSize = .zero
|
|
|
|
/// Coalesce high-frequency scrollbar updates into a single main-thread
|
|
/// dispatch. The action callback (which may fire thousands of times per
|
|
/// second during bulk output like `seq 1 100000`) stores the latest value
|
|
/// and schedules exactly one async flush.
|
|
func enqueueScrollbarUpdate(_ newValue: GhosttyScrollbar) {
|
|
_scrollbarLock.lock()
|
|
defer { _scrollbarLock.unlock() }
|
|
// Store the latest value (always overwrites — only the newest matters).
|
|
_pendingScrollbar = newValue
|
|
let needsSchedule = !_scrollbarFlushScheduled
|
|
if needsSchedule { _scrollbarFlushScheduled = true }
|
|
|
|
// If a flush is already scheduled, skip the dispatch — the scheduled
|
|
// block will pick up the latest value.
|
|
guard needsSchedule else { return }
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.flushPendingScrollbar()
|
|
}
|
|
}
|
|
|
|
private func flushPendingScrollbar() {
|
|
_scrollbarLock.lock()
|
|
_scrollbarFlushScheduled = false
|
|
let pending = _pendingScrollbar
|
|
_pendingScrollbar = nil
|
|
_scrollbarLock.unlock()
|
|
|
|
guard let pending else { return }
|
|
scrollbar = pending
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidUpdateScrollbar,
|
|
object: self,
|
|
userInfo: [GhosttyNotificationKey.scrollbar: pending]
|
|
)
|
|
}
|
|
|
|
var desiredFocus: Bool = false
|
|
var suppressingReparentFocus: Bool = false
|
|
var tabId: UUID?
|
|
var onFocus: (() -> Void)?
|
|
var onTriggerFlash: (() -> Void)?
|
|
var backgroundColor: NSColor?
|
|
private var appliedColorScheme: ghostty_color_scheme_e?
|
|
private var lastLoggedSurfaceBackgroundSignature: String?
|
|
private var lastLoggedWindowBackgroundSignature: String?
|
|
private var keySequence: [ghostty_input_trigger_s] = []
|
|
private var keyTables: [String] = []
|
|
fileprivate private(set) var keyboardCopyModeActive = false
|
|
private var keyboardCopyModeConsumedKeyUps: Set<UInt16> = []
|
|
private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState()
|
|
private var keyboardCopyModeViewportRow: Int?
|
|
/// Tracks whether the user has explicitly entered visual selection mode (v).
|
|
/// Separate from Ghostty's `has_selection` because copy mode always maintains
|
|
/// a 1-cell selection as a visible cursor. This flag determines whether
|
|
/// movements should extend the selection (visual) or scroll the viewport.
|
|
private var keyboardCopyModeVisualActive = false
|
|
fileprivate var isKeyboardCopyModeActive: Bool { keyboardCopyModeActive }
|
|
fileprivate var currentKeyStateIndicatorText: String? {
|
|
if let name = keyTables.last {
|
|
return terminalKeyTableIndicatorText(name)
|
|
}
|
|
|
|
if keyboardCopyModeActive {
|
|
return terminalKeyboardCopyModeIndicatorText
|
|
}
|
|
|
|
return nil
|
|
}
|
|
#if DEBUG
|
|
private static let keyLatencyProbeEnabled: Bool = {
|
|
if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" {
|
|
return true
|
|
}
|
|
return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")
|
|
}()
|
|
static var debugGhosttySurfaceKeyEventObserver: ((ghostty_input_key_s) -> Void)?
|
|
#endif
|
|
private var eventMonitor: Any?
|
|
private var trackingArea: NSTrackingArea?
|
|
private var windowObserver: NSObjectProtocol?
|
|
private var lastScrollEventTime: CFTimeInterval = 0
|
|
private var visibleInUI: Bool = true
|
|
private var pendingSurfaceSize: CGSize?
|
|
private var deferredSurfaceSizeRetryQueued = false
|
|
private var lastDrawableSize: CGSize = .zero
|
|
private var isFindEscapeSuppressionArmed = false
|
|
#if DEBUG
|
|
private var lastSizeSkipSignature: String?
|
|
#endif
|
|
|
|
private var hasUsableFocusGeometry: Bool {
|
|
bounds.width > 1 && bounds.height > 1
|
|
}
|
|
|
|
static func shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: Bool,
|
|
pressedMouseButtons: Int,
|
|
appIsActive: Bool,
|
|
windowIsKey: Bool,
|
|
alreadyFirstResponder: Bool,
|
|
visibleInUI: Bool,
|
|
hasUsableGeometry: Bool,
|
|
hiddenInHierarchy: Bool
|
|
) -> Bool {
|
|
guard focusFollowsMouseEnabled else { return false }
|
|
guard pressedMouseButtons == 0 else { return false }
|
|
guard appIsActive, windowIsKey else { return false }
|
|
guard !alreadyFirstResponder else { return false }
|
|
guard visibleInUI, hasUsableGeometry, !hiddenInHierarchy else { return false }
|
|
return true
|
|
}
|
|
|
|
// Visibility is used for focus gating. Explicit portal visibility transitions
|
|
// also drive Ghostty occlusion so hidden workspace/split surfaces pause and
|
|
// queue a redraw when they become visible again.
|
|
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()
|
|
}
|
|
|
|
override func makeBackingLayer() -> CALayer {
|
|
let metalLayer = CAMetalLayer()
|
|
metalLayer.pixelFormat = .bgra8Unorm
|
|
metalLayer.isOpaque = false
|
|
// framebufferOnly=false lets the macOS compositor read the drawable
|
|
// when blending translucent or blurred window layers. This matches
|
|
// standalone Ghostty's SurfaceView and is required for background-opacity
|
|
// and background-blur to render correctly.
|
|
metalLayer.framebufferOnly = false
|
|
return metalLayer
|
|
}
|
|
|
|
private func setup() {
|
|
// Only enable our instrumented CAMetalLayer in targeted debug/test scenarios.
|
|
// The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs.
|
|
wantsLayer = true
|
|
layer?.masksToBounds = true
|
|
installEventMonitor()
|
|
updateTrackingAreas()
|
|
registerForDraggedTypes(Array(Self.dropTypes))
|
|
}
|
|
|
|
private func effectiveBackgroundColor() -> NSColor {
|
|
let base = backgroundColor ?? GhosttyApp.shared.defaultBackgroundColor
|
|
let opacity = GhosttyApp.shared.defaultBackgroundOpacity
|
|
return base.withAlphaComponent(opacity)
|
|
}
|
|
|
|
func applySurfaceBackground() {
|
|
let color = effectiveBackgroundColor()
|
|
if let layer {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
// GhosttySurfaceScrollView owns the panel background fill. Keeping this layer clear
|
|
// avoids stacking multiple identical translucent backgrounds (which looks opaque).
|
|
layer.backgroundColor = NSColor.clear.cgColor
|
|
layer.isOpaque = false
|
|
CATransaction.commit()
|
|
}
|
|
terminalSurface?.hostedView.setBackgroundColor(color)
|
|
if GhosttyApp.shared.backgroundLogEnabled {
|
|
let signature = "\(color.hexString()):\(String(format: "%.3f", color.alphaComponent))"
|
|
if signature != lastLoggedSurfaceBackgroundSignature {
|
|
lastLoggedSurfaceBackgroundSignature = signature
|
|
let hasOverride = backgroundColor != nil
|
|
let overrideHex = backgroundColor?.hexString() ?? "nil"
|
|
let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString()
|
|
let source = hasOverride ? "surfaceOverride" : "defaultBackground"
|
|
GhosttyApp.shared.logBackground(
|
|
"surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Theme/background application is window-local. During cross-window workspace
|
|
// switches (e.g. jump-to-unread), the global active tab manager can lag behind.
|
|
// Prefer the owning window's selected workspace when available.
|
|
static func shouldApplyWindowBackground(
|
|
surfaceTabId: UUID?,
|
|
owningManagerExists: Bool,
|
|
owningSelectedTabId: UUID?,
|
|
activeSelectedTabId: UUID?
|
|
) -> Bool {
|
|
guard let surfaceTabId else { return true }
|
|
if owningManagerExists {
|
|
guard let owningSelectedTabId else { return true }
|
|
return owningSelectedTabId == surfaceTabId
|
|
}
|
|
if let activeSelectedTabId {
|
|
return activeSelectedTabId == surfaceTabId
|
|
}
|
|
return true
|
|
}
|
|
|
|
func applyWindowBackgroundIfActive() {
|
|
guard let window else { return }
|
|
let appDelegate = AppDelegate.shared
|
|
let owningManager = tabId.flatMap { appDelegate?.tabManagerFor(tabId: $0) }
|
|
let owningSelectedTabId = owningManager?.selectedTabId
|
|
let activeSelectedTabId = owningManager == nil ? appDelegate?.tabManager?.selectedTabId : nil
|
|
guard Self.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: owningManager != nil,
|
|
owningSelectedTabId: owningSelectedTabId,
|
|
activeSelectedTabId: activeSelectedTabId
|
|
) else {
|
|
return
|
|
}
|
|
applySurfaceBackground()
|
|
let color = effectiveBackgroundColor()
|
|
if cmuxShouldUseClearWindowBackground(for: color.alphaComponent) {
|
|
window.backgroundColor = cmuxTransparentWindowBaseColor()
|
|
window.isOpaque = false
|
|
GhosttyApp.shared.applyWindowBlurIfNeeded(window)
|
|
} 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) {
|
|
let isSameSurface = terminalSurface === surface
|
|
let isAlreadyAttached = surface.isAttached(to: self)
|
|
if !isSameSurface {
|
|
appliedColorScheme = nil
|
|
}
|
|
terminalSurface = surface
|
|
tabId = surface.tabId
|
|
if !isAlreadyAttached {
|
|
surface.attachToView(self)
|
|
}
|
|
surface.setKeyboardCopyModeActive(keyboardCopyModeActive)
|
|
if !isAlreadyAttached {
|
|
updateSurfaceSize()
|
|
}
|
|
applySurfaceBackground()
|
|
applySurfaceColorScheme(force: !isSameSurface || !isAlreadyAttached)
|
|
}
|
|
|
|
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)
|
|
if let terminalSurface {
|
|
NotificationCenter.default.post(
|
|
name: .terminalSurfaceHostedViewDidMoveToWindow,
|
|
object: terminalSurface,
|
|
userInfo: [
|
|
"surfaceId": terminalSurface.id,
|
|
"workspaceId": terminalSurface.tabId
|
|
]
|
|
)
|
|
}
|
|
|
|
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()
|
|
invalidateTextInputCoordinates()
|
|
}
|
|
|
|
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()
|
|
invalidateTextInputCoordinates()
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
updateSurfaceSize()
|
|
invalidateTextInputCoordinates()
|
|
}
|
|
|
|
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 static func hasTabDragPasteboardTypes() -> Bool {
|
|
let types = NSPasteboard(name: .drag).types ?? []
|
|
return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType)
|
|
}
|
|
|
|
private static func isDragResizeEvent(_ eventType: NSEvent.EventType?) -> Bool {
|
|
switch eventType {
|
|
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func shouldDeferSurfaceResizeForActiveDrag() -> Bool {
|
|
// The drag pasteboard can retain tab-transfer UTIs briefly after a split command
|
|
// or other layout churn. Only defer terminal resizes while an actual drag event
|
|
// is in flight; otherwise pre-existing panes can stay stuck at their old size.
|
|
guard hasTabDragPasteboardTypes() else { return false }
|
|
return isDragResizeEvent(NSApp.currentEvent?.type)
|
|
}
|
|
|
|
private func activeSurfaceResizeDeferralReason() -> String? {
|
|
return Self.shouldDeferSurfaceResizeForActiveDrag() ? "tabDrag" : nil
|
|
}
|
|
|
|
private func scheduleDeferredSurfaceSizeRetryIfNeeded() {
|
|
guard window != nil else { return }
|
|
guard !deferredSurfaceSizeRetryQueued else { return }
|
|
deferredSurfaceSizeRetryQueued = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.deferredSurfaceSizeRetryQueued = false
|
|
_ = self.updateSurfaceSize()
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
|
|
guard let terminalSurface = terminalSurface else { return false }
|
|
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 false
|
|
}
|
|
pendingSurfaceSize = size
|
|
if let deferralReason = activeSurfaceResizeDeferralReason() {
|
|
scheduleDeferredSurfaceSizeRetryIfNeeded()
|
|
#if DEBUG
|
|
let signature = "\(deferralReason)-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))"
|
|
if lastSizeSkipSignature != signature {
|
|
dlog(
|
|
"surface.size.defer surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(deferralReason) " +
|
|
"size=\(String(format: "%.1fx%.1f", size.width, size.height)) " +
|
|
"inWindow=\(window != nil ? 1 : 0)"
|
|
)
|
|
lastSizeSkipSignature = signature
|
|
}
|
|
#endif
|
|
return false
|
|
}
|
|
|
|
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 false
|
|
}
|
|
|
|
// 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 false
|
|
}
|
|
#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))
|
|
)
|
|
var didChange = false
|
|
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
if let layer, !nearlyEqual(layer.contentsScale, layerScale) {
|
|
didChange = true
|
|
}
|
|
layer?.contentsScale = layerScale
|
|
layer?.masksToBounds = true
|
|
if let metalLayer = layer as? CAMetalLayer {
|
|
if drawablePixelSize != lastDrawableSize || metalLayer.drawableSize != drawablePixelSize {
|
|
if metalLayer.drawableSize != drawablePixelSize {
|
|
didChange = true
|
|
}
|
|
if metalLayer.drawableSize != drawablePixelSize {
|
|
metalLayer.drawableSize = drawablePixelSize
|
|
}
|
|
lastDrawableSize = drawablePixelSize
|
|
}
|
|
}
|
|
CATransaction.commit()
|
|
|
|
let surfaceSizeChanged = terminalSurface.updateSize(
|
|
width: size.width,
|
|
height: size.height,
|
|
xScale: xScale,
|
|
yScale: yScale,
|
|
layerScale: layerScale,
|
|
backingSize: backingSize
|
|
)
|
|
return didChange || surfaceSizeChanged
|
|
}
|
|
|
|
@discardableResult
|
|
fileprivate func pushTargetSurfaceSize(_ size: CGSize) -> Bool {
|
|
updateSurfaceSize(size: size)
|
|
}
|
|
|
|
#if DEBUG
|
|
fileprivate func debugPendingSurfaceSize() -> CGSize? {
|
|
pendingSurfaceSize
|
|
}
|
|
#endif
|
|
|
|
/// Force a full size reconciliation for the current bounds.
|
|
/// Keep the drawable-size cache intact so redundant refresh paths do not
|
|
/// reallocate Metal drawables when the pixel size is unchanged.
|
|
@discardableResult
|
|
func forceRefreshSurface() -> Bool {
|
|
updateSurfaceSize()
|
|
}
|
|
|
|
private func nearlyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool {
|
|
abs(lhs - rhs) <= epsilon
|
|
}
|
|
|
|
func expectedPixelSize(for pointsSize: CGSize) -> CGSize {
|
|
let backing = convertToBacking(NSRect(origin: .zero, size: pointsSize)).size
|
|
if backing.width > 0, backing.height > 0 {
|
|
return backing
|
|
}
|
|
let scale = max(1.0, window?.backingScaleFactor ?? layer?.contentsScale ?? 1.0)
|
|
return CGSize(width: pointsSize.width * scale, height: pointsSize.height * scale)
|
|
}
|
|
|
|
// Convenience accessor for the ghostty surface
|
|
private var surface: ghostty_surface_t? {
|
|
terminalSurface?.surface
|
|
}
|
|
|
|
private func applySurfaceColorScheme(force: Bool = false) {
|
|
guard let surface else { return }
|
|
let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua])
|
|
let scheme: ghostty_color_scheme_e = bestMatch == .darkAqua
|
|
? GHOSTTY_COLOR_SCHEME_DARK
|
|
: GHOSTTY_COLOR_SCHEME_LIGHT
|
|
if !force, appliedColorScheme == scheme {
|
|
if GhosttyApp.shared.backgroundLogEnabled {
|
|
let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light"
|
|
GhosttyApp.shared.logBackground(
|
|
"surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=false"
|
|
)
|
|
}
|
|
return
|
|
}
|
|
ghostty_surface_set_color_scheme(surface, scheme)
|
|
appliedColorScheme = scheme
|
|
if GhosttyApp.shared.backgroundLogEnabled {
|
|
let schemeLabel = scheme == GHOSTTY_COLOR_SCHEME_DARK ? "dark" : "light"
|
|
GhosttyApp.shared.logBackground(
|
|
"surface color scheme tab=\(tabId?.uuidString ?? "nil") surface=\(terminalSurface?.id.uuidString ?? "nil") bestMatch=\(bestMatch?.rawValue ?? "nil") scheme=\(schemeLabel) force=\(force) applied=true"
|
|
)
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
private func ensureSurfaceReadyForInput() -> ghostty_surface_t? {
|
|
if let surface = surface {
|
|
return surface
|
|
}
|
|
guard window != nil else { return nil }
|
|
terminalSurface?.attachToView(self)
|
|
updateSurfaceSize(size: bounds.size)
|
|
applySurfaceColorScheme(force: true)
|
|
return surface
|
|
}
|
|
|
|
func performBindingAction(_ action: String) -> Bool {
|
|
guard let surface = surface else { return false }
|
|
return action.withCString { cString in
|
|
ghostty_surface_binding_action(surface, cString, UInt(strlen(cString)))
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func toggleKeyboardCopyMode() -> Bool {
|
|
guard surface != nil else { return false }
|
|
setKeyboardCopyModeActive(!keyboardCopyModeActive)
|
|
if !keyboardCopyModeActive, let surface {
|
|
_ = ghostty_surface_clear_selection(surface)
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func setKeyboardCopyModeActive(_ active: Bool) {
|
|
keyboardCopyModeInputState.reset()
|
|
keyboardCopyModeVisualActive = false
|
|
keyboardCopyModeActive = active
|
|
if active, let surface {
|
|
keyboardCopyModeViewportRow = keyboardCopyModeSelectionAnchor(surface: surface)?.row
|
|
_ = ghostty_surface_clear_selection(surface)
|
|
if keyboardCopyModeViewportRow == nil {
|
|
keyboardCopyModeViewportRow = keyboardCopyModeImeViewportRow(surface: surface)
|
|
}
|
|
// Create a 1-cell selection at the terminal cursor to serve as a
|
|
// visible cursor indicator in copy mode.
|
|
_ = ghostty_surface_select_cursor_cell(surface)
|
|
} else {
|
|
keyboardCopyModeViewportRow = nil
|
|
}
|
|
terminalSurface?.setKeyboardCopyModeActive(active)
|
|
}
|
|
|
|
private func performBindingAction(_ action: String, repeatCount: Int) {
|
|
let count = terminalKeyboardCopyModeClampCount(repeatCount)
|
|
for _ in 0 ..< count {
|
|
_ = performBindingAction(action)
|
|
}
|
|
}
|
|
|
|
private func currentKeyboardCopyModeViewportRow(surface: ghostty_surface_t) -> Int {
|
|
let rows = max(Int(ghostty_surface_size(surface).rows), 1)
|
|
let fallback = rows - 1
|
|
return max(0, min(rows - 1, keyboardCopyModeViewportRow ?? fallback))
|
|
}
|
|
|
|
private func keyboardCopyModeImeViewportRow(surface: ghostty_surface_t) -> Int {
|
|
let rows = max(Int(ghostty_surface_size(surface).rows), 1)
|
|
var x: Double = 0
|
|
var y: Double = 0
|
|
var width: Double = 0
|
|
var height: Double = 0
|
|
ghostty_surface_ime_point(surface, &x, &y, &width, &height)
|
|
return terminalKeyboardCopyModeInitialViewportRow(
|
|
rows: rows,
|
|
imePointY: y,
|
|
imeCellHeight: height
|
|
)
|
|
}
|
|
|
|
private func keyboardCopyModeSelectionAnchor(surface: ghostty_surface_t) -> (row: Int, y: Double)? {
|
|
let size = ghostty_surface_size(surface)
|
|
guard size.rows > 0, size.columns > 0 else { return nil }
|
|
guard ghostty_surface_select_cursor_cell(surface) else { return nil }
|
|
|
|
var text = ghostty_text_s()
|
|
guard ghostty_surface_read_selection(surface, &text) else { return nil }
|
|
defer { ghostty_surface_free_text(surface, &text) }
|
|
|
|
let rows = max(Int(size.rows), 1)
|
|
let cols = max(Int(size.columns), 1)
|
|
let rawRow = Int(text.offset_start) / cols
|
|
let clampedRow = max(0, min(rows - 1, rawRow))
|
|
return (row: clampedRow, y: text.tl_px_y)
|
|
}
|
|
|
|
private func refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: ghostty_surface_t) {
|
|
// In visual mode the user owns the selection range; don't disturb it.
|
|
// Outside visual mode we keep a 1-cell cursor selection for visibility,
|
|
// so we still need to refresh the viewport row after scrolling.
|
|
guard !keyboardCopyModeVisualActive else { return }
|
|
guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else { return }
|
|
keyboardCopyModeViewportRow = anchor.row
|
|
// Preserve the visible cursor indicator.
|
|
_ = ghostty_surface_select_cursor_cell(surface)
|
|
}
|
|
|
|
private func copyCurrentViewportLinesToClipboard(
|
|
surface: ghostty_surface_t,
|
|
startRow: Int,
|
|
lineCount: Int
|
|
) -> Bool {
|
|
let clampedCount = terminalKeyboardCopyModeClampCount(lineCount)
|
|
let rows = max(Int(ghostty_surface_size(surface).rows), 1)
|
|
let targetRow = max(0, min(rows - 1, startRow))
|
|
let endRow = min(rows - 1, targetRow + clampedCount - 1)
|
|
guard let anchor = keyboardCopyModeSelectionAnchor(surface: surface) else {
|
|
return false
|
|
}
|
|
_ = ghostty_surface_clear_selection(surface)
|
|
|
|
var imeX: Double = 0
|
|
var imeY: Double = 0
|
|
var imeWidth: Double = 0
|
|
var imeHeight: Double = 0
|
|
ghostty_surface_ime_point(surface, &imeX, &imeY, &imeWidth, &imeHeight)
|
|
let cellHeight = imeHeight > 0 ? imeHeight : max(bounds.height / Double(rows), 1)
|
|
let yMax = max(bounds.height - 1, 0)
|
|
|
|
let startRawY = anchor.y + (Double(targetRow - anchor.row) * cellHeight)
|
|
let endRawY = anchor.y + (Double(endRow - anchor.row) * cellHeight)
|
|
let startY = max(0, min(startRawY, yMax))
|
|
let endY = max(0, min(endRawY, yMax))
|
|
let xMax = max(bounds.width - 1, 0)
|
|
let startX = min(1, xMax)
|
|
let endX = xMax
|
|
|
|
let mods = ghostty_input_mods_e(rawValue: GHOSTTY_MODS_NONE.rawValue) ?? GHOSTTY_MODS_NONE
|
|
ghostty_surface_mouse_pos(surface, startX, startY, mods)
|
|
guard ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) else {
|
|
return false
|
|
}
|
|
defer {
|
|
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
|
|
}
|
|
ghostty_surface_mouse_pos(surface, endX, endY, mods)
|
|
guard ghostty_surface_has_selection(surface) else { return false }
|
|
|
|
return performBindingAction("copy_to_clipboard")
|
|
}
|
|
|
|
private func handleKeyboardCopyModeIfNeeded(_ event: NSEvent, surface: ghostty_surface_t) -> Bool {
|
|
guard keyboardCopyModeActive else { return false }
|
|
|
|
if terminalKeyboardCopyModeShouldBypassForShortcut(modifierFlags: event.modifierFlags) {
|
|
keyboardCopyModeInputState.reset()
|
|
return false
|
|
}
|
|
|
|
// Use the visual-mode flag instead of raw has_selection so that the
|
|
// 1-cell cursor selection doesn't make every motion behave as visual.
|
|
let hasSelection = keyboardCopyModeVisualActive
|
|
let resolution = terminalKeyboardCopyModeResolve(
|
|
keyCode: event.keyCode,
|
|
charactersIgnoringModifiers: event.charactersIgnoringModifiers,
|
|
modifierFlags: event.modifierFlags,
|
|
hasSelection: hasSelection,
|
|
state: &keyboardCopyModeInputState
|
|
)
|
|
guard case let .perform(action, count) = resolution else {
|
|
return true
|
|
}
|
|
|
|
switch action {
|
|
case .exit:
|
|
_ = ghostty_surface_clear_selection(surface)
|
|
setKeyboardCopyModeActive(false)
|
|
case .startSelection:
|
|
keyboardCopyModeVisualActive = true
|
|
case .clearSelection:
|
|
keyboardCopyModeVisualActive = false
|
|
_ = ghostty_surface_clear_selection(surface)
|
|
// Re-create 1-cell cursor at terminal cursor position.
|
|
_ = ghostty_surface_select_cursor_cell(surface)
|
|
case .copyAndExit:
|
|
_ = performBindingAction("copy_to_clipboard")
|
|
_ = ghostty_surface_clear_selection(surface)
|
|
setKeyboardCopyModeActive(false)
|
|
case .copyLineAndExit:
|
|
let startRow = currentKeyboardCopyModeViewportRow(surface: surface)
|
|
_ = copyCurrentViewportLinesToClipboard(
|
|
surface: surface,
|
|
startRow: startRow,
|
|
lineCount: count
|
|
)
|
|
_ = ghostty_surface_clear_selection(surface)
|
|
setKeyboardCopyModeActive(false)
|
|
case let .scrollLines(delta):
|
|
_ = performBindingAction("scroll_page_lines:\(delta * count)")
|
|
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
|
|
case let .scrollPage(delta):
|
|
performBindingAction(delta > 0 ? "scroll_page_down" : "scroll_page_up", repeatCount: count)
|
|
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
|
|
case let .scrollHalfPage(delta):
|
|
let fraction = delta > 0 ? 0.5 : -0.5
|
|
performBindingAction("scroll_page_fractional:\(fraction)", repeatCount: count)
|
|
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
|
|
case .scrollToTop:
|
|
keyboardCopyModeViewportRow = 0
|
|
_ = performBindingAction("scroll_to_top")
|
|
case .scrollToBottom:
|
|
keyboardCopyModeViewportRow = max(Int(ghostty_surface_size(surface).rows) - 1, 0)
|
|
_ = performBindingAction("scroll_to_bottom")
|
|
case let .jumpToPrompt(delta):
|
|
_ = performBindingAction("jump_to_prompt:\(delta * count)")
|
|
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
|
|
case .startSearch:
|
|
_ = performBindingAction("start_search")
|
|
case .searchNext:
|
|
performBindingAction("navigate_search:next", repeatCount: count)
|
|
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
|
|
case .searchPrevious:
|
|
performBindingAction("navigate_search:previous", repeatCount: count)
|
|
refreshKeyboardCopyModeViewportRowFromVisibleAnchor(surface: surface)
|
|
case let .adjustSelection(direction):
|
|
performBindingAction("adjust_selection:\(direction.rawValue)", repeatCount: count)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// MARK: - Input Handling
|
|
|
|
@IBAction func copy(_ sender: Any?) {
|
|
_ = performBindingAction("copy_to_clipboard")
|
|
}
|
|
|
|
// MARK: - Clipboard paste
|
|
|
|
@IBAction func paste(_ sender: Any?) {
|
|
_ = performBindingAction("paste_from_clipboard")
|
|
}
|
|
|
|
/// Pastes clipboard text as plain text, stripping any rich formatting.
|
|
@IBAction func pasteAsPlainText(_ sender: Any?) {
|
|
_ = performBindingAction("paste_from_clipboard")
|
|
}
|
|
|
|
/// Validates whether edit menu items (copy, paste, split) should be enabled.
|
|
func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
|
|
switch item.action {
|
|
case #selector(copy(_:)):
|
|
guard let surface = surface else { return false }
|
|
return ghostty_surface_has_selection(surface)
|
|
case #selector(paste(_:)):
|
|
return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD)
|
|
case #selector(pasteAsPlainText(_:)):
|
|
return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD)
|
|
case #selector(splitHorizontally(_:)), #selector(splitVertically(_:)):
|
|
return canSplitCurrentSurface()
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - Accessibility
|
|
|
|
/// Expose the terminal surface as an editable accessibility element.
|
|
/// Voice input tools frequently target AX text areas for text insertion.
|
|
override func isAccessibilityElement() -> Bool {
|
|
true
|
|
}
|
|
|
|
override func accessibilityRole() -> NSAccessibility.Role? {
|
|
.textArea
|
|
}
|
|
|
|
override func accessibilityHelp() -> String? {
|
|
"Terminal content area"
|
|
}
|
|
|
|
override func accessibilityValue() -> Any? {
|
|
// We don't keep a full terminal text snapshot in this layer.
|
|
// Expose selected text when available; otherwise provide an empty value
|
|
// so AX clients still treat this as an editable text area.
|
|
accessibilitySelectedText() ?? ""
|
|
}
|
|
|
|
override func setAccessibilityValue(_ value: Any?) {
|
|
let content: String
|
|
switch value {
|
|
case let v as NSAttributedString:
|
|
content = v.string
|
|
case let v as String:
|
|
content = v
|
|
default:
|
|
return
|
|
}
|
|
|
|
guard !content.isEmpty else { return }
|
|
|
|
#if DEBUG
|
|
dlog("ime.ax.setValue len=\(content.count)")
|
|
#endif
|
|
|
|
let inject = {
|
|
self.insertText(content, replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
}
|
|
if Thread.isMainThread {
|
|
inject()
|
|
} else {
|
|
DispatchQueue.main.async(execute: inject)
|
|
}
|
|
}
|
|
|
|
override func accessibilitySelectedTextRange() -> NSRange {
|
|
selectedRange()
|
|
}
|
|
|
|
override func accessibilitySelectedText() -> String? {
|
|
guard let snapshot = readSelectionSnapshot() else { return nil }
|
|
return snapshot.string.isEmpty ? nil : snapshot.string
|
|
}
|
|
|
|
private func readSelectionSnapshot() -> SelectionSnapshot? {
|
|
guard let 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 selected: String
|
|
if let ptr = text.text, text.text_len > 0 {
|
|
let selectedData = Data(bytes: ptr, count: Int(text.text_len))
|
|
selected = String(decoding: selectedData, as: UTF8.self)
|
|
} else {
|
|
selected = ""
|
|
}
|
|
|
|
return SelectionSnapshot(
|
|
range: NSRange(location: Int(text.offset_start), length: Int(text.offset_len)),
|
|
string: selected,
|
|
topLeft: CGPoint(x: text.tl_px_x, y: text.tl_px_y)
|
|
)
|
|
}
|
|
|
|
private func visibleDocumentRectInScreenCoordinates() -> NSRect {
|
|
let localRect = visibleRect
|
|
let windowRect = convert(localRect, to: nil)
|
|
guard let window else { return windowRect }
|
|
return window.convertToScreen(windowRect)
|
|
}
|
|
|
|
private func invalidateTextInputCoordinates(selectionChanged: Bool = false) {
|
|
guard let inputContext else { return }
|
|
inputContext.invalidateCharacterCoordinates()
|
|
guard selectionChanged else { return }
|
|
|
|
// `textInputClientDidUpdateSelection` is absent from the Xcode 16.2 AppKit SDK
|
|
// used by the macOS 14 compatibility lane, so call it dynamically when present.
|
|
let updateSelectionSelector = NSSelectorFromString("textInputClientDidUpdateSelection")
|
|
guard inputContext.responds(to: updateSelectionSelector) else { return }
|
|
_ = inputContext.perform(updateSelectionSelector)
|
|
}
|
|
|
|
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
|
PaneFirstClickFocusSettings.isEnabled()
|
|
}
|
|
|
|
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,
|
|
]
|
|
)
|
|
}
|
|
terminalSurface?.recordExternalFocusState(true)
|
|
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))")
|
|
terminalSurface?.recordExternalFocusState(false)
|
|
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?
|
|
private struct SelectionSnapshot {
|
|
let range: NSRange
|
|
let string: String
|
|
let topLeft: CGPoint
|
|
}
|
|
|
|
#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 }
|
|
CmuxTypingTiming.logEventDelay(path: path, event: event)
|
|
}
|
|
#endif
|
|
|
|
// Prevents NSBeep for unimplemented actions from interpretKeyEvents
|
|
override func doCommand(by selector: Selector) {
|
|
// Intentionally empty - prevents system beep on unhandled key commands
|
|
}
|
|
|
|
/// Some third-party voice input apps inject committed text by sending the
|
|
/// responder-chain `insertText:` action (single-argument form).
|
|
/// Route that into our NSTextInputClient path so text lands in the terminal.
|
|
override func insertText(_ insertString: Any) {
|
|
insertText(insertString, replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
}
|
|
|
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
|
#if DEBUG
|
|
let typingTimingStart = CmuxTypingTiming.start()
|
|
defer {
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.performKeyEquivalent",
|
|
startedAt: typingTimingStart,
|
|
event: event
|
|
)
|
|
}
|
|
#endif
|
|
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 = ghosttyBindingFlags(for: event, surface: surface)
|
|
|
|
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.charactersIgnoringModifiers ?? ""
|
|
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) {
|
|
#if DEBUG
|
|
let typingTimingStart = CmuxTypingTiming.start()
|
|
let phaseTotalStart = ProcessInfo.processInfo.systemUptime
|
|
var ensureSurfaceMs: Double = 0
|
|
var dismissNotificationMs: Double = 0
|
|
var keyboardCopyModeMs: Double = 0
|
|
var interpretMs: Double = 0
|
|
var syncPreeditMs: Double = 0
|
|
var ghosttySendMs: Double = 0
|
|
var refreshMs: Double = 0
|
|
defer {
|
|
let totalMs = (ProcessInfo.processInfo.systemUptime - phaseTotalStart) * 1000.0
|
|
CmuxTypingTiming.logBreakdown(
|
|
path: "terminal.keyDown.phase",
|
|
totalMs: totalMs,
|
|
event: event,
|
|
thresholdMs: 1.0,
|
|
parts: [
|
|
("ensureSurfaceMs", ensureSurfaceMs),
|
|
("dismissNotificationMs", dismissNotificationMs),
|
|
("keyboardCopyModeMs", keyboardCopyModeMs),
|
|
("interpretMs", interpretMs),
|
|
("syncPreeditMs", syncPreeditMs),
|
|
("ghosttySendMs", ghosttySendMs),
|
|
("refreshMs", refreshMs),
|
|
],
|
|
extra: "marked=\(hasMarkedText() ? 1 : 0)"
|
|
)
|
|
CmuxTypingTiming.logDuration(path: "terminal.keyDown", startedAt: typingTimingStart, event: event)
|
|
}
|
|
let ensureSurfaceStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
guard let surface = ensureSurfaceReadyForInput() else {
|
|
#if DEBUG
|
|
ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0
|
|
#endif
|
|
super.keyDown(with: event)
|
|
return
|
|
}
|
|
#if DEBUG
|
|
ensureSurfaceMs = (ProcessInfo.processInfo.systemUptime - ensureSurfaceStart) * 1000.0
|
|
#endif
|
|
if let terminalSurface {
|
|
#if DEBUG
|
|
let dismissNotificationStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
|
tabId: terminalSurface.tabId,
|
|
surfaceId: terminalSurface.id
|
|
)
|
|
#if DEBUG
|
|
dismissNotificationMs = (ProcessInfo.processInfo.systemUptime - dismissNotificationStart) * 1000.0
|
|
#endif
|
|
}
|
|
if event.keyCode != 53 {
|
|
endFindEscapeSuppression()
|
|
}
|
|
if shouldConsumeSuppressedFindEscape(event) {
|
|
return
|
|
}
|
|
#if DEBUG
|
|
let keyboardCopyModeStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
if handleKeyboardCopyModeIfNeeded(event, surface: surface) {
|
|
#if DEBUG
|
|
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
|
|
#endif
|
|
keyboardCopyModeConsumedKeyUps.insert(event.keyCode)
|
|
return
|
|
}
|
|
#if DEBUG
|
|
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
|
|
#endif
|
|
if shouldRemapShiftEnterForTmux(event: event, surface: surface) {
|
|
terminalSurface?.sendText("\n")
|
|
return
|
|
}
|
|
#if DEBUG
|
|
recordKeyLatency(path: "keyDown", event: event)
|
|
#endif
|
|
|
|
#if DEBUG
|
|
cmuxWriteChildExitProbe(
|
|
[
|
|
"probeKeyDownCharsHex": cmuxScalarHex(event.characters),
|
|
"probeKeyDownCharsIgnoringHex": cmuxScalarHex(event.charactersIgnoringModifiers),
|
|
"probeKeyDownKeyCode": String(event.keyCode),
|
|
"probeKeyDownModsRaw": String(event.modifierFlags.rawValue),
|
|
"probeKeyDownSurfaceId": terminalSurface?.id.uuidString ?? "",
|
|
],
|
|
increments: ["probeKeyDownCount": 1]
|
|
)
|
|
#endif
|
|
|
|
// Fast path for control-modified terminal input (for example Ctrl+D).
|
|
//
|
|
// These keys are terminal control input, not text composition, so we bypass
|
|
// AppKit text interpretation and send a single deterministic Ghostty key event.
|
|
// This avoids intermittent drops after rapid split close/reparent transitions.
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
if flags.contains(.control) && !flags.contains(.command) && !flags.contains(.option) && !hasMarkedText() {
|
|
terminalSurface?.recordExternalFocusState(true)
|
|
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
|
|
#if DEBUG
|
|
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
|
handled = sendTimedGhosttyKey(
|
|
surface,
|
|
keyEvent,
|
|
path: "terminal.keyDown.ctrlGhosttySend",
|
|
event: event
|
|
)
|
|
ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
|
#else
|
|
handled = ghostty_surface_key(surface, keyEvent)
|
|
#endif
|
|
} else {
|
|
#if DEBUG
|
|
let sendTimingStart = CmuxTypingTiming.start()
|
|
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
handled = text.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
return ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
#if DEBUG
|
|
ghosttySendMs = (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.keyDown.ctrlGhosttySend",
|
|
startedAt: sendTimingStart,
|
|
event: event,
|
|
extra: "handled=\(handled ? 1 : 0)"
|
|
)
|
|
#endif
|
|
}
|
|
#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.)
|
|
#if DEBUG
|
|
let interpretTimingStart = CmuxTypingTiming.start()
|
|
let interpretPhaseStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
interpretKeyEvents([translationEvent])
|
|
#if DEBUG
|
|
interpretMs = (ProcessInfo.processInfo.systemUptime - interpretPhaseStart) * 1000.0
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.keyDown.interpretKeyEvents",
|
|
startedAt: interpretTimingStart,
|
|
event: event
|
|
)
|
|
#endif
|
|
|
|
// 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 {
|
|
#if DEBUG
|
|
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
syncPreedit(clearIfNeeded: markedTextBefore)
|
|
#if DEBUG
|
|
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
|
|
#endif
|
|
return
|
|
}
|
|
|
|
// Sync the preedit state with Ghostty so it can render the IME
|
|
// composition overlay (e.g. for Korean, Japanese, Chinese input).
|
|
#if DEBUG
|
|
let syncPreeditStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
syncPreedit(clearIfNeeded: markedTextBefore)
|
|
#if DEBUG
|
|
syncPreeditMs = (ProcessInfo.processInfo.systemUptime - syncPreeditStart) * 1000.0
|
|
#endif
|
|
|
|
// 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 ?? []
|
|
var shouldRefreshAfterTextInput = false
|
|
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) {
|
|
shouldRefreshAfterTextInput = true
|
|
#if DEBUG
|
|
let sendTimingStart = CmuxTypingTiming.start()
|
|
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
text.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
#if DEBUG
|
|
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.keyDown.accumulatedGhosttySend",
|
|
startedAt: sendTimingStart,
|
|
event: event,
|
|
extra: "textBytes=\(text.utf8.count)"
|
|
)
|
|
#endif
|
|
} else {
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.text = nil
|
|
#if DEBUG
|
|
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
|
_ = sendTimedGhosttyKey(
|
|
surface,
|
|
keyEvent,
|
|
path: "terminal.keyDown.accumulatedGhosttySend",
|
|
event: event
|
|
)
|
|
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
|
#else
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
if shouldSendCommittedIMEConfirmKey(
|
|
event: translationEvent,
|
|
markedTextBefore: markedTextBefore
|
|
) {
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.text = nil
|
|
#if DEBUG
|
|
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
|
_ = sendTimedGhosttyKey(
|
|
surface,
|
|
keyEvent,
|
|
path: "terminal.keyDown.accumulatedConfirmGhosttySend",
|
|
event: event
|
|
)
|
|
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
|
#else
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
#endif
|
|
}
|
|
} 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 {
|
|
shouldRefreshAfterTextInput = true
|
|
#if DEBUG
|
|
let sendTimingStart = CmuxTypingTiming.start()
|
|
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
text.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
#if DEBUG
|
|
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.keyDown.ghosttySend",
|
|
startedAt: sendTimingStart,
|
|
event: event,
|
|
extra: "textBytes=\(text.utf8.count)"
|
|
)
|
|
#endif
|
|
} else {
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.text = nil
|
|
#if DEBUG
|
|
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
|
_ = sendTimedGhosttyKey(
|
|
surface,
|
|
keyEvent,
|
|
path: "terminal.keyDown.ghosttySend",
|
|
event: event
|
|
)
|
|
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
|
#else
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
#endif
|
|
}
|
|
} else {
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.text = nil
|
|
#if DEBUG
|
|
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
|
|
_ = sendTimedGhosttyKey(
|
|
surface,
|
|
keyEvent,
|
|
path: "terminal.keyDown.ghosttySend",
|
|
event: event
|
|
)
|
|
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
|
|
#else
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
if shouldRefreshAfterTextInput {
|
|
#if DEBUG
|
|
let refreshStart = ProcessInfo.processInfo.systemUptime
|
|
#endif
|
|
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
|
|
#if DEBUG
|
|
refreshMs = (ProcessInfo.processInfo.systemUptime - refreshStart) * 1000.0
|
|
#endif
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
private func ghosttyBindingFlags(
|
|
for event: NSEvent,
|
|
surface: ghostty_surface_t
|
|
) -> ghostty_binding_flags_e? {
|
|
var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
|
|
let text = textForKeyEvent(event).flatMap { shouldSendText($0) ? $0 : nil } ?? ""
|
|
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
|
|
}
|
|
|
|
private func shouldRemapShiftEnterForTmux(
|
|
event: NSEvent,
|
|
surface: ghostty_surface_t
|
|
) -> Bool {
|
|
guard !GhosttyApp.shared.userConfigDefinesShiftEnterBinding else { return false }
|
|
let normalizedModifiers = terminalKeyboardCopyModeNormalizedModifiers(event.modifierFlags)
|
|
guard normalizedModifiers == [.shift] else { return false }
|
|
guard event.keyCode == 36 || event.keyCode == 76 else { return false }
|
|
guard let terminalSurface else { return false }
|
|
let tabId = terminalSurface.tabId
|
|
let panelId = terminalSurface.id
|
|
let isInsideTmux = AppDelegate.shared?
|
|
.tabManagerFor(tabId: tabId)?
|
|
.tabs
|
|
.first(where: { $0.id == tabId })?
|
|
.panelIsInsideTmux(panelId: panelId) ?? false
|
|
let ghosttyHasBinding = ghosttyBindingFlags(for: event, surface: surface) != nil
|
|
return GhosttyApp.shouldRemapShiftEnterForTmux(
|
|
keyCode: event.keyCode,
|
|
modifierFlags: event.modifierFlags,
|
|
isInsideTmux: isInsideTmux,
|
|
userConfigDefinesShiftEnterBinding: false,
|
|
ghosttyHasBinding: ghosttyHasBinding,
|
|
hasMarkedText: hasMarkedText()
|
|
)
|
|
}
|
|
|
|
#if DEBUG
|
|
@discardableResult
|
|
private func sendTimedGhosttyKey(
|
|
_ surface: ghostty_surface_t,
|
|
_ keyEvent: ghostty_input_key_s,
|
|
path: String,
|
|
event: NSEvent? = nil,
|
|
extra: String? = nil
|
|
) -> Bool {
|
|
let timingStart = CmuxTypingTiming.start()
|
|
let handled = sendGhosttyKey(surface, keyEvent)
|
|
let baseExtra = "handled=\(handled ? 1 : 0)"
|
|
let mergedExtra: String
|
|
if let extra, !extra.isEmpty {
|
|
mergedExtra = "\(baseExtra) \(extra)"
|
|
} else {
|
|
mergedExtra = baseExtra
|
|
}
|
|
CmuxTypingTiming.logDuration(path: path, startedAt: timingStart, event: event, extra: mergedExtra)
|
|
return handled
|
|
}
|
|
#endif
|
|
|
|
override func keyUp(with event: NSEvent) {
|
|
guard let surface = ensureSurfaceReadyForInput() else {
|
|
super.keyUp(with: event)
|
|
return
|
|
}
|
|
if event.keyCode != 53 {
|
|
endFindEscapeSuppression()
|
|
}
|
|
if shouldConsumeSuppressedFindEscape(event) {
|
|
endFindEscapeSuppression()
|
|
return
|
|
}
|
|
if event.keyCode == 53 {
|
|
endFindEscapeSuppression()
|
|
}
|
|
|
|
if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil {
|
|
return
|
|
}
|
|
|
|
// Build release events from the same translation path as keyDown so
|
|
// consumers that depend on precise key identity (for example Space
|
|
// hold/release flows) receive consistent metadata.
|
|
var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
|
|
keyEvent.action = GHOSTTY_ACTION_RELEASE
|
|
keyEvent.text = nil
|
|
keyEvent.composing = false
|
|
_ = sendGhosttyKey(surface, keyEvent)
|
|
}
|
|
|
|
override func flagsChanged(with event: NSEvent) {
|
|
guard let surface = surface else {
|
|
super.flagsChanged(with: event)
|
|
return
|
|
}
|
|
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = UInt32(event.keyCode)
|
|
keyEvent.mods = modsFromEvent(event)
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.text = nil
|
|
keyEvent.composing = false
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
|
|
private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e {
|
|
var mods = GHOSTTY_MODS_NONE.rawValue
|
|
if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
|
|
if event.modifierFlags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue }
|
|
if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue }
|
|
if event.modifierFlags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue }
|
|
return ghostty_input_mods_e(rawValue: mods)
|
|
}
|
|
|
|
/// Consumed mods are modifiers that were used for text translation.
|
|
/// Control and Command never contribute to text translation, so they
|
|
/// should be excluded from consumed_mods.
|
|
private func consumedModsFromFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
|
|
var mods = GHOSTTY_MODS_NONE.rawValue
|
|
// Only include Shift and Option as potentially consumed
|
|
// Control and Command are never consumed for text translation
|
|
if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
|
|
if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue }
|
|
return ghostty_input_mods_e(rawValue: mods)
|
|
}
|
|
|
|
func beginFindEscapeSuppression() {
|
|
isFindEscapeSuppressionArmed = true
|
|
}
|
|
|
|
private func endFindEscapeSuppression() {
|
|
isFindEscapeSuppressionArmed = false
|
|
}
|
|
|
|
private func shouldConsumeSuppressedFindEscape(_ event: NSEvent) -> Bool {
|
|
guard event.keyCode == 53 else { return false }
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
guard flags.isEmpty else { return false }
|
|
return isFindEscapeSuppressionArmed
|
|
}
|
|
|
|
/// Get the characters for a key event with control character handling.
|
|
/// When control is pressed, we get the character without the control modifier
|
|
/// so Ghostty's KeyEncoder can apply its own control character encoding.
|
|
private func textForKeyEvent(_ event: NSEvent) -> String? {
|
|
guard let chars = event.characters, !chars.isEmpty else { return nil }
|
|
|
|
if chars.count == 1, let scalar = chars.unicodeScalars.first {
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
|
|
// If we have a single control character, return the character without
|
|
// the control modifier so Ghostty's KeyEncoder can handle it.
|
|
if isControlCharacterScalar(scalar) {
|
|
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 isControlCharacterScalar(_ scalar: UnicodeScalar) -> Bool {
|
|
scalar.value < 0x20 || scalar.value == 0x7F
|
|
}
|
|
|
|
private func shouldSendText(_ text: String) -> Bool {
|
|
guard !text.isEmpty else { return false }
|
|
if text.count == 1, let scalar = text.unicodeScalars.first {
|
|
return !isControlCharacterScalar(scalar)
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// 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 shouldSendCommittedIMEConfirmKey(event: NSEvent, markedTextBefore: Bool) -> Bool {
|
|
guard markedTextBefore, markedText.length == 0 else { return false }
|
|
guard event.keyCode == 36 || event.keyCode == 76 else { return false }
|
|
// Korean IME: Enter commits the syllable AND executes the command (single step).
|
|
// Japanese/Chinese IME: Enter only confirms the conversion; a second Enter executes.
|
|
// Only send the extra Return key for Korean input sources.
|
|
guard let sourceId = KeyboardLayout.id else { return false }
|
|
return sourceId.range(of: "korean", options: .caseInsensitive) != nil
|
|
}
|
|
|
|
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)
|
|
let name: String
|
|
if let namePtr, nameLen > 0 {
|
|
let data = Data(bytes: namePtr, count: nameLen)
|
|
name = String(data: data, encoding: .utf8) ?? ""
|
|
} else {
|
|
name = ""
|
|
}
|
|
keyTables.append(name)
|
|
case GHOSTTY_KEY_TABLE_DEACTIVATE:
|
|
_ = keyTables.popLast()
|
|
case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL:
|
|
keyTables.removeAll()
|
|
default:
|
|
break
|
|
}
|
|
|
|
terminalSurface?.hostedView.syncKeyStateIndicator(text: currentKeyStateIndicatorText)
|
|
}
|
|
|
|
// 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
|
|
|
|
private func requestPointerFocusRecovery() {
|
|
#if DEBUG
|
|
dlog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
|
#endif
|
|
onFocus?()
|
|
}
|
|
|
|
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
|
|
// Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus`
|
|
// callback. Treat pointer-down as explicit focus intent so clicking a ghost pane still
|
|
// repairs workspace/pane active state before key routing runs.
|
|
requestPointerFocusRecovery()
|
|
window?.makeFirstResponder(self)
|
|
if let terminalSurface {
|
|
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
|
|
tabId: terminalSurface.tabId,
|
|
surfaceId: terminalSurface.id
|
|
)
|
|
}
|
|
guard let surface = surface else { return }
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
// Only update mouse position on the first click to prevent unwanted cursor
|
|
// movement during double-click selection (issue #1698)
|
|
if event.clickCount == 1 {
|
|
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) {
|
|
requestPointerFocusRecovery()
|
|
super.rightMouseDown(with: event)
|
|
return
|
|
}
|
|
|
|
requestPointerFocusRecovery()
|
|
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
|
|
}
|
|
requestPointerFocusRecovery()
|
|
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: String(localized: "terminalContextMenu.triggerFlash", defaultValue: "Trigger Flash"),
|
|
action: #selector(triggerFlash(_:)),
|
|
keyEquivalent: ""
|
|
)
|
|
flashItem.target = self
|
|
menu.addItem(.separator())
|
|
}
|
|
if ghostty_surface_has_selection(surface) {
|
|
let item = menu.addItem(
|
|
withTitle: String(localized: "terminalContextMenu.copy", defaultValue: "Copy"),
|
|
action: #selector(copy(_:)),
|
|
keyEquivalent: ""
|
|
)
|
|
item.target = self
|
|
}
|
|
let pasteItem = menu.addItem(
|
|
withTitle: String(localized: "terminalContextMenu.paste", defaultValue: "Paste"),
|
|
action: #selector(paste(_:)),
|
|
keyEquivalent: ""
|
|
)
|
|
pasteItem.target = self
|
|
menu.addItem(.separator())
|
|
let splitHorizontallyItem = menu.addItem(
|
|
withTitle: String(localized: "terminalContextMenu.splitHorizontally", defaultValue: "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: String(localized: "terminalContextMenu.splitVertically", defaultValue: "Split Vertically"),
|
|
action: #selector(splitVertically(_:)),
|
|
keyEquivalent: "d"
|
|
)
|
|
splitVerticallyItem.target = self
|
|
splitVerticallyItem.keyEquivalentModifierMask = [.command]
|
|
splitVerticallyItem.image = NSImage(
|
|
systemSymbolName: "rectangle.righthalf.inset.filled",
|
|
accessibilityDescription: nil
|
|
)
|
|
menu.addItem(.separator())
|
|
let resetTerminalItem = menu.addItem(
|
|
withTitle: String(localized: "terminalContextMenu.resetTerminal", defaultValue: "Reset Terminal"),
|
|
action: #selector(resetTerminal(_:)),
|
|
keyEquivalent: ""
|
|
)
|
|
resetTerminalItem.target = self
|
|
resetTerminalItem.image = NSImage(
|
|
systemSymbolName: "arrow.trianglehead.2.clockwise",
|
|
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.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
|
}
|
|
|
|
@objc private func triggerFlash(_ sender: Any?) {
|
|
onTriggerFlash?()
|
|
}
|
|
|
|
@objc private func resetTerminal(_ sender: Any?) {
|
|
_ = performBindingAction("reset")
|
|
}
|
|
|
|
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) {
|
|
NotificationCenter.default.post(name: .ghosttyDidReceiveWheelScroll, object: self)
|
|
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)
|
|
}
|
|
if let trackingArea {
|
|
removeTrackingArea(trackingArea)
|
|
}
|
|
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 {
|
|
TerminalImageTransferPlanner.escapeForShell(value)
|
|
}
|
|
|
|
static func dropPlanForTesting(
|
|
pasteboard: NSPasteboard,
|
|
isRemoteTerminalSurface: Bool
|
|
) -> DropPlan {
|
|
let target: TerminalImageTransferTarget = isRemoteTerminalSurface ? .remote(.workspaceRemote) : .local
|
|
switch TerminalImageTransferPlanner.plan(
|
|
pasteboard: pasteboard,
|
|
mode: .drop,
|
|
target: target
|
|
) {
|
|
case .insertText(let text):
|
|
return .insertText(text)
|
|
case .uploadFiles(let fileURLs, _):
|
|
return .uploadFiles(fileURLs)
|
|
case .reject:
|
|
return .reject
|
|
}
|
|
}
|
|
|
|
static func performRemoteDropUploadForTesting(
|
|
upload: (@escaping (Result<[String], Error>) -> Void) -> Void,
|
|
sendText: @escaping (String) -> Void,
|
|
onFailure: @escaping () -> Void
|
|
) {
|
|
upload { result in
|
|
switch result {
|
|
case .success(let remotePaths):
|
|
let content = remotePaths
|
|
.map { Self.escapeDropForShell($0) }
|
|
.joined(separator: " ")
|
|
guard !content.isEmpty else {
|
|
onFailure()
|
|
return
|
|
}
|
|
sendText(content)
|
|
case .failure:
|
|
onFailure()
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
static func handleDropForTesting(
|
|
pasteboard: NSPasteboard,
|
|
isRemoteTerminalSurface: Bool,
|
|
uploadRemote: ([URL], @escaping (Result<[String], Error>) -> Void) -> Void,
|
|
sendText: @escaping (String) -> Void,
|
|
onFailure: @escaping () -> Void
|
|
) -> Bool {
|
|
let target: TerminalImageTransferTarget = isRemoteTerminalSurface ? .remote(.workspaceRemote) : .local
|
|
let plan = TerminalImageTransferPlanner.plan(
|
|
pasteboard: pasteboard,
|
|
mode: .drop,
|
|
target: target
|
|
)
|
|
guard plan != .reject else { return false }
|
|
|
|
TerminalImageTransferPlanner.execute(
|
|
plan: plan,
|
|
uploadWorkspaceRemote: { urls, _, finish in
|
|
uploadRemote(urls) { result in
|
|
finish(result)
|
|
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(urls)
|
|
}
|
|
},
|
|
uploadDetectedSSH: { _, _, _, finish in
|
|
finish(.failure(NSError(domain: "cmux.remote.drop", code: 4)))
|
|
},
|
|
insertText: sendText,
|
|
onFailure: { _ in onFailure() }
|
|
)
|
|
return true
|
|
}
|
|
|
|
private func executeImageTransferPlan(
|
|
_ plan: TerminalImageTransferPlan,
|
|
operation: TerminalImageTransferOperation? = nil,
|
|
onCancel: @escaping () -> Void = {}
|
|
) -> Bool {
|
|
guard plan != .reject else { return false }
|
|
|
|
let operation = operation ?? {
|
|
if case .uploadFiles = plan {
|
|
return TerminalImageTransferOperation()
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
if let operation {
|
|
terminalSurface?.hostedView.beginImageTransferIndicator(
|
|
for: operation,
|
|
onCancel: onCancel
|
|
)
|
|
}
|
|
|
|
TerminalImageTransferPlanner.execute(
|
|
plan: plan,
|
|
operation: operation,
|
|
uploadWorkspaceRemote: { [weak self] fileURLs, operation, finish in
|
|
guard let workspace = MainActor.assumeIsolated({
|
|
self?.terminalSurface?.owningWorkspace()
|
|
}) else {
|
|
finish(.failure(NSError(domain: "cmux.remote.drop", code: 3)))
|
|
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
|
|
return
|
|
}
|
|
workspace.uploadDroppedFilesForRemoteTerminal(
|
|
fileURLs,
|
|
operation: operation,
|
|
completion: { result in
|
|
finish(result)
|
|
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
|
|
}
|
|
)
|
|
},
|
|
uploadDetectedSSH: { session, fileURLs, operation, finish in
|
|
session.uploadDroppedFiles(
|
|
fileURLs,
|
|
operation: operation,
|
|
completion: { result in
|
|
finish(result)
|
|
GhosttyPasteboardHelper.cleanupTransferredTemporaryImageFiles(fileURLs)
|
|
}
|
|
)
|
|
},
|
|
insertText: { [weak self] text in
|
|
let send = {
|
|
if let operation {
|
|
self?.terminalSurface?.hostedView.endImageTransferIndicator(for: operation)
|
|
}
|
|
// 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.
|
|
self?.terminalSurface?.sendText(text)
|
|
}
|
|
if Thread.isMainThread {
|
|
send()
|
|
} else {
|
|
DispatchQueue.main.async(execute: send)
|
|
}
|
|
},
|
|
onFailure: { [weak self] _ in
|
|
if let operation {
|
|
self?.terminalSurface?.hostedView.endImageTransferIndicator(for: operation)
|
|
}
|
|
DispatchQueue.main.async {
|
|
NSSound.beep()
|
|
#if DEBUG
|
|
dlog("terminal.remoteDropUpload.failed surface=\(self?.terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
|
#endif
|
|
}
|
|
}
|
|
)
|
|
return true
|
|
}
|
|
|
|
private func resolvedImageTransferTarget() -> TerminalImageTransferTarget {
|
|
MainActor.assumeIsolated {
|
|
terminalSurface?.resolvedImageTransferTarget() ?? .local
|
|
}
|
|
}
|
|
|
|
fileprivate func handleDroppedFileURLs(_ urls: [URL]) -> Bool {
|
|
executePreparedImageTransfer(
|
|
.fileURLs(urls),
|
|
onCancel: {}
|
|
)
|
|
}
|
|
|
|
@discardableResult
|
|
fileprivate func insertDroppedPasteboard(_ pasteboard: NSPasteboard) -> Bool {
|
|
executePreparedImageTransfer(
|
|
TerminalImageTransferPlanner.prepare(
|
|
pasteboard: pasteboard,
|
|
mode: .drop
|
|
),
|
|
onCancel: {}
|
|
)
|
|
}
|
|
|
|
@discardableResult
|
|
private func executePreparedImageTransfer(
|
|
_ preparedContent: TerminalImageTransferPreparedContent,
|
|
onCancel: @escaping () -> Void
|
|
) -> Bool {
|
|
switch preparedContent {
|
|
case .reject:
|
|
return false
|
|
case .insertText(let text):
|
|
terminalSurface?.sendText(text)
|
|
return true
|
|
case .fileURLs(let fileURLs):
|
|
let plan = TerminalImageTransferPlanner.plan(
|
|
fileURLs: fileURLs,
|
|
target: resolvedImageTransferTarget()
|
|
)
|
|
return executeImageTransferPlan(plan, onCancel: onCancel)
|
|
}
|
|
}
|
|
|
|
#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 ghosttyDidReceiveWheelScroll = Notification.Name("ghosttyDidReceiveWheelScroll")
|
|
static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus")
|
|
static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload")
|
|
static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange")
|
|
static let browserSearchFocus = Notification.Name("browserSearchFocus")
|
|
}
|
|
|
|
// MARK: - Scroll View Wrapper (Ghostty-style scrollbar)
|
|
|
|
private final class GhosttyScrollView: NSScrollView {
|
|
weak var surfaceView: GhosttyNSView?
|
|
|
|
// Keep keyboard routing on the terminal surface; this wrapper is viewport plumbing.
|
|
override var acceptsFirstResponder: Bool { false }
|
|
|
|
override func scrollWheel(with event: NSEvent) {
|
|
guard let surfaceView else {
|
|
super.scrollWheel(with: event)
|
|
return
|
|
}
|
|
|
|
// Route wheel gestures to the terminal surface so Ghostty handles scrollback.
|
|
// Letting NSScrollView consume these events moves the wrapper viewport itself,
|
|
// which causes pane-content drift instead of terminal scrollback movement.
|
|
GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: surface scroll")
|
|
if window?.firstResponder !== surfaceView {
|
|
window?.makeFirstResponder(surfaceView)
|
|
}
|
|
surfaceView.scrollWheel(with: event)
|
|
}
|
|
}
|
|
|
|
private final class GhosttyFlashOverlayView: NSView {
|
|
override var acceptsFirstResponder: Bool { false }
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
nil
|
|
}
|
|
}
|
|
|
|
private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView {
|
|
override var acceptsFirstResponder: Bool { false }
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
nil
|
|
}
|
|
}
|
|
|
|
func shouldAllowEnsureFocusWindowActivation(
|
|
activeTabManager: TabManager?,
|
|
targetTabManager: TabManager,
|
|
keyWindow: NSWindow?,
|
|
mainWindow: NSWindow?,
|
|
targetWindow: NSWindow
|
|
) -> Bool {
|
|
guard activeTabManager === targetTabManager || (keyWindow == nil && mainWindow == nil) else {
|
|
return false
|
|
}
|
|
|
|
if let keyWindow {
|
|
return keyWindow === targetWindow
|
|
}
|
|
|
|
if let mainWindow {
|
|
return mainWindow === targetWindow
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
final class GhosttySurfaceScrollView: NSView {
|
|
enum FlashStyle {
|
|
case navigation
|
|
case notification
|
|
}
|
|
|
|
static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> FlashStyle {
|
|
switch reason {
|
|
case .navigation:
|
|
return .navigation
|
|
case .notificationArrival, .notificationDismiss, .manualUnreadDismiss, .debug:
|
|
return .notification
|
|
}
|
|
}
|
|
|
|
private static func flashPresentation(for style: FlashStyle) -> WorkspaceAttentionFlashPresentation {
|
|
switch style {
|
|
case .navigation:
|
|
return WorkspaceAttentionCoordinator.flashStyle(for: .navigation)
|
|
case .notification:
|
|
return WorkspaceAttentionCoordinator.flashStyle(for: .notificationArrival)
|
|
}
|
|
}
|
|
|
|
private enum NotificationRingMetrics {
|
|
static let inset = PanelOverlayRingMetrics.inset
|
|
static let cornerRadius = PanelOverlayRingMetrics.cornerRadius
|
|
static let lineWidth = PanelOverlayRingMetrics.lineWidth
|
|
}
|
|
|
|
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 var lastFlashStyle: FlashStyle = .navigation
|
|
private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView
|
|
private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView
|
|
private let keyboardCopyModeBadgeIconView: NSImageView
|
|
private let keyboardCopyModeBadgeLabel: NSTextField
|
|
private let imageTransferIndicatorContainerView: NSView
|
|
private let imageTransferIndicatorView: NSVisualEffectView
|
|
private let imageTransferIndicatorSpinner: NSProgressIndicator
|
|
private let imageTransferCancelButton: NSButton
|
|
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
|
|
private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem?
|
|
private var imageTransferIndicatorShowWorkItem: DispatchWorkItem?
|
|
private var activeImageTransferOperation: TerminalImageTransferOperation?
|
|
private var activeImageTransferCancelHandler: (() -> Void)?
|
|
private var lastSearchOverlayStateID: ObjectIdentifier?
|
|
private var searchOverlayMutationGeneration: UInt64 = 0
|
|
private var observers: [NSObjectProtocol] = []
|
|
private var windowObservers: [NSObjectProtocol] = []
|
|
private var isLiveScrolling = false
|
|
private var lastSentRow: Int?
|
|
/// Tracks whether the user has scrolled away from the bottom to review scrollback.
|
|
/// When true, auto-scroll should be suspended to prevent the "doomscroll" bug
|
|
/// where the terminal fights the user's scroll position.
|
|
private var userScrolledAwayFromBottom = false
|
|
private var pendingExplicitWheelScroll = false
|
|
private var allowExplicitScrollbarSync = false
|
|
/// Threshold in points from bottom to consider "at bottom" (allows for minor float drift)
|
|
private static let scrollToBottomThreshold: CGFloat = 5.0
|
|
private var isActive = true
|
|
private var lastFocusRefreshAt: CFTimeInterval = 0
|
|
private var lastRequestedPortalOcclusionVisible: Bool?
|
|
private var activeDropZone: DropZone?
|
|
private var pendingDropZone: DropZone?
|
|
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
|
|
private var pendingAutomaticFirstResponderApply = false
|
|
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
|
|
|
|
/// Tracks whether keyboard focus should go to the search field or the terminal
|
|
/// when the window becomes key while the find bar is open.
|
|
enum SearchFocusTarget {
|
|
case searchField
|
|
case terminal
|
|
}
|
|
private(set) var searchFocusTarget: SearchFocusTarget = .searchField
|
|
|
|
private static func panelBackgroundFillColor(for terminalBackgroundColor: NSColor) -> NSColor {
|
|
// The Ghostty renderer already draws translucent terminal backgrounds. If we paint an
|
|
// additional translucent layer here, alpha stacks and appears effectively opaque.
|
|
terminalBackgroundColor.alphaComponent < 0.999 ? .clear : terminalBackgroundColor
|
|
}
|
|
|
|
#if DEBUG
|
|
private var lastDropZoneOverlayLogSignature: String?
|
|
private var lastDragGeometryLogSignature: String?
|
|
private var dragLayoutLogSequence: UInt64 = 0
|
|
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
|
|
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
|
|
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
|
|
)
|
|
}
|
|
|
|
func releaseOwnedPortalHost(hostId: ObjectIdentifier, reason: String) {
|
|
surfaceView.terminalSurface?.releasePortalHostIfOwned(
|
|
hostId: hostId,
|
|
reason: reason
|
|
)
|
|
}
|
|
|
|
func prepareOwnedPortalHostForTransientReattach(hostId: ObjectIdentifier, reason: String) {
|
|
surfaceView.terminalSurface?.preparePortalHostReplacementIfOwned(
|
|
hostId: hostId,
|
|
reason: reason
|
|
)
|
|
}
|
|
|
|
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()
|
|
keyboardCopyModeBadgeContainerView = GhosttyFlashOverlayView(frame: .zero)
|
|
keyboardCopyModeBadgeView = GhosttyPassthroughVisualEffectView(frame: .zero)
|
|
keyboardCopyModeBadgeIconView = NSImageView(frame: .zero)
|
|
keyboardCopyModeBadgeLabel = NSTextField(labelWithString: terminalKeyboardCopyModeIndicatorText)
|
|
imageTransferIndicatorContainerView = NSView(frame: .zero)
|
|
imageTransferIndicatorView = NSVisualEffectView(frame: .zero)
|
|
imageTransferIndicatorSpinner = NSProgressIndicator(frame: .zero)
|
|
imageTransferCancelButton = NSButton(frame: .zero)
|
|
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
|
|
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 = NotificationRingMetrics.lineWidth
|
|
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 = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor
|
|
flashLayer.lineWidth = NotificationRingMetrics.lineWidth
|
|
flashLayer.lineJoin = .round
|
|
flashLayer.lineCap = .round
|
|
flashLayer.shadowColor = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).accent.strokeColor.cgColor
|
|
flashLayer.shadowOpacity = Float(WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowOpacity)
|
|
flashLayer.shadowRadius = WorkspaceAttentionCoordinator.flashStyle(for: .navigation).glowRadius
|
|
flashLayer.shadowOffset = .zero
|
|
flashLayer.opacity = 0
|
|
flashOverlayView.layer?.addSublayer(flashLayer)
|
|
addSubview(flashOverlayView)
|
|
keyboardCopyModeBadgeContainerView.translatesAutoresizingMaskIntoConstraints = false
|
|
keyboardCopyModeBadgeContainerView.wantsLayer = true
|
|
keyboardCopyModeBadgeContainerView.layer?.masksToBounds = false
|
|
keyboardCopyModeBadgeContainerView.layer?.shadowColor = NSColor.black.cgColor
|
|
keyboardCopyModeBadgeContainerView.layer?.shadowOpacity = 0.22
|
|
keyboardCopyModeBadgeContainerView.layer?.shadowRadius = 10
|
|
keyboardCopyModeBadgeContainerView.layer?.shadowOffset = CGSize(width: 0, height: 2)
|
|
keyboardCopyModeBadgeView.translatesAutoresizingMaskIntoConstraints = false
|
|
keyboardCopyModeBadgeView.wantsLayer = true
|
|
keyboardCopyModeBadgeView.material = .hudWindow
|
|
keyboardCopyModeBadgeView.blendingMode = .withinWindow
|
|
keyboardCopyModeBadgeView.state = .active
|
|
keyboardCopyModeBadgeView.layer?.cornerRadius = 18
|
|
keyboardCopyModeBadgeView.layer?.masksToBounds = true
|
|
keyboardCopyModeBadgeView.layer?.borderWidth = 1
|
|
keyboardCopyModeBadgeView.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor
|
|
keyboardCopyModeBadgeView.alphaValue = 0.97
|
|
keyboardCopyModeBadgeIconView.translatesAutoresizingMaskIntoConstraints = false
|
|
keyboardCopyModeBadgeIconView.symbolConfiguration = NSImage.SymbolConfiguration(
|
|
pointSize: 13,
|
|
weight: .regular,
|
|
scale: .medium
|
|
)
|
|
keyboardCopyModeBadgeIconView.image = NSImage(
|
|
systemSymbolName: "keyboard.badge.ellipsis",
|
|
accessibilityDescription: terminalKeyTableIndicatorAccessibilityLabel
|
|
)
|
|
keyboardCopyModeBadgeIconView.contentTintColor = NSColor.secondaryLabelColor
|
|
keyboardCopyModeBadgeLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
keyboardCopyModeBadgeLabel.textColor = NSColor.labelColor
|
|
keyboardCopyModeBadgeLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
|
|
keyboardCopyModeBadgeLabel.lineBreakMode = .byTruncatingTail
|
|
keyboardCopyModeBadgeLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
keyboardCopyModeBadgeLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
keyboardCopyModeBadgeContainerView.addSubview(keyboardCopyModeBadgeView)
|
|
keyboardCopyModeBadgeView.addSubview(keyboardCopyModeBadgeIconView)
|
|
keyboardCopyModeBadgeView.addSubview(keyboardCopyModeBadgeLabel)
|
|
NSLayoutConstraint.activate([
|
|
keyboardCopyModeBadgeView.topAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.topAnchor),
|
|
keyboardCopyModeBadgeView.bottomAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.bottomAnchor),
|
|
keyboardCopyModeBadgeView.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.leadingAnchor),
|
|
keyboardCopyModeBadgeView.trailingAnchor.constraint(equalTo: keyboardCopyModeBadgeContainerView.trailingAnchor),
|
|
keyboardCopyModeBadgeView.widthAnchor.constraint(lessThanOrEqualToConstant: 180),
|
|
keyboardCopyModeBadgeIconView.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.leadingAnchor, constant: 12),
|
|
keyboardCopyModeBadgeIconView.centerYAnchor.constraint(equalTo: keyboardCopyModeBadgeView.centerYAnchor),
|
|
keyboardCopyModeBadgeIconView.widthAnchor.constraint(equalToConstant: 18),
|
|
keyboardCopyModeBadgeIconView.heightAnchor.constraint(equalToConstant: 18),
|
|
keyboardCopyModeBadgeLabel.leadingAnchor.constraint(equalTo: keyboardCopyModeBadgeIconView.trailingAnchor, constant: 7),
|
|
keyboardCopyModeBadgeLabel.trailingAnchor.constraint(equalTo: keyboardCopyModeBadgeView.trailingAnchor, constant: -14),
|
|
keyboardCopyModeBadgeLabel.topAnchor.constraint(equalTo: keyboardCopyModeBadgeView.topAnchor, constant: 8),
|
|
keyboardCopyModeBadgeLabel.bottomAnchor.constraint(equalTo: keyboardCopyModeBadgeView.bottomAnchor, constant: -8),
|
|
])
|
|
keyboardCopyModeBadgeContainerView.isHidden = true
|
|
addSubview(keyboardCopyModeBadgeContainerView)
|
|
NSLayoutConstraint.activate([
|
|
keyboardCopyModeBadgeContainerView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
|
keyboardCopyModeBadgeContainerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
|
])
|
|
|
|
imageTransferIndicatorContainerView.translatesAutoresizingMaskIntoConstraints = false
|
|
imageTransferIndicatorContainerView.wantsLayer = true
|
|
imageTransferIndicatorContainerView.layer?.masksToBounds = false
|
|
imageTransferIndicatorContainerView.layer?.shadowColor = NSColor.black.cgColor
|
|
imageTransferIndicatorContainerView.layer?.shadowOpacity = 0.18
|
|
imageTransferIndicatorContainerView.layer?.shadowRadius = 8
|
|
imageTransferIndicatorContainerView.layer?.shadowOffset = CGSize(width: 0, height: 2)
|
|
imageTransferIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
|
imageTransferIndicatorView.wantsLayer = true
|
|
imageTransferIndicatorView.material = .hudWindow
|
|
imageTransferIndicatorView.blendingMode = .withinWindow
|
|
imageTransferIndicatorView.state = .active
|
|
imageTransferIndicatorView.layer?.cornerRadius = 16
|
|
imageTransferIndicatorView.layer?.masksToBounds = true
|
|
imageTransferIndicatorView.layer?.borderWidth = 1
|
|
imageTransferIndicatorView.layer?.borderColor = NSColor.white.withAlphaComponent(0.12).cgColor
|
|
imageTransferIndicatorView.alphaValue = 0.95
|
|
imageTransferIndicatorSpinner.translatesAutoresizingMaskIntoConstraints = false
|
|
imageTransferIndicatorSpinner.style = .spinning
|
|
imageTransferIndicatorSpinner.controlSize = .small
|
|
imageTransferIndicatorSpinner.isDisplayedWhenStopped = false
|
|
imageTransferCancelButton.translatesAutoresizingMaskIntoConstraints = false
|
|
imageTransferCancelButton.isBordered = false
|
|
imageTransferCancelButton.imagePosition = .imageOnly
|
|
imageTransferCancelButton.image = NSImage(
|
|
systemSymbolName: "xmark.circle.fill",
|
|
accessibilityDescription: String(localized: "common.cancel", defaultValue: "Cancel")
|
|
)
|
|
imageTransferCancelButton.contentTintColor = NSColor.secondaryLabelColor
|
|
imageTransferCancelButton.toolTip = String(localized: "common.cancel", defaultValue: "Cancel")
|
|
imageTransferCancelButton.setAccessibilityLabel(
|
|
String(localized: "common.cancel", defaultValue: "Cancel")
|
|
)
|
|
imageTransferCancelButton.target = self
|
|
imageTransferCancelButton.action = #selector(handleImageTransferCancel)
|
|
imageTransferIndicatorContainerView.addSubview(imageTransferIndicatorView)
|
|
imageTransferIndicatorView.addSubview(imageTransferIndicatorSpinner)
|
|
imageTransferIndicatorView.addSubview(imageTransferCancelButton)
|
|
NSLayoutConstraint.activate([
|
|
imageTransferIndicatorView.topAnchor.constraint(equalTo: imageTransferIndicatorContainerView.topAnchor),
|
|
imageTransferIndicatorView.bottomAnchor.constraint(equalTo: imageTransferIndicatorContainerView.bottomAnchor),
|
|
imageTransferIndicatorView.leadingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.leadingAnchor),
|
|
imageTransferIndicatorView.trailingAnchor.constraint(equalTo: imageTransferIndicatorContainerView.trailingAnchor),
|
|
imageTransferIndicatorSpinner.leadingAnchor.constraint(equalTo: imageTransferIndicatorView.leadingAnchor, constant: 10),
|
|
imageTransferIndicatorSpinner.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor),
|
|
imageTransferIndicatorSpinner.widthAnchor.constraint(equalToConstant: 14),
|
|
imageTransferIndicatorSpinner.heightAnchor.constraint(equalToConstant: 14),
|
|
imageTransferCancelButton.leadingAnchor.constraint(equalTo: imageTransferIndicatorSpinner.trailingAnchor, constant: 6),
|
|
imageTransferCancelButton.trailingAnchor.constraint(equalTo: imageTransferIndicatorView.trailingAnchor, constant: -8),
|
|
imageTransferCancelButton.centerYAnchor.constraint(equalTo: imageTransferIndicatorView.centerYAnchor),
|
|
imageTransferCancelButton.widthAnchor.constraint(equalToConstant: 16),
|
|
imageTransferCancelButton.heightAnchor.constraint(equalToConstant: 16),
|
|
imageTransferIndicatorSpinner.topAnchor.constraint(equalTo: imageTransferIndicatorView.topAnchor, constant: 8),
|
|
imageTransferIndicatorSpinner.bottomAnchor.constraint(equalTo: imageTransferIndicatorView.bottomAnchor, constant: -8),
|
|
])
|
|
imageTransferIndicatorContainerView.isHidden = true
|
|
addSubview(imageTransferIndicatorContainerView)
|
|
NSLayoutConstraint.activate([
|
|
imageTransferIndicatorContainerView.topAnchor.constraint(
|
|
equalTo: keyboardCopyModeBadgeContainerView.bottomAnchor,
|
|
constant: 8
|
|
),
|
|
imageTransferIndicatorContainerView.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
|
|
// Final scroll position check to update userScrolledAwayFromBottom state
|
|
self?.handleLiveScroll()
|
|
})
|
|
|
|
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: .ghosttyDidReceiveWheelScroll,
|
|
object: surfaceView,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.pendingExplicitWheelScroll = true
|
|
})
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .ghosttySearchFocus,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard let self,
|
|
let surface = notification.object as? TerminalSurface,
|
|
surface === self.surfaceView.terminalSurface else { return }
|
|
self.searchFocusTarget = .searchField
|
|
// Explicitly unfocus the terminal so the cursor stops blinking
|
|
// when the search field takes over.
|
|
surface.setFocus(false)
|
|
})
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .ghosttyDidUpdateCellSize,
|
|
object: surfaceView,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.synchronizeScrollView()
|
|
})
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: NSScroller.preferredScrollerStyleDidChangeNotification,
|
|
object: nil,
|
|
// Match AppKit's geometry change immediately so the terminal width
|
|
// does not stay stuck behind a legacy scrollbar gutter.
|
|
queue: nil
|
|
) { [weak self] _ in
|
|
self?.handlePreferredScrollerStyleChange()
|
|
})
|
|
|
|
}
|
|
|
|
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) }
|
|
deferredSearchOverlayMutationWorkItem?.cancel()
|
|
imageTransferIndicatorShowWorkItem?.cancel()
|
|
dropZoneOverlayView.removeFromSuperview()
|
|
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()
|
|
}
|
|
|
|
override func viewDidMoveToSuperview() {
|
|
super.viewDidMoveToSuperview()
|
|
guard activeDropZone != nil || pendingDropZone != nil else { return }
|
|
attachDropZoneOverlayIfNeeded()
|
|
if let zone = activeDropZone ?? pendingDropZone {
|
|
applyDropZoneOverlayFrame(dropZoneOverlayFrame(for: zone, in: bounds.size))
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
@discardableResult
|
|
func reconcileGeometryNow() -> Bool {
|
|
guard Thread.isMainThread else {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.reconcileGeometryNow()
|
|
}
|
|
return false
|
|
}
|
|
|
|
return synchronizeGeometryAndContent()
|
|
}
|
|
|
|
/// Request an immediate terminal redraw after geometry updates so stale IOSurface
|
|
/// contents do not remain stretched during live resize churn.
|
|
func refreshSurfaceNow(reason: String = "portal.refreshSurfaceNow") {
|
|
surfaceView.terminalSurface?.forceRefresh(reason: reason)
|
|
}
|
|
|
|
@discardableResult
|
|
private func synchronizeGeometryAndContent() -> Bool {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
defer { CATransaction.commit() }
|
|
|
|
let previousSurfaceSize = surfaceView.frame.size
|
|
_ = setFrameIfNeeded(backgroundView, to: bounds)
|
|
_ = setFrameIfNeeded(scrollView, to: bounds)
|
|
let targetSize = scrollView.bounds.size
|
|
#if DEBUG
|
|
logLayoutDuringActiveDrag(targetSize: targetSize)
|
|
#endif
|
|
let targetSurfaceFrame = CGRect(origin: surfaceView.frame.origin, size: targetSize)
|
|
_ = setFrameIfNeeded(surfaceView, to: targetSurfaceFrame)
|
|
let targetDocumentFrame = CGRect(
|
|
origin: documentView.frame.origin,
|
|
size: CGSize(width: scrollView.bounds.width, height: documentView.frame.height)
|
|
)
|
|
_ = setFrameIfNeeded(documentView, to: targetDocumentFrame)
|
|
_ = setFrameIfNeeded(inactiveOverlayView, to: bounds)
|
|
if let zone = activeDropZone {
|
|
attachDropZoneOverlayIfNeeded()
|
|
_ = setFrameIfNeeded(
|
|
dropZoneOverlayView,
|
|
to: 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)
|
|
}
|
|
_ = setFrameIfNeeded(notificationRingOverlayView, to: bounds)
|
|
_ = setFrameIfNeeded(flashOverlayView, to: bounds)
|
|
if let overlay = searchOverlayHostingView {
|
|
_ = setFrameIfNeeded(overlay, to: bounds)
|
|
}
|
|
// NSScrollView can defer clip-view/content-size updates until its own layout pass,
|
|
// which makes interactive width changes arrive a queue turn late on Sequoia.
|
|
scrollView.layoutSubtreeIfNeeded()
|
|
updateNotificationRingPath()
|
|
updateFlashPath(style: lastFlashStyle)
|
|
updateFlashAppearance(style: lastFlashStyle)
|
|
synchronizeScrollView()
|
|
synchronizeSurfaceView()
|
|
let didCoreSurfaceChange = synchronizeCoreSurface()
|
|
return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange
|
|
}
|
|
|
|
@discardableResult
|
|
private func setFrameIfNeeded(_ view: NSView, to frame: CGRect) -> Bool {
|
|
guard !Self.rectApproximatelyEqual(view.frame, frame) else { return false }
|
|
view.frame = frame
|
|
return true
|
|
}
|
|
|
|
private func sizeApproximatelyEqual(_ lhs: CGSize, _ rhs: CGSize, epsilon: CGFloat = 0.0001) -> Bool {
|
|
abs(lhs.width - rhs.width) <= epsilon && abs(lhs.height - rhs.height) <= epsilon
|
|
}
|
|
|
|
private func pointApproximatelyEqual(_ lhs: CGPoint, _ rhs: CGPoint, epsilon: CGFloat = 0.5) -> Bool {
|
|
abs(lhs.x - rhs.x) <= epsilon && abs(lhs.y - rhs.y) <= epsilon
|
|
}
|
|
|
|
private func dropZoneOverlayContainerView() -> NSView {
|
|
superview ?? self
|
|
}
|
|
|
|
private func attachDropZoneOverlayIfNeeded() {
|
|
// Keep the hover indicator outside the hosted terminal subtree so it stays purely additive
|
|
// and cannot invalidate the scroll/surface layout that Ghostty renders into.
|
|
let container = dropZoneOverlayContainerView()
|
|
if dropZoneOverlayView.superview !== container {
|
|
dropZoneOverlayView.removeFromSuperview()
|
|
if container === self {
|
|
addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil)
|
|
} else {
|
|
container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: self)
|
|
}
|
|
#if DEBUG
|
|
logDropZoneOverlay(event: "attach", zone: activeDropZone ?? pendingDropZone, frame: dropZoneOverlayView.frame)
|
|
#endif
|
|
return
|
|
}
|
|
|
|
guard container !== self else { return }
|
|
guard let hostedIndex = container.subviews.firstIndex(of: self),
|
|
let overlayIndex = container.subviews.firstIndex(of: dropZoneOverlayView),
|
|
overlayIndex <= hostedIndex else { return }
|
|
container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: self)
|
|
}
|
|
|
|
private func applyDropZoneOverlayFrame(_ frame: CGRect) {
|
|
if Self.rectApproximatelyEqual(dropZoneOverlayView.frame, frame) { return }
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
dropZoneOverlayView.frame = frame
|
|
CATransaction.commit()
|
|
}
|
|
|
|
#if DEBUG
|
|
private static func isDragMouseEvent(_ eventType: NSEvent.EventType?) -> Bool {
|
|
switch eventType {
|
|
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func hasActiveDragLoggingContext() -> Bool {
|
|
let pasteboardTypes = NSPasteboard(name: .drag).types
|
|
let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true
|
|
let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true
|
|
let eventType = NSApp.currentEvent?.type
|
|
return activeDropZone != nil ||
|
|
pendingDropZone != nil ||
|
|
((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType))
|
|
}
|
|
|
|
private func logDragGeometryChange(event: String, old: CGPoint, new: CGPoint) {
|
|
guard hasActiveDragLoggingContext() else { return }
|
|
|
|
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
|
let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil"
|
|
let signature =
|
|
"\(event)|\(surface)|\(String(format: "%.1f,%.1f", old.x, old.y))|" +
|
|
"\(String(format: "%.1f,%.1f", new.x, new.y))|\(overlaySuperviewClass)|\(dropZoneOverlayView.isHidden ? 1 : 0)"
|
|
guard lastDragGeometryLogSignature != signature else { return }
|
|
lastDragGeometryLogSignature = signature
|
|
dlog(
|
|
"terminal.dragGeometry event=\(event) surface=\(surface) " +
|
|
"old=\(String(format: "%.1f,%.1f", old.x, old.y)) " +
|
|
"new=\(String(format: "%.1f,%.1f", new.x, new.y)) " +
|
|
"overlaySuper=\(overlaySuperviewClass) " +
|
|
"overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " +
|
|
"overlayHidden=\(dropZoneOverlayView.isHidden ? 1 : 0)"
|
|
)
|
|
}
|
|
|
|
private func logLayoutDuringActiveDrag(targetSize: CGSize) {
|
|
let pasteboardTypes = NSPasteboard(name: .drag).types
|
|
let hasTabDrag = pasteboardTypes?.contains(Self.tabTransferPasteboardType) == true
|
|
let hasSidebarDrag = pasteboardTypes?.contains(Self.sidebarTabReorderPasteboardType) == true
|
|
let eventType = NSApp.currentEvent?.type
|
|
let hasActiveDrag =
|
|
activeDropZone != nil ||
|
|
pendingDropZone != nil ||
|
|
((hasTabDrag || hasSidebarDrag) && Self.isDragMouseEvent(eventType))
|
|
guard hasActiveDrag else { return }
|
|
|
|
dragLayoutLogSequence &+= 1
|
|
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
|
let activeZone = activeDropZone.map { String(describing: $0) } ?? "none"
|
|
let pendingZone = pendingDropZone.map { String(describing: $0) } ?? "none"
|
|
let event = eventType.map { String(describing: $0) } ?? "nil"
|
|
let overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil"
|
|
dlog(
|
|
"terminal.layout.drag surface=\(surface) seq=\(dragLayoutLogSequence) " +
|
|
"activeZone=\(activeZone) pendingZone=\(pendingZone) " +
|
|
"hasTabDrag=\(hasTabDrag ? 1 : 0) hasSidebarDrag=\(hasSidebarDrag ? 1 : 0) " +
|
|
"event=\(event) inWindow=\(window != nil ? 1 : 0) " +
|
|
"overlaySuper=\(overlaySuperviewClass) overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " +
|
|
"scrollOrigin=\(String(format: "%.1f,%.1f", scrollView.contentView.bounds.origin.x, scrollView.contentView.bounds.origin.y)) " +
|
|
"surfaceOrigin=\(String(format: "%.1f,%.1f", surfaceView.frame.origin.x, surfaceView.frame.origin.y)) " +
|
|
"bounds=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
|
|
"target=\(String(format: "%.1fx%.1f", targetSize.width, targetSize.height))"
|
|
)
|
|
}
|
|
#endif
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
windowObservers.removeAll()
|
|
guard let window else { return }
|
|
windowObservers.append(NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didBecomeKeyNotification,
|
|
object: window,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
|
|
#if DEBUG
|
|
dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))")
|
|
#endif
|
|
self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey")
|
|
})
|
|
windowObservers.append(NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didResignKeyNotification,
|
|
object: window,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self, let window = self.window else { return }
|
|
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
|
|
// Losing key window does not always trigger first-responder resignation, so force
|
|
// the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync.
|
|
if let fr = window.firstResponder as? NSView,
|
|
fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) {
|
|
#if DEBUG
|
|
dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) resigningFirstResponder")
|
|
#endif
|
|
window.makeFirstResponder(nil)
|
|
} else {
|
|
#if DEBUG
|
|
dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) firstResponder=\(String(describing: window.firstResponder)) (not terminal, skipping)")
|
|
#endif
|
|
}
|
|
})
|
|
if window.isKeyWindow {
|
|
scheduleAutomaticFirstResponderApply(reason: "viewDidMoveToWindow")
|
|
}
|
|
}
|
|
|
|
func attachSurface(_ terminalSurface: TerminalSurface) {
|
|
surfaceView.attachSurface(terminalSurface)
|
|
}
|
|
|
|
func setFocusHandler(_ handler: (() -> Void)?) {
|
|
guard let handler else {
|
|
surfaceView.onFocus = nil
|
|
return
|
|
}
|
|
surfaceView.onFocus = { [weak self] in
|
|
// When the terminal surface gains focus (click, tab, etc.), update the
|
|
// search focus target so window reactivation restores terminal focus.
|
|
if self?.surfaceView.terminalSurface?.searchState != nil {
|
|
self?.searchFocusTarget = .terminal
|
|
}
|
|
handler()
|
|
}
|
|
}
|
|
|
|
func beginFindEscapeSuppression() {
|
|
surfaceView.beginFindEscapeSuppression()
|
|
}
|
|
|
|
func setTriggerFlashHandler(_ handler: (() -> Void)?) {
|
|
surfaceView.onTriggerFlash = handler
|
|
}
|
|
|
|
func setBackgroundColor(_ color: NSColor) {
|
|
guard let layer = backgroundView.layer else { return }
|
|
let fillColor = Self.panelBackgroundFillColor(for: color)
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
layer.backgroundColor = fillColor.cgColor
|
|
layer.isOpaque = fillColor.alphaComponent >= 1.0
|
|
CATransaction.commit()
|
|
}
|
|
|
|
func setInactiveOverlay(color: NSColor, opacity: CGFloat, visible: Bool) {
|
|
let clampedOpacity = max(0, min(1, opacity))
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
inactiveOverlayView.layer?.backgroundColor = color.withAlphaComponent(clampedOpacity).cgColor
|
|
inactiveOverlayView.isHidden = !(visible && clampedOpacity > 0.0001)
|
|
CATransaction.commit()
|
|
}
|
|
|
|
func setNotificationRing(visible: Bool) {
|
|
if !Thread.isMainThread {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.setNotificationRing(visible: visible)
|
|
}
|
|
return
|
|
}
|
|
|
|
let targetHidden = !visible
|
|
let targetOpacity: Float = visible ? 1 : 0
|
|
guard notificationRingOverlayView.isHidden != targetHidden ||
|
|
notificationRingLayer.opacity != targetOpacity else { return }
|
|
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
notificationRingOverlayView.isHidden = targetHidden
|
|
notificationRingLayer.opacity = targetOpacity
|
|
CATransaction.commit()
|
|
}
|
|
|
|
private func cancelDeferredSearchOverlayMutation() {
|
|
deferredSearchOverlayMutationWorkItem?.cancel()
|
|
deferredSearchOverlayMutationWorkItem = nil
|
|
}
|
|
|
|
private func scheduleDeferredSearchOverlayMutation(
|
|
generation: UInt64,
|
|
_ mutation: @escaping () -> Void
|
|
) {
|
|
cancelDeferredSearchOverlayMutation()
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
guard self.searchOverlayMutationGeneration == generation else { return }
|
|
self.deferredSearchOverlayMutationWorkItem = nil
|
|
mutation()
|
|
}
|
|
deferredSearchOverlayMutationWorkItem = work
|
|
DispatchQueue.main.async(execute: work)
|
|
}
|
|
|
|
private func cancelImageTransferIndicatorShow() {
|
|
imageTransferIndicatorShowWorkItem?.cancel()
|
|
imageTransferIndicatorShowWorkItem = nil
|
|
}
|
|
|
|
private func updateImageTransferIndicatorZOrder(relativeTo overlay: NSView?) {
|
|
guard !imageTransferIndicatorContainerView.isHidden else { return }
|
|
if let overlay, overlay.superview === self {
|
|
addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: overlay)
|
|
return
|
|
}
|
|
if keyboardCopyModeBadgeContainerView.superview === self,
|
|
!keyboardCopyModeBadgeContainerView.isHidden {
|
|
addSubview(
|
|
imageTransferIndicatorContainerView,
|
|
positioned: .above,
|
|
relativeTo: keyboardCopyModeBadgeContainerView
|
|
)
|
|
return
|
|
}
|
|
addSubview(imageTransferIndicatorContainerView, positioned: .above, relativeTo: nil)
|
|
}
|
|
|
|
private func updateKeyboardCopyModeBadgeZOrder(relativeTo overlay: NSView?) {
|
|
guard !keyboardCopyModeBadgeContainerView.isHidden else { return }
|
|
if let overlay, overlay.superview === self {
|
|
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
|
|
} else {
|
|
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil)
|
|
}
|
|
updateImageTransferIndicatorZOrder(relativeTo: overlay)
|
|
}
|
|
|
|
@objc private func handleImageTransferCancel() {
|
|
guard let operation = activeImageTransferOperation else { return }
|
|
let onCancel = activeImageTransferCancelHandler
|
|
guard operation.cancel() else { return }
|
|
endImageTransferIndicator(for: operation)
|
|
onCancel?()
|
|
}
|
|
|
|
func beginImageTransferIndicator(
|
|
for operation: TerminalImageTransferOperation,
|
|
onCancel: @escaping () -> Void
|
|
) {
|
|
if !Thread.isMainThread {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.beginImageTransferIndicator(for: operation, onCancel: onCancel)
|
|
}
|
|
return
|
|
}
|
|
|
|
cancelImageTransferIndicatorShow()
|
|
activeImageTransferOperation = operation
|
|
activeImageTransferCancelHandler = onCancel
|
|
imageTransferIndicatorSpinner.stopAnimation(nil)
|
|
imageTransferIndicatorContainerView.isHidden = true
|
|
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
guard self.activeImageTransferOperation === operation else { return }
|
|
guard !operation.isCancelled else { return }
|
|
self.imageTransferIndicatorShowWorkItem = nil
|
|
self.imageTransferIndicatorSpinner.startAnimation(nil)
|
|
self.imageTransferIndicatorContainerView.isHidden = false
|
|
self.updateImageTransferIndicatorZOrder(relativeTo: self.searchOverlayHostingView)
|
|
}
|
|
imageTransferIndicatorShowWorkItem = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: work)
|
|
}
|
|
|
|
func endImageTransferIndicator(for operation: TerminalImageTransferOperation?) {
|
|
if !Thread.isMainThread {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.endImageTransferIndicator(for: operation)
|
|
}
|
|
return
|
|
}
|
|
|
|
if let operation,
|
|
activeImageTransferOperation !== operation {
|
|
return
|
|
}
|
|
|
|
cancelImageTransferIndicatorShow()
|
|
activeImageTransferOperation = nil
|
|
activeImageTransferCancelHandler = nil
|
|
imageTransferIndicatorSpinner.stopAnimation(nil)
|
|
imageTransferIndicatorContainerView.isHidden = true
|
|
}
|
|
|
|
private func makeSearchOverlayRootView(
|
|
terminalSurface: TerminalSurface,
|
|
searchState: TerminalSurface.SearchState
|
|
) -> SurfaceSearchOverlay {
|
|
SurfaceSearchOverlay(
|
|
tabId: terminalSurface.tabId,
|
|
surfaceId: terminalSurface.id,
|
|
searchState: searchState,
|
|
canApplyFocusRequest: { [weak self] in
|
|
self?.canApplyMountedSearchFieldFocusRequest() ?? false
|
|
},
|
|
onMoveFocusToTerminal: { [weak self] in
|
|
self?.searchFocusTarget = .terminal
|
|
self?.moveFocus()
|
|
},
|
|
onNavigateSearch: { [weak terminalSurface] action in
|
|
_ = terminalSurface?.performBindingAction(action)
|
|
},
|
|
onFieldDidFocus: { [weak self, weak terminalSurface] in
|
|
self?.searchFocusTarget = .searchField
|
|
terminalSurface?.setFocus(false)
|
|
},
|
|
onClose: { [weak self, weak terminalSurface] in
|
|
terminalSurface?.searchState = nil
|
|
self?.moveFocus()
|
|
}
|
|
)
|
|
}
|
|
|
|
private func findEditableSearchField(in view: NSView?) -> NSTextField? {
|
|
guard let view else { return nil }
|
|
if let field = view as? NSTextField, field.isEditable {
|
|
return field
|
|
}
|
|
for subview in view.subviews {
|
|
if let field = findEditableSearchField(in: subview) {
|
|
return field
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func canApplyMountedSearchFieldFocusRequest() -> Bool {
|
|
guard let terminalSurface = surfaceView.terminalSurface,
|
|
let app = AppDelegate.shared,
|
|
let manager = app.tabManagerFor(tabId: terminalSurface.tabId),
|
|
manager.selectedTabId == terminalSurface.tabId,
|
|
let workspace = manager.tabs.first(where: { $0.id == terminalSurface.tabId }) else {
|
|
return false
|
|
}
|
|
return workspace.focusedPanelId == terminalSurface.id
|
|
}
|
|
|
|
private func requestMountedSearchFieldFocus(
|
|
generation: UInt64,
|
|
force: Bool,
|
|
attemptsRemaining: Int = 4
|
|
) {
|
|
guard searchOverlayMutationGeneration == generation else { return }
|
|
guard force || searchFocusTarget == .searchField else { return }
|
|
guard canApplyMountedSearchFieldFocusRequest() else { return }
|
|
guard let overlay = searchOverlayHostingView,
|
|
overlay.superview === self,
|
|
let window,
|
|
window.isKeyWindow else { return }
|
|
|
|
guard let field = findEditableSearchField(in: overlay) else {
|
|
guard attemptsRemaining > 0 else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
|
|
self?.requestMountedSearchFieldFocus(
|
|
generation: generation,
|
|
force: force,
|
|
attemptsRemaining: attemptsRemaining - 1
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
let firstResponder = window.firstResponder
|
|
let alreadyFocused = firstResponder === field ||
|
|
field.currentEditor() != nil ||
|
|
((firstResponder as? NSTextView)?.delegate as? NSTextField) === field
|
|
guard !alreadyFocused else { return }
|
|
|
|
surfaceView.terminalSurface?.setFocus(false)
|
|
let result = window.makeFirstResponder(field)
|
|
#if DEBUG
|
|
dlog(
|
|
"find.mountedFieldFocus surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"result=\(result ? 1 : 0) attemptsRemaining=\(attemptsRemaining) " +
|
|
"firstResponder=\(String(describing: window.firstResponder))"
|
|
)
|
|
#endif
|
|
guard !result, attemptsRemaining > 0 else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
|
|
self?.requestMountedSearchFieldFocus(
|
|
generation: generation,
|
|
force: force,
|
|
attemptsRemaining: attemptsRemaining - 1
|
|
)
|
|
}
|
|
}
|
|
|
|
func setSearchOverlay(searchState: TerminalSurface.SearchState?) {
|
|
if !Thread.isMainThread {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.setSearchOverlay(searchState: searchState)
|
|
}
|
|
return
|
|
}
|
|
|
|
searchOverlayMutationGeneration &+= 1
|
|
let mutationGeneration = searchOverlayMutationGeneration
|
|
|
|
// Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view.
|
|
// SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces.
|
|
guard let terminalSurface = surfaceView.terminalSurface,
|
|
let searchState else {
|
|
let hadOverlay = searchOverlayHostingView != nil
|
|
lastSearchOverlayStateID = nil
|
|
searchFocusTarget = .searchField
|
|
guard hadOverlay else {
|
|
cancelDeferredSearchOverlayMutation()
|
|
return
|
|
}
|
|
#if DEBUG
|
|
dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)")
|
|
#endif
|
|
scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self] in
|
|
self?.searchOverlayHostingView?.removeFromSuperview()
|
|
self?.searchOverlayHostingView = nil
|
|
}
|
|
return
|
|
}
|
|
|
|
let searchStateID = ObjectIdentifier(searchState)
|
|
if let overlay = searchOverlayHostingView,
|
|
lastSearchOverlayStateID == searchStateID,
|
|
overlay.superview === self {
|
|
cancelDeferredSearchOverlayMutation()
|
|
_ = setFrameIfNeeded(overlay, to: bounds)
|
|
updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
|
|
return
|
|
}
|
|
|
|
let hadOverlay = searchOverlayHostingView != nil
|
|
#if DEBUG
|
|
dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")")
|
|
#endif
|
|
|
|
let rootView = makeSearchOverlayRootView(
|
|
terminalSurface: terminalSurface,
|
|
searchState: searchState
|
|
)
|
|
|
|
if let overlay = searchOverlayHostingView {
|
|
overlay.rootView = rootView
|
|
lastSearchOverlayStateID = searchStateID
|
|
if overlay.superview !== self {
|
|
scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in
|
|
guard let self, let overlay else { return }
|
|
overlay.removeFromSuperview()
|
|
overlay.frame = self.bounds
|
|
overlay.autoresizingMask = [.width, .height]
|
|
self.addSubview(overlay)
|
|
self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
|
|
self.requestMountedSearchFieldFocus(
|
|
generation: mutationGeneration,
|
|
force: false
|
|
)
|
|
}
|
|
return
|
|
}
|
|
cancelDeferredSearchOverlayMutation()
|
|
_ = setFrameIfNeeded(overlay, to: bounds)
|
|
updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
|
|
return
|
|
}
|
|
|
|
searchFocusTarget = .searchField
|
|
let overlay = NSHostingView(rootView: rootView)
|
|
overlay.frame = bounds
|
|
overlay.autoresizingMask = [.width, .height]
|
|
searchOverlayHostingView = overlay
|
|
lastSearchOverlayStateID = searchStateID
|
|
scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in
|
|
guard let self, let overlay else { return }
|
|
guard self.searchOverlayHostingView === overlay else { return }
|
|
overlay.removeFromSuperview()
|
|
overlay.frame = self.bounds
|
|
overlay.autoresizingMask = [.width, .height]
|
|
self.addSubview(overlay)
|
|
self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
|
|
self.requestMountedSearchFieldFocus(
|
|
generation: mutationGeneration,
|
|
force: true
|
|
)
|
|
}
|
|
}
|
|
|
|
func syncKeyStateIndicator(text: String?) {
|
|
if !Thread.isMainThread {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.syncKeyStateIndicator(text: text)
|
|
}
|
|
return
|
|
}
|
|
|
|
if let text, !text.isEmpty {
|
|
keyboardCopyModeBadgeLabel.stringValue = text
|
|
keyboardCopyModeBadgeIconView.setAccessibilityLabel(text)
|
|
let needsReorder = keyboardCopyModeBadgeContainerView.isHidden
|
|
|| keyboardCopyModeBadgeContainerView.superview !== self
|
|
|| subviews.last !== keyboardCopyModeBadgeContainerView
|
|
keyboardCopyModeBadgeContainerView.isHidden = false
|
|
if needsReorder {
|
|
updateKeyboardCopyModeBadgeZOrder(relativeTo: searchOverlayHostingView)
|
|
}
|
|
return
|
|
}
|
|
|
|
keyboardCopyModeBadgeIconView.setAccessibilityLabel(terminalKeyTableIndicatorAccessibilityLabel)
|
|
keyboardCopyModeBadgeContainerView.isHidden = true
|
|
}
|
|
|
|
private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect {
|
|
let padding: CGFloat = 4
|
|
let localFrame: CGRect
|
|
switch zone {
|
|
case .center:
|
|
localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2)
|
|
case .left:
|
|
localFrame = CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
|
|
case .right:
|
|
localFrame = CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2)
|
|
case .top:
|
|
localFrame = CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding)
|
|
case .bottom:
|
|
localFrame = CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding)
|
|
}
|
|
|
|
let container = dropZoneOverlayView.superview ?? superview
|
|
guard let container, container !== self else { return localFrame }
|
|
return container.convert(localFrame, from: self)
|
|
}
|
|
|
|
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
|
|
|
|
if let zone {
|
|
#if DEBUG
|
|
if window == nil {
|
|
logDropZoneOverlay(event: "showNoWindow", zone: zone, frame: nil)
|
|
}
|
|
#endif
|
|
attachDropZoneOverlayIfNeeded()
|
|
let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size)
|
|
let previousFrame = dropZoneOverlayView.frame
|
|
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 {
|
|
applyDropZoneOverlayFrame(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 overlaySuperviewClass = dropZoneOverlayView.superview.map { String(describing: type(of: $0)) } ?? "nil"
|
|
let scrollOriginText = String(
|
|
format: "%.1f,%.1f",
|
|
scrollView.contentView.bounds.origin.x,
|
|
scrollView.contentView.bounds.origin.y
|
|
)
|
|
let surfaceOriginText = String(
|
|
format: "%.1f,%.1f",
|
|
surfaceView.frame.origin.x,
|
|
surfaceView.frame.origin.y
|
|
)
|
|
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)|\(overlaySuperviewClass)|" +
|
|
"\(scrollOriginText)|\(surfaceOriginText)|\(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) " +
|
|
"overlaySuper=\(overlaySuperviewClass) overlayExternal=\(dropZoneOverlayView.superview === self ? 0 : 1) " +
|
|
"scrollOrigin=\(scrollOriginText) surfaceOrigin=\(surfaceOriginText)"
|
|
)
|
|
}
|
|
#endif
|
|
|
|
func triggerFlash(style: FlashStyle = .navigation) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.lastFlashStyle = style
|
|
#if DEBUG
|
|
if let surfaceId = self.surfaceView.terminalSurface?.id {
|
|
Self.recordFlash(for: surfaceId)
|
|
}
|
|
#endif
|
|
self.updateFlashPath(style: style)
|
|
self.updateFlashAppearance(style: style)
|
|
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 wasVisible != visible, lastRequestedPortalOcclusionVisible != visible {
|
|
lastRequestedPortalOcclusionVisible = visible
|
|
surfaceView.terminalSurface?.setOcclusion(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 wasVisible != visible {
|
|
NotificationCenter.default.post(
|
|
name: .terminalPortalVisibilityDidChange,
|
|
object: self,
|
|
userInfo: [
|
|
GhosttyNotificationKey.surfaceId: surfaceView.terminalSurface?.id as Any,
|
|
GhosttyNotificationKey.tabId: surfaceView.tabId as Any
|
|
]
|
|
)
|
|
}
|
|
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 {
|
|
scheduleAutomaticFirstResponderApply(reason: "setVisibleInUI")
|
|
}
|
|
}
|
|
|
|
var debugPortalVisibleInUI: Bool {
|
|
surfaceView.isVisibleInUI
|
|
}
|
|
|
|
var debugPortalActive: Bool {
|
|
isActive
|
|
}
|
|
|
|
var debugPortalFrameInWindow: CGRect {
|
|
guard window != nil else { return .zero }
|
|
return convert(bounds, to: nil)
|
|
}
|
|
|
|
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 {
|
|
scheduleAutomaticFirstResponderApply(reason: "setActive")
|
|
} else {
|
|
resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
private func debugLogWorkspaceSwitchTiming(event: String, suffix: String) {
|
|
guard let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() else {
|
|
dlog("\(event) id=none \(suffix)")
|
|
return
|
|
}
|
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
|
dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)")
|
|
}
|
|
|
|
private func debugFirstResponderLabel() -> String {
|
|
guard let window, let firstResponder = window.firstResponder else { return "nil" }
|
|
if let view = firstResponder as? NSView {
|
|
if view === surfaceView {
|
|
return "surfaceView"
|
|
}
|
|
if view.isDescendant(of: surfaceView) {
|
|
return "surfaceDescendant"
|
|
}
|
|
return String(describing: type(of: view))
|
|
}
|
|
return String(describing: type(of: firstResponder))
|
|
}
|
|
|
|
private func debugVisibilityStateSuffix(transition: String) -> String {
|
|
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
|
let hiddenInHierarchy = (isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor) ? 1 : 0
|
|
let inWindow = window != nil ? 1 : 0
|
|
let hasSuperview = superview != nil ? 1 : 0
|
|
let hostHidden = isHidden ? 1 : 0
|
|
let surfaceHidden = surfaceView.isHidden ? 1 : 0
|
|
let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height)
|
|
let frameText = String(format: "%.1fx%.1f", frame.width, frame.height)
|
|
let responder = debugFirstResponderLabel()
|
|
return
|
|
"surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " +
|
|
"visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " +
|
|
"hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " +
|
|
"bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)"
|
|
}
|
|
#endif
|
|
|
|
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
|
|
#if DEBUG
|
|
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
|
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
|
|
dlog(
|
|
"find.moveFocus to=\(surfaceShort) " +
|
|
"from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"searchState=\(searchActive ? "active" : "nil") " +
|
|
"delayMs=\(Int((delay ?? 0) * 1000))"
|
|
)
|
|
#endif
|
|
let work = { [weak self] in
|
|
guard let self else { return }
|
|
guard let window = self.window else { return }
|
|
#if DEBUG
|
|
let before = String(describing: window.firstResponder)
|
|
#endif
|
|
if let previous, previous !== self {
|
|
_ = previous.surfaceView.resignFirstResponder()
|
|
}
|
|
let result = window.makeFirstResponder(self.surfaceView)
|
|
#if DEBUG
|
|
dlog(
|
|
"find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
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 debugPendingSurfaceSize() -> CGSize? {
|
|
surfaceView.debugPendingSurfaceSize()
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
struct DebugDropZoneOverlayState {
|
|
let isHidden: Bool
|
|
let frame: CGRect
|
|
let isAttachedToHostedView: Bool
|
|
let isAttachedToParentContainer: Bool
|
|
}
|
|
|
|
func debugDropZoneOverlayState() -> DebugDropZoneOverlayState {
|
|
DebugDropZoneOverlayState(
|
|
isHidden: dropZoneOverlayView.isHidden,
|
|
frame: dropZoneOverlayView.frame,
|
|
isAttachedToHostedView: dropZoneOverlayView.superview === self,
|
|
isAttachedToParentContainer: dropZoneOverlayView.superview === superview
|
|
)
|
|
}
|
|
|
|
func debugHasSearchOverlay() -> Bool {
|
|
guard let overlay = searchOverlayHostingView else { return false }
|
|
return overlay.superview === self && !overlay.isHidden
|
|
}
|
|
|
|
func debugHasKeyboardCopyModeIndicator() -> Bool {
|
|
keyboardCopyModeBadgeContainerView.superview === self && !keyboardCopyModeBadgeContainerView.isHidden
|
|
}
|
|
|
|
#endif
|
|
|
|
fileprivate var hasActiveDropZoneOverlay: Bool {
|
|
activeDropZone != nil || pendingDropZone != nil
|
|
}
|
|
|
|
/// Handle file/URL drops, forwarding to the terminal as shell-escaped paths.
|
|
func handleDroppedURLs(_ urls: [URL]) -> Bool {
|
|
#if DEBUG
|
|
dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))")
|
|
#endif
|
|
return surfaceView.handleDroppedFileURLs(urls)
|
|
}
|
|
|
|
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) {
|
|
let hasUsablePortalGeometry: Bool = {
|
|
let size = bounds.size
|
|
return size.width > 1 && size.height > 1
|
|
}()
|
|
let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor
|
|
|
|
guard isActive else { return }
|
|
guard let window else { return }
|
|
guard surfaceView.isVisibleInUI else {
|
|
#if DEBUG
|
|
dlog(
|
|
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"reason=not_visible"
|
|
)
|
|
#endif
|
|
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.notVisible")
|
|
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))"
|
|
)
|
|
#endif
|
|
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.hiddenOrTiny")
|
|
return
|
|
}
|
|
|
|
guard let delegate = AppDelegate.shared,
|
|
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
|
|
tabManager.selectedTabId == tabId else {
|
|
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.inactiveTab")
|
|
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 {
|
|
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane")
|
|
return
|
|
}
|
|
|
|
guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface,
|
|
tab.bonsplitController.focusedPaneId == paneId else {
|
|
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane")
|
|
return
|
|
}
|
|
|
|
// Search focus restoration — only after confirming this is the active tab/pane.
|
|
if surfaceView.terminalSurface?.searchState != nil {
|
|
#if DEBUG
|
|
dlog(
|
|
"focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
|
"firstResponder=\(String(describing: window.firstResponder))"
|
|
)
|
|
#endif
|
|
restoreSearchFocus(window: window)
|
|
return
|
|
}
|
|
|
|
if let fr = window.firstResponder as? NSView,
|
|
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
|
reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
|
|
return
|
|
}
|
|
|
|
if !window.isKeyWindow {
|
|
guard shouldAllowEnsureFocusWindowActivation(
|
|
activeTabManager: delegate.tabManager,
|
|
targetTabManager: tabManager,
|
|
keyWindow: NSApp.keyWindow,
|
|
mainWindow: NSApp.mainWindow,
|
|
targetWindow: window
|
|
) else {
|
|
return
|
|
}
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
let result = window.makeFirstResponder(surfaceView)
|
|
#if DEBUG
|
|
dlog(
|
|
"focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
|
|
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
|
|
)
|
|
#endif
|
|
|
|
if !isSurfaceViewFirstResponder() {
|
|
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.afterMakeFirstResponder")
|
|
} else {
|
|
reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder")
|
|
}
|
|
}
|
|
|
|
private func matchesCurrentTerminalFocusTarget(tabId: UUID, surfaceId: UUID) -> Bool {
|
|
guard let delegate = AppDelegate.shared,
|
|
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
|
|
tabManager.selectedTabId == tabId,
|
|
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 {
|
|
return false
|
|
}
|
|
|
|
return tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface &&
|
|
tab.bonsplitController.focusedPaneId == paneId
|
|
}
|
|
|
|
/// 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 scheduleAutomaticFirstResponderApply(reason: String) {
|
|
guard !pendingAutomaticFirstResponderApply else { return }
|
|
pendingAutomaticFirstResponderApply = true
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
self.pendingAutomaticFirstResponderApply = false
|
|
#if DEBUG
|
|
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
|
dlog("find.applyFirstResponder.defer surface=\(surfaceShort) reason=\(reason)")
|
|
#endif
|
|
self.applyFirstResponderIfNeeded()
|
|
}
|
|
}
|
|
|
|
private func reassertTerminalSurfaceFocus(reason: String) {
|
|
guard let terminalSurface = surfaceView.terminalSurface else { return }
|
|
#if DEBUG
|
|
dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
|
|
#endif
|
|
terminalSurface.setFocus(true)
|
|
refreshSurfaceAfterFocusIfNeeded(reason: reason)
|
|
}
|
|
|
|
private func refreshSurfaceAfterFocusIfNeeded(reason: String) {
|
|
guard let terminalSurface = surfaceView.terminalSurface,
|
|
isActive,
|
|
let window,
|
|
window.isKeyWindow,
|
|
surfaceView.isVisibleInUI else { return }
|
|
|
|
let now = CACurrentMediaTime()
|
|
if now - lastFocusRefreshAt < 0.05 {
|
|
return
|
|
}
|
|
lastFocusRefreshAt = now
|
|
#if DEBUG
|
|
dlog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
|
|
#endif
|
|
terminalSurface.forceRefresh(reason: "focus.surface.\(reason)")
|
|
}
|
|
|
|
private func applyFirstResponderIfNeeded() {
|
|
let hasUsablePortalGeometry: Bool = {
|
|
let size = bounds.size
|
|
return size.width > 1 && size.height > 1
|
|
}()
|
|
let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor
|
|
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
|
|
|
guard isActive else { return }
|
|
guard surfaceView.isVisibleInUI else { return }
|
|
guard !isHiddenForFocus, hasUsablePortalGeometry else {
|
|
#if DEBUG
|
|
dlog(
|
|
"focus.apply.skip surface=\(surfaceShort) " +
|
|
"reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))"
|
|
)
|
|
#endif
|
|
return
|
|
}
|
|
guard let window, window.isKeyWindow else { return }
|
|
guard let tabId = surfaceView.tabId,
|
|
let panelId = surfaceView.terminalSurface?.id,
|
|
matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else {
|
|
#if DEBUG
|
|
dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target")
|
|
#endif
|
|
return
|
|
}
|
|
if surfaceView.terminalSurface?.searchState != nil {
|
|
// Find bar is open. Restore focus based on what the user last intended.
|
|
restoreSearchFocus(window: window)
|
|
return
|
|
}
|
|
if let fr = window.firstResponder as? NSView,
|
|
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
|
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder")
|
|
return
|
|
}
|
|
// Don't steal focus from a search overlay on another surface in this window.
|
|
if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) {
|
|
#if DEBUG
|
|
dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused")
|
|
#endif
|
|
return
|
|
}
|
|
#if DEBUG
|
|
dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))")
|
|
#endif
|
|
window.makeFirstResponder(surfaceView)
|
|
if isSurfaceViewFirstResponder() {
|
|
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder")
|
|
}
|
|
}
|
|
|
|
/// Restore focus when window becomes key and the find bar is open.
|
|
/// Respects `searchFocusTarget` so Escape-to-terminal intent is preserved across window switches.
|
|
private func restoreSearchFocus(window: NSWindow) {
|
|
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
|
switch searchFocusTarget {
|
|
case .searchField:
|
|
if let firstResponder = window.firstResponder,
|
|
isCurrentSurfaceSearchFieldResponder(firstResponder) {
|
|
surfaceView.terminalSurface?.setFocus(false)
|
|
#if DEBUG
|
|
dlog(
|
|
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
|
|
"reason=alreadyFocused firstResponder=\(String(describing: firstResponder))"
|
|
)
|
|
#endif
|
|
return
|
|
}
|
|
if let firstResponder = window.firstResponder,
|
|
isSearchOverlayOrDescendant(firstResponder),
|
|
!isCurrentSurfaceSearchResponder(firstResponder) {
|
|
surfaceView.terminalSurface?.setFocus(false)
|
|
#if DEBUG
|
|
dlog(
|
|
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
|
|
"reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))"
|
|
)
|
|
#endif
|
|
return
|
|
}
|
|
// Explicitly unfocus the terminal so cursor stops blinking immediately.
|
|
// The notification observer also does this, but it runs async when posted from main.
|
|
surfaceView.terminalSurface?.setFocus(false)
|
|
// Post notification — SearchTextFieldRepresentable's Coordinator
|
|
// observes it and calls makeFirstResponder on the native NSTextField.
|
|
if let terminalSurface = surfaceView.terminalSurface {
|
|
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
|
}
|
|
#if DEBUG
|
|
dlog(
|
|
"find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
|
|
"via=notification firstResponder=\(String(describing: window.firstResponder))"
|
|
)
|
|
#endif
|
|
case .terminal:
|
|
let result = window.makeFirstResponder(surfaceView)
|
|
#if DEBUG
|
|
dlog(
|
|
"find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
|
|
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
|
|
)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
|
|
if surfaceView.terminalSurface?.searchState != nil {
|
|
if let firstResponder = window?.firstResponder as? NSView,
|
|
(firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) {
|
|
return .surface
|
|
}
|
|
if let firstResponder = window?.firstResponder,
|
|
isCurrentSurfaceSearchResponder(firstResponder) {
|
|
return .findField
|
|
}
|
|
if searchFocusTarget == .searchField {
|
|
return .findField
|
|
}
|
|
}
|
|
return .surface
|
|
}
|
|
|
|
func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent {
|
|
if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField {
|
|
return .findField
|
|
}
|
|
return .surface
|
|
}
|
|
|
|
func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) {
|
|
switch intent {
|
|
case .surface:
|
|
searchFocusTarget = .terminal
|
|
case .findField:
|
|
guard surfaceView.terminalSurface?.searchState != nil else { return }
|
|
searchFocusTarget = .searchField
|
|
}
|
|
#if DEBUG
|
|
dlog(
|
|
"find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"target=\(intent == .findField ? "searchField" : "terminal")"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
@discardableResult
|
|
func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool {
|
|
switch intent {
|
|
case .surface:
|
|
searchFocusTarget = .terminal
|
|
setActive(true)
|
|
applyFirstResponderIfNeeded()
|
|
return true
|
|
case .findField:
|
|
guard let terminalSurface = surfaceView.terminalSurface,
|
|
terminalSurface.searchState != nil else {
|
|
return false
|
|
}
|
|
searchFocusTarget = .searchField
|
|
setActive(true)
|
|
if let window {
|
|
restoreSearchFocus(window: window)
|
|
} else {
|
|
terminalSurface.setFocus(false)
|
|
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
|
}
|
|
#if DEBUG
|
|
dlog(
|
|
"find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
|
"target=searchField firstResponder=\(String(describing: window?.firstResponder))"
|
|
)
|
|
#endif
|
|
return true
|
|
}
|
|
}
|
|
|
|
func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? {
|
|
if isCurrentSurfaceSearchResponder(responder) {
|
|
return .findField
|
|
}
|
|
|
|
let resolvedResponder: NSResponder
|
|
if let editor = responder as? NSTextView,
|
|
editor.isFieldEditor,
|
|
let editedView = editor.delegate as? NSView {
|
|
resolvedResponder = editedView
|
|
} else {
|
|
resolvedResponder = responder
|
|
}
|
|
|
|
guard let view = resolvedResponder as? NSView else { return nil }
|
|
if view === surfaceView || view.isDescendant(of: surfaceView) {
|
|
return .surface
|
|
}
|
|
return nil
|
|
}
|
|
|
|
@discardableResult
|
|
func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool {
|
|
guard let firstResponder = window.firstResponder,
|
|
ownedPanelFocusIntent(for: firstResponder) == intent else {
|
|
return false
|
|
}
|
|
|
|
surfaceView.terminalSurface?.setFocus(false)
|
|
resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent")
|
|
#if DEBUG
|
|
dlog(
|
|
"focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"target=\(intent == .findField ? "searchField" : "terminal")"
|
|
)
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
private func resignOwnedFirstResponderIfNeeded(reason: String) {
|
|
guard let window,
|
|
let firstResponder = window.firstResponder else { return }
|
|
|
|
let ownsSurfaceResponder: Bool = {
|
|
guard let view = firstResponder as? NSView else { return false }
|
|
return view === surfaceView || view.isDescendant(of: surfaceView)
|
|
}()
|
|
|
|
guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }
|
|
|
|
#if DEBUG
|
|
dlog(
|
|
"focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"reason=\(reason) firstResponder=\(String(describing: firstResponder))"
|
|
)
|
|
#endif
|
|
window.makeFirstResponder(nil)
|
|
}
|
|
|
|
/// Check if a responder is inside a search overlay hosting view.
|
|
/// Handles the AppKit field-editor case: when an NSTextField is being edited,
|
|
/// window.firstResponder is the shared NSTextView field editor, not the text field.
|
|
private func isSearchOverlayOrDescendant(_ responder: NSResponder) -> Bool {
|
|
// If the responder is a field editor, follow its delegate back to the owning control.
|
|
if let editor = responder as? NSTextView,
|
|
editor.isFieldEditor,
|
|
let editedView = editor.delegate as? NSView {
|
|
return isSearchOverlayOrDescendant(editedView)
|
|
}
|
|
|
|
guard let view = responder as? NSView else { return false }
|
|
var current: NSView? = view
|
|
while let v = current {
|
|
if v is NSHostingView<SurfaceSearchOverlay> { return true }
|
|
let typeName = String(describing: type(of: v))
|
|
if typeName.contains("BrowserSearchOverlay") { return true }
|
|
current = v.superview
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool {
|
|
let resolvedResponder: NSResponder
|
|
if let editor = responder as? NSTextView,
|
|
editor.isFieldEditor,
|
|
let editedView = editor.delegate as? NSView {
|
|
resolvedResponder = editedView
|
|
} else {
|
|
resolvedResponder = responder
|
|
}
|
|
|
|
guard let view = resolvedResponder as? NSView else { return false }
|
|
return view.isDescendant(of: self)
|
|
}
|
|
|
|
private func isCurrentSurfaceSearchFieldResponder(_ responder: NSResponder) -> Bool {
|
|
if let editor = responder as? NSTextView,
|
|
editor.isFieldEditor,
|
|
let editedView = editor.delegate as? NSTextField {
|
|
return editedView.isDescendant(of: self) && isSearchOverlayOrDescendant(editedView)
|
|
}
|
|
|
|
guard let textField = responder as? NSTextField else { return false }
|
|
return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField)
|
|
}
|
|
|
|
#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
|
|
guard !pointApproximatelyEqual(surfaceView.frame.origin, visibleRect.origin) else { return }
|
|
#if DEBUG
|
|
logDragGeometryChange(event: "surfaceOrigin", old: surfaceView.frame.origin, new: visibleRect.origin)
|
|
#endif
|
|
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.
|
|
@discardableResult
|
|
private func synchronizeCoreSurface() -> Bool {
|
|
// Reserving extra overlay-scroller gutter here causes AppKit and libghostty to fight
|
|
// over terminal columns during split churn. The width can flap by one scrollbar gutter,
|
|
// which redraws the shell prompt multiple times on Cmd+D. Favor stable columns.
|
|
let width = max(0, scrollView.contentSize.width)
|
|
let height = surfaceView.frame.height
|
|
guard width > 0, height > 0 else { return false }
|
|
return surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height))
|
|
}
|
|
|
|
private func updateNotificationRingPath() {
|
|
updateOverlayRingPath(
|
|
layer: notificationRingLayer,
|
|
bounds: notificationRingOverlayView.bounds,
|
|
inset: NotificationRingMetrics.inset,
|
|
radius: NotificationRingMetrics.cornerRadius
|
|
)
|
|
}
|
|
|
|
private func updateFlashPath(style: FlashStyle) {
|
|
let inset: CGFloat
|
|
let radius: CGFloat
|
|
switch style {
|
|
case .navigation, .notification:
|
|
inset = NotificationRingMetrics.inset
|
|
radius = NotificationRingMetrics.cornerRadius
|
|
}
|
|
updateOverlayRingPath(
|
|
layer: flashLayer,
|
|
bounds: flashOverlayView.bounds,
|
|
inset: inset,
|
|
radius: radius
|
|
)
|
|
}
|
|
|
|
private func updateFlashAppearance(style: FlashStyle) {
|
|
let presentation = Self.flashPresentation(for: style)
|
|
let strokeColor = presentation.accent.strokeColor
|
|
flashLayer.strokeColor = strokeColor.cgColor
|
|
flashLayer.shadowColor = strokeColor.cgColor
|
|
flashLayer.shadowOpacity = Float(presentation.glowOpacity)
|
|
flashLayer.shadowRadius = presentation.glowRadius
|
|
}
|
|
|
|
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 = PanelOverlayRingMetrics.pathRect(in: bounds)
|
|
layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
|
}
|
|
|
|
private func synchronizeScrollView() {
|
|
var didChangeGeometry = false
|
|
let targetDocumentHeight = documentHeight()
|
|
if abs(documentView.frame.height - targetDocumentHeight) > 0.5 {
|
|
documentView.frame.size.height = targetDocumentHeight
|
|
didChangeGeometry = true
|
|
}
|
|
|
|
if !isLiveScrolling {
|
|
let cellHeight = surfaceView.cellSize.height
|
|
if cellHeight > 0, let scrollbar = surfaceView.scrollbar {
|
|
let offsetY =
|
|
CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight
|
|
let targetOrigin = CGPoint(x: 0, y: offsetY)
|
|
|
|
// Check if we're currently at the bottom (with threshold for float drift)
|
|
let currentOrigin = scrollView.contentView.bounds.origin
|
|
let documentHeight = documentView.frame.height
|
|
let viewportHeight = scrollView.contentView.bounds.height
|
|
let distanceFromBottom = documentHeight - currentOrigin.y - viewportHeight
|
|
let isAtBottom = distanceFromBottom <= Self.scrollToBottomThreshold
|
|
|
|
// Update userScrolledAwayFromBottom based on current position
|
|
if isAtBottom {
|
|
userScrolledAwayFromBottom = false
|
|
}
|
|
|
|
// Passive bottom packets should not override an explicit scrollback review,
|
|
// but the first scrollbar packet caused by the user's own wheel input should
|
|
// still move the viewport to the requested scrollback position.
|
|
let shouldAutoScroll = !userScrolledAwayFromBottom || allowExplicitScrollbarSync
|
|
|
|
if shouldAutoScroll && !pointApproximatelyEqual(currentOrigin, targetOrigin) {
|
|
scrollView.contentView.scroll(to: targetOrigin)
|
|
didChangeGeometry = true
|
|
}
|
|
lastSentRow = Int(scrollbar.offset)
|
|
}
|
|
}
|
|
|
|
allowExplicitScrollbarSync = false
|
|
|
|
if didChangeGeometry {
|
|
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
|
|
|
|
// Track if user has scrolled away from bottom to review scrollback
|
|
if scrollOffset > Self.scrollToBottomThreshold {
|
|
userScrolledAwayFromBottom = true
|
|
} else if scrollOffset <= 0 {
|
|
userScrolledAwayFromBottom = false
|
|
}
|
|
|
|
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
|
|
}
|
|
if pendingExplicitWheelScroll {
|
|
userScrolledAwayFromBottom = scrollbar.offset + scrollbar.len < scrollbar.total
|
|
allowExplicitScrollbarSync = true
|
|
pendingExplicitWheelScroll = false
|
|
}
|
|
surfaceView.scrollbar = scrollbar
|
|
synchronizeScrollView()
|
|
}
|
|
|
|
private func handlePreferredScrollerStyleChange() {
|
|
guard Thread.isMainThread else {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.handlePreferredScrollerStyleChange()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Retile just the scroll view so contentSize reflects the current
|
|
// scrollbar mode without perturbing viewport origin or hosted view
|
|
// geometry; the broader reconcile path caused visible content glitches.
|
|
scrollView.tile()
|
|
_ = synchronizeCoreSurface()
|
|
}
|
|
|
|
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
|
|
let typingTimingStart = CmuxTypingTiming.start()
|
|
#endif
|
|
#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)
|
|
}
|
|
#if DEBUG
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.sendTextToSurface",
|
|
startedAt: typingTimingStart,
|
|
extra: "textBytes=\(chars.utf8.count)"
|
|
)
|
|
#endif
|
|
}
|
|
|
|
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 {
|
|
readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
|
|
}
|
|
|
|
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
|
|
#if DEBUG
|
|
let typingTimingStart = CmuxTypingTiming.start()
|
|
defer {
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.setMarkedText",
|
|
startedAt: typingTimingStart,
|
|
extra: "markedLength=\(markedText.length)"
|
|
)
|
|
}
|
|
#endif
|
|
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()
|
|
invalidateTextInputCoordinates(selectionChanged: true)
|
|
}
|
|
}
|
|
|
|
func unmarkText() {
|
|
#if DEBUG
|
|
let hadMarkedText = markedText.length > 0
|
|
let typingTimingStart = CmuxTypingTiming.start()
|
|
defer {
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.unmarkText",
|
|
startedAt: typingTimingStart,
|
|
extra: "hadMarkedText=\(hadMarkedText ? 1 : 0)"
|
|
)
|
|
}
|
|
#endif
|
|
if markedText.length > 0 {
|
|
markedText.mutableString.setString("")
|
|
syncPreedit()
|
|
invalidateTextInputCoordinates(selectionChanged: true)
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
#if DEBUG
|
|
let typingTimingStart = CmuxTypingTiming.start()
|
|
defer {
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.syncPreedit",
|
|
startedAt: typingTimingStart,
|
|
extra: "markedLength=\(markedText.length) clearIfNeeded=\(clearIfNeeded ? 1 : 0)"
|
|
)
|
|
}
|
|
#endif
|
|
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? {
|
|
guard range.length > 0,
|
|
let snapshot = readSelectionSnapshot() else { return nil }
|
|
actualRange?.pointee = snapshot.range
|
|
return NSAttributedString(string: snapshot.string)
|
|
}
|
|
|
|
func characterIndex(for point: NSPoint) -> Int {
|
|
return selectedRange().location
|
|
}
|
|
|
|
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 range.length > 0,
|
|
range != selectedRange(),
|
|
let snapshot = readSelectionSnapshot() {
|
|
x = snapshot.topLeft.x - 2
|
|
y = snapshot.topLeft.y + 2
|
|
} else 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 range.length > 0,
|
|
range != selectedRange(),
|
|
let snapshot = readSelectionSnapshot() {
|
|
x = snapshot.topLeft.x - 2
|
|
y = snapshot.topLeft.y + 2
|
|
} else if let surface = surface {
|
|
ghostty_surface_ime_point(surface, &x, &y, &w, &h)
|
|
}
|
|
#endif
|
|
|
|
if range.length == 0, w > 0 {
|
|
// Dictation expects a caret rect for insertion points rather than a box.
|
|
w = 0
|
|
}
|
|
|
|
// 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 attributedString() -> NSAttributedString {
|
|
if markedText.length > 0 {
|
|
return NSAttributedString(attributedString: markedText)
|
|
}
|
|
if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty {
|
|
return NSAttributedString(string: snapshot.string)
|
|
}
|
|
return NSAttributedString(string: "")
|
|
}
|
|
|
|
func windowLevel() -> Int {
|
|
Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue)
|
|
}
|
|
|
|
@available(macOS 14.0, *)
|
|
var unionRectInVisibleSelectedRange: NSRect {
|
|
firstRect(forCharacterRange: selectedRange(), actualRange: nil)
|
|
}
|
|
|
|
@available(macOS 14.0, *)
|
|
var documentVisibleRect: NSRect {
|
|
visibleDocumentRectInScreenCoordinates()
|
|
}
|
|
|
|
func insertText(_ string: Any, replacementRange: NSRange) {
|
|
#if DEBUG
|
|
let typingTimingStart = CmuxTypingTiming.start()
|
|
defer {
|
|
CmuxTypingTiming.logDuration(
|
|
path: "terminal.insertText",
|
|
startedAt: typingTimingStart,
|
|
event: NSApp.currentEvent,
|
|
extra: "replacementLocation=\(replacementRange.location) replacementLength=\(replacementRange.length)"
|
|
)
|
|
}
|
|
#endif
|
|
// Get the string value
|
|
var chars = ""
|
|
switch string {
|
|
case let v as NSAttributedString:
|
|
chars = v.string
|
|
case let v as String:
|
|
chars = v
|
|
default:
|
|
return
|
|
}
|
|
|
|
// Clear marked text since we're inserting
|
|
unmarkText()
|
|
|
|
// Some IME/input-method paths call insertText with an empty payload to
|
|
// flush state. There is no terminal text to send in that case.
|
|
guard !chars.isEmpty else { return }
|
|
|
|
#if DEBUG
|
|
if NSApp.currentEvent == nil {
|
|
dlog("ime.insertText.noEvent len=\(chars.count)")
|
|
}
|
|
#endif
|
|
|
|
// If we have an accumulator, we're in a keyDown event - accumulate the text
|
|
if keyTextAccumulator != nil {
|
|
keyTextAccumulator?.append(chars)
|
|
return
|
|
}
|
|
|
|
// Otherwise send directly to the terminal
|
|
sendTextToSurface(chars)
|
|
}
|
|
}
|
|
|
|
// MARK: - SwiftUI Wrapper
|
|
|
|
struct GhosttyTerminalView: NSViewRepresentable {
|
|
@Environment(\.paneDropZone) var paneDropZone
|
|
|
|
let terminalSurface: TerminalSurface
|
|
let paneId: PaneID
|
|
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 {
|
|
private static var nextInstanceSerial: UInt64 = 0
|
|
|
|
var onDidMoveToWindow: (() -> Void)?
|
|
var onGeometryChanged: (() -> Void)?
|
|
let instanceSerial: UInt64
|
|
private(set) var geometryRevision: UInt64 = 0
|
|
private var lastReportedGeometryState: GeometryState?
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
Self.nextInstanceSerial &+= 1
|
|
instanceSerial = Self.nextInstanceSerial
|
|
super.init(frame: frameRect)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) not implemented")
|
|
}
|
|
|
|
private struct GeometryState: Equatable {
|
|
let frame: CGRect
|
|
let bounds: CGRect
|
|
let windowNumber: Int?
|
|
let superviewID: ObjectIdentifier?
|
|
}
|
|
|
|
private func currentGeometryState() -> GeometryState {
|
|
GeometryState(
|
|
frame: frame,
|
|
bounds: bounds,
|
|
windowNumber: window?.windowNumber,
|
|
superviewID: superview.map(ObjectIdentifier.init)
|
|
)
|
|
}
|
|
|
|
private func notifyGeometryChangedIfNeeded() {
|
|
let state = currentGeometryState()
|
|
guard state != lastReportedGeometryState else { return }
|
|
lastReportedGeometryState = state
|
|
geometryRevision &+= 1
|
|
onGeometryChanged?()
|
|
}
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
onDidMoveToWindow?()
|
|
notifyGeometryChangedIfNeeded()
|
|
}
|
|
|
|
override func viewDidMoveToSuperview() {
|
|
super.viewDidMoveToSuperview()
|
|
notifyGeometryChangedIfNeeded()
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
notifyGeometryChangedIfNeeded()
|
|
}
|
|
|
|
override func setFrameOrigin(_ newOrigin: NSPoint) {
|
|
super.setFrameOrigin(newOrigin)
|
|
notifyGeometryChangedIfNeeded()
|
|
}
|
|
|
|
override func setFrameSize(_ newSize: NSSize) {
|
|
super.setFrameSize(newSize)
|
|
notifyGeometryChangedIfNeeded()
|
|
}
|
|
}
|
|
|
|
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?
|
|
var lastSynchronizedHostGeometryRevision: UInt64 = 0
|
|
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
|
|
}
|
|
|
|
static func shouldSynchronizePortalGeometryImmediately(
|
|
hostInLiveResize: Bool,
|
|
windowInLiveResize: Bool,
|
|
interactiveGeometryResizeActive: Bool
|
|
) -> Bool {
|
|
hostInLiveResize || windowInLiveResize || interactiveGeometryResizeActive
|
|
}
|
|
|
|
private static func synchronizePortalGeometry(
|
|
for host: HostContainerView,
|
|
coordinator: Coordinator
|
|
) {
|
|
let geometryRevision = host.geometryRevision
|
|
guard coordinator.lastSynchronizedHostGeometryRevision != geometryRevision else { return }
|
|
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
|
|
let window = host.window
|
|
if shouldSynchronizePortalGeometryImmediately(
|
|
hostInLiveResize: host.inLiveResize,
|
|
windowInLiveResize: window?.inLiveResize == true,
|
|
interactiveGeometryResizeActive: TerminalWindowPortalRegistry.isInteractiveGeometryResizeActive
|
|
) {
|
|
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
|
|
return
|
|
}
|
|
// Avoid synchronizing the terminal portal while AppKit is still inside
|
|
// the current layout turn. Re-entrant syncs here can wedge window resize
|
|
// handling and leave the app spinning on the wait cursor.
|
|
guard let window else { return }
|
|
TerminalWindowPortalRegistry.scheduleExternalGeometrySynchronize(for: window)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let container = HostContainerView(frame: .zero)
|
|
container.wantsLayer = false
|
|
// The actual terminal surface lives in the AppKit portal layer above SwiftUI.
|
|
// This empty placeholder should not be walked by the accessibility subsystem.
|
|
container.setAccessibilityRole(.none)
|
|
container.setAccessibilityElement(false)
|
|
return container
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {
|
|
let hostedView = terminalSurface.hostedView
|
|
let coordinator = context.coordinator
|
|
let previousDesiredIsActive = coordinator.desiredIsActive
|
|
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
|
|
let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing
|
|
let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority
|
|
let desiredStateChanged =
|
|
previousDesiredIsActive != isActive ||
|
|
previousDesiredIsVisibleInUI != isVisibleInUI ||
|
|
previousDesiredPortalZPriority != portalZPriority
|
|
coordinator.desiredIsActive = isActive
|
|
coordinator.desiredIsVisibleInUI = isVisibleInUI
|
|
coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing
|
|
coordinator.desiredPortalZPriority = portalZPriority
|
|
coordinator.hostedView = hostedView
|
|
#if DEBUG
|
|
if desiredStateChanged {
|
|
if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() {
|
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
|
dlog(
|
|
"ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " +
|
|
"surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " +
|
|
"active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
|
|
"hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
|
|
"hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
|
|
)
|
|
} else {
|
|
dlog(
|
|
"ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
|
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
|
|
"hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
|
|
"hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
|
|
)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
let hostContainer = nsView as? HostContainerView
|
|
let hostOwnsPortalNow = hostContainer.map { host in
|
|
terminalSurface.claimPortalHost(
|
|
hostId: ObjectIdentifier(host),
|
|
paneId: paneId,
|
|
instanceSerial: host.instanceSerial,
|
|
inWindow: host.window != nil,
|
|
bounds: host.bounds,
|
|
reason: "update"
|
|
)
|
|
} ?? true
|
|
|
|
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
|
|
hostedView.attachSurface(terminalSurface)
|
|
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
|
|
hostedView.setTriggerFlashHandler(onTriggerFlash)
|
|
if hostOwnsPortalNow {
|
|
hostedView.setInactiveOverlay(
|
|
color: inactiveOverlayColor,
|
|
opacity: CGFloat(inactiveOverlayOpacity),
|
|
visible: showsInactiveOverlay
|
|
)
|
|
hostedView.setNotificationRing(visible: showsUnreadNotificationRing)
|
|
hostedView.setSearchOverlay(searchState: searchState)
|
|
hostedView.syncKeyStateIndicator(text: terminalSurface.currentKeyStateIndicatorText)
|
|
}
|
|
let portalExpectedSurfaceId = terminalSurface.id
|
|
let portalExpectedGeneration = terminalSurface.portalBindingGeneration()
|
|
func portalBindingStillLive() -> Bool {
|
|
terminalSurface.canAcceptPortalBinding(
|
|
expectedSurfaceId: portalExpectedSurfaceId,
|
|
expectedGeneration: portalExpectedGeneration
|
|
)
|
|
}
|
|
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
|
|
if hostOwnsPortalNow {
|
|
hostedView.setDropZoneOverlay(zone: forwardedDropZone)
|
|
}
|
|
|
|
coordinator.attachGeneration += 1
|
|
let generation = coordinator.attachGeneration
|
|
|
|
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 terminalSurface.claimPortalHost(
|
|
hostId: ObjectIdentifier(host),
|
|
paneId: paneId,
|
|
instanceSerial: host.instanceSerial,
|
|
inWindow: host.window != nil,
|
|
bounds: host.bounds,
|
|
reason: "didMoveToWindow"
|
|
) else { return }
|
|
guard host.window != nil else { return }
|
|
guard portalBindingStillLive() else { return }
|
|
TerminalWindowPortalRegistry.bind(
|
|
hostedView: hostedView,
|
|
to: host,
|
|
visibleInUI: coordinator.desiredIsVisibleInUI,
|
|
zPriority: coordinator.desiredPortalZPriority,
|
|
expectedSurfaceId: portalExpectedSurfaceId,
|
|
expectedGeneration: portalExpectedGeneration
|
|
)
|
|
coordinator.lastBoundHostId = ObjectIdentifier(host)
|
|
coordinator.lastSynchronizedHostGeometryRevision = host.geometryRevision
|
|
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 terminalSurface.claimPortalHost(
|
|
hostId: ObjectIdentifier(host),
|
|
paneId: paneId,
|
|
instanceSerial: host.instanceSerial,
|
|
inWindow: host.window != nil,
|
|
bounds: host.bounds,
|
|
reason: "geometryChanged"
|
|
) else { return }
|
|
guard portalBindingStillLive() else { return }
|
|
let hostId = ObjectIdentifier(host)
|
|
if host.window != nil,
|
|
(coordinator.lastBoundHostId != hostId ||
|
|
!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 = hostId
|
|
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
|
|
hostedView.setActive(coordinator.desiredIsActive)
|
|
hostedView.setNotificationRing(visible: coordinator.desiredShowsUnreadNotificationRing)
|
|
}
|
|
Self.synchronizePortalGeometry(
|
|
for: host,
|
|
coordinator: coordinator
|
|
)
|
|
}
|
|
|
|
if host.window != nil, hostOwnsPortalNow {
|
|
let portalBindingLive = portalBindingStillLive()
|
|
let hostId = ObjectIdentifier(host)
|
|
let geometryRevision = host.geometryRevision
|
|
let portalEntryMissing = !TerminalWindowPortalRegistry.isHostedView(hostedView, boundTo: host)
|
|
let shouldBindNow =
|
|
coordinator.lastBoundHostId != hostId ||
|
|
hostedView.superview == nil ||
|
|
portalEntryMissing ||
|
|
previousDesiredIsVisibleInUI != isVisibleInUI ||
|
|
previousDesiredShowsUnreadNotificationRing != showsUnreadNotificationRing ||
|
|
previousDesiredPortalZPriority != portalZPriority
|
|
if portalBindingLive && shouldBindNow {
|
|
#if DEBUG
|
|
if portalEntryMissing {
|
|
dlog(
|
|
"ws.hostState.rebindOnUpdate 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 = hostId
|
|
coordinator.lastSynchronizedHostGeometryRevision = geometryRevision
|
|
} else if portalBindingLive && coordinator.lastSynchronizedHostGeometryRevision != geometryRevision {
|
|
Self.synchronizePortalGeometry(
|
|
for: host,
|
|
coordinator: coordinator
|
|
)
|
|
}
|
|
} else if hostOwnsPortalNow, portalBindingStillLive() {
|
|
// 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 = hostOwnsPortalNow && Self.shouldApplyImmediateHostedStateUpdate(
|
|
hostedViewHasSuperview: hostedView.superview != nil,
|
|
isBoundToCurrentHost: isBoundToCurrentHost
|
|
)
|
|
|
|
if portalBindingStillLive() && 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=\(hostOwnsPortalNow ? "staleHostBinding" : "hostOwnershipRejected") " +
|
|
"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
|
|
hostedView?.prepareOwnedPortalHostForTransientReattach(
|
|
hostId: ObjectIdentifier(host),
|
|
reason: "dismantle"
|
|
)
|
|
}
|
|
|
|
// SwiftUI can transiently dismantle/rebuild NSViewRepresentable instances during split
|
|
// tree updates. Do not drop the portal lease or 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() }
|
|
}
|
|
}
|