* fix: skip Korean from CJK font-codepoint-map auto-injection The automatic CJK font-codepoint-map injection (PR #1017) maps Korean ranges to Apple SD Gothic Neo, which has a different style/weight from the primary terminal font. This overrides Ghostty's native CTFontCreateForString fallback, which dynamically selects a better-matching font for Hangul. Ghostty itself (ghostty-org/ghostty) has no hardcoded CJK font names and relies entirely on CTFontCreateForString for fallback. For Korean, this native fallback produces visually consistent results with the primary font. Remove the Korean branch from cjkFontMappings() so Ghostty's native fallback handles Hangul rendering. Japanese and Chinese mappings are unaffected. * test: update CJK font mapping tests for Korean removal - testCJKFontMappingsReturnsAppleSDGothicNeoWithHangulForKorean → renamed to testCJKFontMappingsReturnsNilForKoreanOnly → asserts nil since Korean is no longer auto-mapped - testCJKFontMappingsMultiLanguageMapsScriptSpecificRanges → renamed to testCJKFontMappingsMultiLanguageSkipsKorean → asserts no Apple SD Gothic Neo mapping exists → Japanese mappings remain unchanged --------- Co-authored-by: dante-ad-shield <danate@ad-shield.io>
9266 lines
374 KiB
Swift
9266 lines
374 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import AppKit
|
|
import Metal
|
|
import QuartzCore
|
|
import Combine
|
|
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
|
|
|
|
private enum GhosttyPasteboardHelper {
|
|
private static let selectionPasteboard = NSPasteboard(
|
|
name: NSPasteboard.Name("com.mitchellh.ghostty.selection")
|
|
)
|
|
private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text")
|
|
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
|
|
private static let objectReplacementCharacter = Character(UnicodeScalar(0xFFFC)!)
|
|
|
|
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 {
|
|
var result = value
|
|
for char in shellEscapeCharacters {
|
|
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
|
|
}
|
|
return result
|
|
}
|
|
|
|
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
|
|
/// 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? {
|
|
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 = "clipboard-\(timestamp)-\(UUID().uuidString.prefix(8)).\(fileExtension)"
|
|
let path = (NSTemporaryDirectory() as NSString).appendingPathComponent(filename)
|
|
|
|
do {
|
|
try imageData.write(to: URL(fileURLWithPath: path))
|
|
} catch {
|
|
#if DEBUG
|
|
dlog("terminal.paste.image.writeFailed error=\(error.localizedDescription)")
|
|
#endif
|
|
return nil
|
|
}
|
|
|
|
return escapeForShell(path)
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
func cmuxPasteboardStringContentsForTesting(_ pasteboard: NSPasteboard) -> String? {
|
|
GhosttyPasteboardHelper.stringContents(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?
|
|
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 var lastAppearanceColorScheme: GhosttyConfig.ColorSchemePreference?
|
|
private lazy var defaultBackgroundNotificationDispatcher: GhosttyDefaultBackgroundNotificationDispatcher =
|
|
// Theme chrome should track terminal theme changes in the same frame.
|
|
// Keep coalescing semantics, but flush in the next main turn instead of waiting ~1 frame.
|
|
GhosttyDefaultBackgroundNotificationDispatcher(delay: 0, logEvent: { [weak self] message in
|
|
guard let self, self.backgroundLogEnabled else { return }
|
|
self.logBackground(message)
|
|
})
|
|
|
|
// Scroll lag tracking
|
|
private(set) var isScrolling = false
|
|
private var scrollLagSampleCount = 0
|
|
private var scrollLagTotalMs: Double = 0
|
|
private var scrollLagMaxMs: Double = 0
|
|
private let scrollLagThresholdMs: Double = 40
|
|
private let scrollLagMinimumSamples = 8
|
|
private let scrollLagMinimumAverageMs: Double = 12
|
|
private let scrollLagReportCooldownSeconds: TimeInterval = 300
|
|
private var lastScrollLagReportUptime: TimeInterval?
|
|
private var scrollEndTimer: DispatchWorkItem?
|
|
|
|
func markScrollActivity(hasMomentum: Bool, momentumEnded: Bool) {
|
|
// Cancel any pending scroll-end timer
|
|
scrollEndTimer?.cancel()
|
|
scrollEndTimer = nil
|
|
|
|
if momentumEnded {
|
|
// Trackpad momentum ended - scrolling is done
|
|
endScrollSession()
|
|
} else if hasMomentum {
|
|
// Trackpad scrolling with momentum - wait for momentum to end
|
|
isScrolling = true
|
|
} else {
|
|
// Mouse wheel or non-momentum scroll - use timeout
|
|
isScrolling = true
|
|
let timer = DispatchWorkItem { [weak self] in
|
|
self?.endScrollSession()
|
|
}
|
|
scrollEndTimer = timer
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: timer)
|
|
}
|
|
}
|
|
|
|
private func endScrollSession() {
|
|
guard isScrolling else { return }
|
|
isScrolling = false
|
|
|
|
// Report accumulated lag stats if any exceeded threshold
|
|
if scrollLagSampleCount > 0 {
|
|
let avgLag = scrollLagTotalMs / Double(scrollLagSampleCount)
|
|
let maxLag = scrollLagMaxMs
|
|
let samples = scrollLagSampleCount
|
|
let threshold = scrollLagThresholdMs
|
|
let nowUptime = ProcessInfo.processInfo.systemUptime
|
|
if Self.shouldCaptureScrollLagEvent(
|
|
samples: samples,
|
|
averageMs: avgLag,
|
|
maxMs: maxLag,
|
|
thresholdMs: threshold,
|
|
minimumSamples: scrollLagMinimumSamples,
|
|
minimumAverageMs: scrollLagMinimumAverageMs,
|
|
nowUptime: nowUptime,
|
|
lastReportedUptime: lastScrollLagReportUptime,
|
|
cooldown: scrollLagReportCooldownSeconds
|
|
) {
|
|
if TelemetrySettings.enabledForCurrentLaunch {
|
|
SentrySDK.capture(message: "Scroll lag detected") { scope in
|
|
scope.setLevel(.warning)
|
|
scope.setContext(value: [
|
|
"samples": samples,
|
|
"avg_ms": String(format: "%.2f", avgLag),
|
|
"max_ms": String(format: "%.2f", maxLag),
|
|
"threshold_ms": threshold
|
|
], key: "scroll_lag")
|
|
}
|
|
}
|
|
lastScrollLagReportUptime = nowUptime
|
|
}
|
|
// Reset stats
|
|
scrollLagSampleCount = 0
|
|
scrollLagTotalMs = 0
|
|
scrollLagMaxMs = 0
|
|
}
|
|
}
|
|
|
|
private init() {
|
|
initializeGhostty()
|
|
}
|
|
|
|
#if DEBUG
|
|
private static let initLogPath = "/tmp/cmux-ghostty-init.log"
|
|
|
|
private static func initLog(_ message: String) {
|
|
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
let line = "[\(timestamp)] \(message)\n"
|
|
if let handle = FileHandle(forWritingAtPath: initLogPath) {
|
|
handle.seekToEndOfFile()
|
|
handle.write(line.data(using: .utf8)!)
|
|
handle.closeFile()
|
|
} else {
|
|
FileManager.default.createFile(atPath: initLogPath, contents: line.data(using: .utf8))
|
|
}
|
|
}
|
|
|
|
private static func dumpConfigDiagnostics(_ config: ghostty_config_t, label: String) {
|
|
let count = Int(ghostty_config_diagnostics_count(config))
|
|
guard count > 0 else {
|
|
initLog("ghostty diagnostics (\(label)): none")
|
|
return
|
|
}
|
|
initLog("ghostty diagnostics (\(label)): count=\(count)")
|
|
for i in 0..<count {
|
|
let diag = ghostty_config_get_diagnostic(config, UInt32(i))
|
|
let msg = diag.message.flatMap { String(cString: $0) } ?? "(null)"
|
|
initLog(" [\(i)] \(msg)")
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private func initializeGhostty() {
|
|
// Ensure TUI apps can use colors even if NO_COLOR is set in the launcher env.
|
|
if getenv("NO_COLOR") != nil {
|
|
unsetenv("NO_COLOR")
|
|
}
|
|
|
|
// Initialize Ghostty library first
|
|
let result = ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv)
|
|
if result != GHOSTTY_SUCCESS {
|
|
print("Failed to initialize ghostty: \(result)")
|
|
return
|
|
}
|
|
|
|
// Load config
|
|
guard let primaryConfig = ghostty_config_new() else {
|
|
print("Failed to create ghostty config")
|
|
return
|
|
}
|
|
|
|
// Load default config (includes user config). If this fails hard (e.g. due to
|
|
// invalid user config), ghostty_app_new may return nil; we fall back below.
|
|
loadDefaultConfigFilesWithLegacyFallback(primaryConfig)
|
|
updateDefaultBackground(from: primaryConfig, source: "initialize.primaryConfig")
|
|
|
|
// Create runtime config with callbacks
|
|
var runtimeConfig = ghostty_runtime_config_s()
|
|
runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque()
|
|
runtimeConfig.supports_selection_clipboard = true
|
|
runtimeConfig.wakeup_cb = { userdata in
|
|
DispatchQueue.main.async {
|
|
GhosttyApp.shared.tick()
|
|
}
|
|
}
|
|
runtimeConfig.action_cb = { app, target, action in
|
|
return GhosttyApp.shared.handleAction(target: target, action: action)
|
|
}
|
|
runtimeConfig.read_clipboard_cb = { userdata, location, state in
|
|
// Read clipboard
|
|
guard let callbackContext = GhosttyApp.callbackContext(from: userdata),
|
|
let surface = callbackContext.runtimeSurface else { return }
|
|
|
|
let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location)
|
|
var value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? ""
|
|
|
|
// When clipboard has only image data (e.g. screenshot), save as temp
|
|
// PNG and paste the file path so CLI tools can receive images.
|
|
if value.isEmpty,
|
|
let imagePath = pasteboard.flatMap({
|
|
GhosttyPasteboardHelper.saveClipboardImageIfNeeded(from: $0, assumeNoText: true)
|
|
})
|
|
{
|
|
value = imagePath
|
|
}
|
|
|
|
value.withCString { ptr in
|
|
ghostty_surface_complete_clipboard_request(surface, ptr, state, false)
|
|
}
|
|
}
|
|
runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in
|
|
guard let content else { return }
|
|
guard let callbackContext = GhosttyApp.callbackContext(from: userdata),
|
|
let surface = callbackContext.runtimeSurface else { return }
|
|
|
|
ghostty_surface_complete_clipboard_request(surface, content, state, true)
|
|
}
|
|
runtimeConfig.write_clipboard_cb = { _, location, content, len, _ in
|
|
// Write clipboard
|
|
guard let content = content, len > 0 else { return }
|
|
let buffer = UnsafeBufferPointer(start: content, count: Int(len))
|
|
|
|
var fallback: String?
|
|
for item in buffer {
|
|
guard let dataPtr = item.data else { continue }
|
|
let value = String(cString: dataPtr)
|
|
|
|
if let mimePtr = item.mime {
|
|
let mime = String(cString: mimePtr)
|
|
if mime.hasPrefix("text/plain") {
|
|
GhosttyPasteboardHelper.writeString(value, to: location)
|
|
return
|
|
}
|
|
}
|
|
|
|
if fallback == nil {
|
|
fallback = value
|
|
}
|
|
}
|
|
|
|
if let fallback {
|
|
GhosttyPasteboardHelper.writeString(fallback, to: location)
|
|
}
|
|
}
|
|
runtimeConfig.close_surface_cb = { userdata, needsConfirmClose in
|
|
guard let callbackContext = GhosttyApp.callbackContext(from: userdata) else { return }
|
|
let callbackSurfaceId = callbackContext.surfaceId
|
|
let callbackTabId = callbackContext.tabId
|
|
|
|
#if DEBUG
|
|
cmuxWriteChildExitProbe(
|
|
[
|
|
"probeCloseSurfaceNeedsConfirm": needsConfirmClose ? "1" : "0",
|
|
"probeCloseSurfaceTabId": callbackTabId?.uuidString ?? "",
|
|
"probeCloseSurfaceSurfaceId": callbackSurfaceId.uuidString,
|
|
],
|
|
increments: ["probeCloseSurfaceCbCount": 1]
|
|
)
|
|
#endif
|
|
|
|
DispatchQueue.main.async {
|
|
guard let app = AppDelegate.shared else { return }
|
|
// Close requests must be resolved by the callback's workspace/surface IDs only.
|
|
// If the mapping is already gone (duplicate/stale callback), ignore it.
|
|
if let callbackTabId,
|
|
let manager = app.tabManagerFor(tabId: callbackTabId) ?? app.tabManager,
|
|
let workspace = manager.tabs.first(where: { $0.id == callbackTabId }),
|
|
workspace.panels[callbackSurfaceId] != nil {
|
|
if needsConfirmClose {
|
|
manager.closeRuntimeSurfaceWithConfirmation(
|
|
tabId: callbackTabId,
|
|
surfaceId: callbackSurfaceId
|
|
)
|
|
} else {
|
|
manager.closeRuntimeSurface(
|
|
tabId: callbackTabId,
|
|
surfaceId: callbackSurfaceId
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create app
|
|
if let created = ghostty_app_new(&runtimeConfig, primaryConfig) {
|
|
self.app = created
|
|
self.config = primaryConfig
|
|
} else {
|
|
#if DEBUG
|
|
Self.initLog("ghostty_app_new(primary) failed; attempting fallback config")
|
|
Self.dumpConfigDiagnostics(primaryConfig, label: "primary")
|
|
#endif
|
|
|
|
// If the user config is invalid, prefer a minimal fallback configuration so
|
|
// cmux still launches with working terminals.
|
|
ghostty_config_free(primaryConfig)
|
|
|
|
guard let fallbackConfig = ghostty_config_new() else {
|
|
print("Failed to create ghostty fallback config")
|
|
return
|
|
}
|
|
|
|
ghostty_config_finalize(fallbackConfig)
|
|
updateDefaultBackground(from: fallbackConfig, source: "initialize.fallbackConfig")
|
|
|
|
guard let created = ghostty_app_new(&runtimeConfig, fallbackConfig) else {
|
|
#if DEBUG
|
|
Self.initLog("ghostty_app_new(fallback) failed")
|
|
Self.dumpConfigDiagnostics(fallbackConfig, label: "fallback")
|
|
#endif
|
|
print("Failed to create ghostty app")
|
|
ghostty_config_free(fallbackConfig)
|
|
return
|
|
}
|
|
|
|
self.app = created
|
|
self.config = fallbackConfig
|
|
}
|
|
|
|
// Notify observers that a usable config is available (initial load).
|
|
lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference()
|
|
NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)
|
|
|
|
#if os(macOS)
|
|
if let app {
|
|
ghostty_app_set_focus(app, NSApp.isActive)
|
|
}
|
|
|
|
appObservers.append(NotificationCenter.default.addObserver(
|
|
forName: NSApplication.didBecomeActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let app = self?.app else { return }
|
|
ghostty_app_set_focus(app, true)
|
|
})
|
|
|
|
appObservers.append(NotificationCenter.default.addObserver(
|
|
forName: NSApplication.didResignActiveNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let app = self?.app else { return }
|
|
ghostty_app_set_focus(app, false)
|
|
})
|
|
|
|
#endif
|
|
}
|
|
|
|
private func loadDefaultConfigFilesWithLegacyFallback(_ config: ghostty_config_t) {
|
|
ghostty_config_load_default_files(config)
|
|
loadLegacyGhosttyConfigIfNeeded(config)
|
|
ghostty_config_load_recursive_files(config)
|
|
loadCmuxAppSupportGhosttyConfigIfNeeded(config)
|
|
loadCJKFontFallbackIfNeeded(config)
|
|
ghostty_config_finalize(config)
|
|
}
|
|
|
|
/// When the user has not configured `font-codepoint-map` for CJK ranges,
|
|
/// 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.
|
|
///
|
|
/// See: https://github.com/manaflow-ai/cmux/pull/1017
|
|
private func loadCJKFontFallbackIfNeeded(_ config: ghostty_config_t) {
|
|
if Self.userConfigContainsCJKCodepointMap() { return }
|
|
|
|
guard let mappings = Self.cjkFontMappings() else { return }
|
|
|
|
let lines = mappings.map { range, font in
|
|
"font-codepoint-map = \(range)=\(font)"
|
|
}.joined(separator: "\n")
|
|
|
|
let tmpURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("cmux-cjk-font-fallback-\(UUID().uuidString).conf")
|
|
do {
|
|
try lines.write(to: tmpURL, atomically: true, encoding: .utf8)
|
|
defer { try? FileManager.default.removeItem(at: tmpURL) }
|
|
tmpURL.path.withCString { path in
|
|
ghostty_config_load_file(config, path)
|
|
}
|
|
} catch {
|
|
#if DEBUG
|
|
Self.initLog("failed to write CJK font fallback config: \(error)")
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// Unicode ranges shared by all CJK languages (Han ideographs, symbols, fullwidth forms).
|
|
private static let sharedCJKRanges = [
|
|
"U+3000-U+303F", // CJK Symbols and Punctuation
|
|
"U+4E00-U+9FFF", // CJK Unified Ideographs
|
|
"U+F900-U+FAFF", // CJK Compatibility Ideographs
|
|
"U+FF00-U+FFEF", // Halfwidth and Fullwidth Forms
|
|
"U+3400-U+4DBF", // CJK Unified Ideographs Extension A
|
|
]
|
|
|
|
/// Unicode ranges specific to Japanese (kana).
|
|
private static let japaneseRanges = [
|
|
"U+3040-U+309F", // Hiragana
|
|
"U+30A0-U+30FF", // Katakana
|
|
]
|
|
|
|
/// Unicode ranges specific to Korean (Hangul).
|
|
private static let koreanRanges = [
|
|
"U+AC00-U+D7AF", // Hangul Syllables
|
|
"U+1100-U+11FF", // Hangul Jamo
|
|
]
|
|
|
|
/// Returns (range, font) pairs for CJK font fallback based on the system's
|
|
/// preferred languages, or nil if no CJK language is detected. Each language
|
|
/// only maps its own script ranges to avoid assigning glyphs to a font that
|
|
/// lacks coverage (e.g. Hangul to Hiragino Sans).
|
|
static func cjkFontMappings(
|
|
preferredLanguages: [String] = Locale.preferredLanguages
|
|
) -> [(String, String)]? {
|
|
var mappings: [(String, String)] = []
|
|
var coveredShared = false
|
|
|
|
for lang in preferredLanguages {
|
|
let lower = lang.lowercased()
|
|
let font: String
|
|
var langRanges: [String] = []
|
|
|
|
if lower.hasPrefix("ja") {
|
|
font = "Hiragino Sans"
|
|
langRanges = japaneseRanges
|
|
} else if lower.hasPrefix("zh-hant") || lower.hasPrefix("zh-tw") || lower.hasPrefix("zh-hk") {
|
|
font = "PingFang TC"
|
|
} else if lower.hasPrefix("zh") {
|
|
font = "PingFang SC"
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
if !coveredShared {
|
|
for range in sharedCJKRanges {
|
|
mappings.append((range, font))
|
|
}
|
|
coveredShared = true
|
|
}
|
|
|
|
for range in langRanges {
|
|
mappings.append((range, font))
|
|
}
|
|
}
|
|
|
|
return mappings.isEmpty ? nil : mappings
|
|
}
|
|
|
|
/// Checks whether the user's Ghostty config files already contain
|
|
/// a `font-codepoint-map` entry covering CJK ranges. Also checks
|
|
/// application-support config paths that cmux may load at runtime.
|
|
static func userConfigContainsCJKCodepointMap(
|
|
configPaths: [String] = defaultCJKScanPaths()
|
|
) -> Bool {
|
|
var visited = Set<String>()
|
|
for rawPath in configPaths {
|
|
let path = NSString(string: rawPath).expandingTildeInPath
|
|
if Self.configFileContainsCodepointMap(atPath: path, visited: &visited) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Returns the default set of config paths to scan for existing
|
|
/// `font-codepoint-map` entries. Includes both the standard Ghostty
|
|
/// config locations and any app-support paths that cmux may load.
|
|
private static func defaultCJKScanPaths() -> [String] {
|
|
var paths = [
|
|
"~/.config/ghostty/config",
|
|
"~/.config/ghostty/config.ghostty",
|
|
"~/Library/Application Support/com.mitchellh.ghostty/config",
|
|
"~/Library/Application Support/com.mitchellh.ghostty/config.ghostty",
|
|
]
|
|
if let appSupport = FileManager.default.urls(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask
|
|
).first {
|
|
let releaseDir = appSupport.appendingPathComponent(releaseBundleIdentifier)
|
|
paths.append(releaseDir.appendingPathComponent("config").path)
|
|
paths.append(releaseDir.appendingPathComponent("config.ghostty").path)
|
|
|
|
if let bundleId = Bundle.main.bundleIdentifier, bundleId != releaseBundleIdentifier {
|
|
let currentDir = appSupport.appendingPathComponent(bundleId)
|
|
paths.append(currentDir.appendingPathComponent("config").path)
|
|
paths.append(currentDir.appendingPathComponent("config.ghostty").path)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
/// Scans a single config file (and any files it includes) for
|
|
/// `font-codepoint-map` entries. Tracks visited paths to prevent
|
|
/// infinite recursion on cyclic includes.
|
|
private static func configFileContainsCodepointMap(
|
|
atPath path: String,
|
|
visited: inout Set<String>
|
|
) -> Bool {
|
|
let resolved = (path as NSString).standardizingPath
|
|
guard !visited.contains(resolved) else { return false }
|
|
visited.insert(resolved)
|
|
|
|
guard let contents = try? String(contentsOfFile: resolved, encoding: .utf8) else {
|
|
return false
|
|
}
|
|
let parentDir = (resolved as NSString).deletingLastPathComponent
|
|
|
|
for line in contents.components(separatedBy: .newlines) {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
if trimmed.hasPrefix("#") { continue }
|
|
if trimmed.hasPrefix("font-codepoint-map") {
|
|
return true
|
|
}
|
|
if trimmed.hasPrefix("config-file") {
|
|
let parts = trimmed.split(separator: "=", maxSplits: 1)
|
|
if parts.count == 2 {
|
|
var includePath = parts[1]
|
|
.trimmingCharacters(in: .whitespaces)
|
|
// Ghostty supports optional includes with a trailing '?'
|
|
if includePath.hasSuffix("?") {
|
|
includePath.removeLast()
|
|
}
|
|
includePath = includePath
|
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
|
let expanded = NSString(string: includePath).expandingTildeInPath
|
|
let absolute = (expanded as NSString).isAbsolutePath
|
|
? expanded
|
|
: (parentDir as NSString).appendingPathComponent(expanded)
|
|
if configFileContainsCodepointMap(atPath: absolute, visited: &visited) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
static func shouldLoadLegacyGhosttyConfig(
|
|
newConfigFileSize: Int?,
|
|
legacyConfigFileSize: Int?
|
|
) -> Bool {
|
|
guard let newConfigFileSize, newConfigFileSize == 0 else { return false }
|
|
guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false }
|
|
return true
|
|
}
|
|
|
|
static func 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
|
|
}
|
|
|
|
func tick() {
|
|
guard let app = app else { return }
|
|
|
|
let start = CACurrentMediaTime()
|
|
ghostty_app_tick(app)
|
|
let elapsedMs = (CACurrentMediaTime() - start) * 1000
|
|
|
|
// Track lag during scrolling
|
|
if isScrolling {
|
|
scrollLagSampleCount += 1
|
|
scrollLagTotalMs += elapsedMs
|
|
scrollLagMaxMs = max(scrollLagMaxMs, elapsedMs)
|
|
}
|
|
}
|
|
|
|
func reloadConfiguration(soft: Bool = false, source: String = "unspecified") {
|
|
guard let app else {
|
|
logThemeAction("reload skipped source=\(source) soft=\(soft) reason=no_app")
|
|
return
|
|
}
|
|
logThemeAction("reload begin source=\(source) soft=\(soft)")
|
|
resetDefaultBackgroundUpdateScope(source: "reloadConfiguration(source=\(source))")
|
|
if soft, let config {
|
|
ghostty_app_update_config(app, config)
|
|
lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference()
|
|
NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)
|
|
scheduleSurfaceRefreshAfterConfigurationReload(source: source)
|
|
logThemeAction("reload end source=\(source) soft=\(soft) mode=soft")
|
|
return
|
|
}
|
|
|
|
guard let newConfig = ghostty_config_new() else {
|
|
logThemeAction("reload skipped source=\(source) soft=\(soft) reason=config_alloc_failed")
|
|
return
|
|
}
|
|
loadDefaultConfigFilesWithLegacyFallback(newConfig)
|
|
ghostty_app_update_config(app, newConfig)
|
|
updateDefaultBackground(
|
|
from: newConfig,
|
|
source: "reloadConfiguration(source=\(source))",
|
|
scope: .unscoped
|
|
)
|
|
DispatchQueue.main.async {
|
|
self.applyBackgroundToKeyWindow()
|
|
}
|
|
if let oldConfig = config {
|
|
ghostty_config_free(oldConfig)
|
|
}
|
|
config = newConfig
|
|
lastAppearanceColorScheme = GhosttyConfig.currentColorSchemePreference()
|
|
NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)
|
|
scheduleSurfaceRefreshAfterConfigurationReload(source: source)
|
|
logThemeAction("reload end source=\(source) soft=\(soft) mode=full")
|
|
}
|
|
|
|
private func scheduleSurfaceRefreshAfterConfigurationReload(source: String) {
|
|
DispatchQueue.main.async {
|
|
AppDelegate.shared?.refreshTerminalSurfacesAfterGhosttyConfigReload(source: source)
|
|
}
|
|
}
|
|
|
|
func synchronizeThemeWithAppearance(_ appearance: NSAppearance?, source: String) {
|
|
let currentColorScheme = GhosttyConfig.currentColorSchemePreference(
|
|
appAppearance: appearance ?? NSApp?.effectiveAppearance
|
|
)
|
|
let shouldReload = Self.shouldReloadConfigurationForAppearanceChange(
|
|
previousColorScheme: lastAppearanceColorScheme,
|
|
currentColorScheme: currentColorScheme
|
|
)
|
|
if backgroundLogEnabled {
|
|
let previousLabel: String
|
|
switch lastAppearanceColorScheme {
|
|
case .light:
|
|
previousLabel = "light"
|
|
case .dark:
|
|
previousLabel = "dark"
|
|
case nil:
|
|
previousLabel = "nil"
|
|
}
|
|
let currentLabel: String = currentColorScheme == .dark ? "dark" : "light"
|
|
logBackground(
|
|
"appearance sync source=\(source) previous=\(previousLabel) current=\(currentLabel) reload=\(shouldReload)"
|
|
)
|
|
}
|
|
guard shouldReload else { return }
|
|
lastAppearanceColorScheme = currentColorScheme
|
|
reloadConfiguration(source: "appearanceSync:\(source)")
|
|
}
|
|
|
|
func openConfigurationInTextEdit() {
|
|
#if os(macOS)
|
|
let path = ghosttyStringValue(ghostty_config_open_path())
|
|
guard !path.isEmpty else { return }
|
|
let fileURL = URL(fileURLWithPath: path)
|
|
let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app")
|
|
let configuration = NSWorkspace.OpenConfiguration()
|
|
NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration)
|
|
#endif
|
|
}
|
|
|
|
private func ghosttyStringValue(_ value: ghostty_string_s) -> String {
|
|
defer { ghostty_string_free(value) }
|
|
guard let ptr = value.ptr, value.len > 0 else { return "" }
|
|
let rawPtr = UnsafeRawPointer(ptr).assumingMemoryBound(to: UInt8.self)
|
|
let buffer = UnsafeBufferPointer(start: rawPtr, count: Int(value.len))
|
|
return String(decoding: buffer, as: UTF8.self)
|
|
}
|
|
|
|
private func resetDefaultBackgroundUpdateScope(source: String) {
|
|
let previousScope = defaultBackgroundUpdateScope
|
|
let previousScopeSource = defaultBackgroundScopeSource
|
|
defaultBackgroundUpdateScope = .unscoped
|
|
defaultBackgroundScopeSource = "reset:\(source)"
|
|
if backgroundLogEnabled {
|
|
logBackground(
|
|
"default background scope reset source=\(source) previousScope=\(previousScope.logLabel) previousSource=\(previousScopeSource)"
|
|
)
|
|
}
|
|
}
|
|
|
|
private func updateDefaultBackground(
|
|
from config: ghostty_config_t?,
|
|
source: String,
|
|
scope: GhosttyDefaultBackgroundUpdateScope = .unscoped
|
|
) {
|
|
guard let config else { return }
|
|
|
|
var resolvedColor = defaultBackgroundColor
|
|
var color = ghostty_config_color_s()
|
|
let bgKey = "background"
|
|
if ghostty_config_get(config, &color, bgKey, UInt(bgKey.lengthOfBytes(using: .utf8))) {
|
|
resolvedColor = NSColor(
|
|
red: CGFloat(color.r) / 255,
|
|
green: CGFloat(color.g) / 255,
|
|
blue: CGFloat(color.b) / 255,
|
|
alpha: 1.0
|
|
)
|
|
}
|
|
|
|
var opacity = defaultBackgroundOpacity
|
|
let opacityKey = "background-opacity"
|
|
_ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8)))
|
|
opacity = min(1.0, max(0.0, opacity))
|
|
applyDefaultBackground(
|
|
color: resolvedColor,
|
|
opacity: opacity,
|
|
source: source,
|
|
scope: scope
|
|
)
|
|
}
|
|
|
|
func focusFollowsMouseEnabled() -> Bool {
|
|
guard let config else { return false }
|
|
var enabled = false
|
|
let key = "focus-follows-mouse"
|
|
let keyLength = UInt(key.lengthOfBytes(using: .utf8))
|
|
let found = ghostty_config_get(config, &enabled, key, keyLength)
|
|
return found && enabled
|
|
}
|
|
|
|
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.scrollbar = scrollbar
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidUpdateScrollbar,
|
|
object: surfaceView,
|
|
userInfo: [GhosttyNotificationKey.scrollbar: scrollbar]
|
|
)
|
|
return true
|
|
case GHOSTTY_ACTION_CELL_SIZE:
|
|
let cellSize = CGSize(
|
|
width: CGFloat(action.action.cell_size.width),
|
|
height: CGFloat(action.action.cell_size.height)
|
|
)
|
|
surfaceView.cellSize = cellSize
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidUpdateCellSize,
|
|
object: surfaceView,
|
|
userInfo: [GhosttyNotificationKey.cellSize: cellSize]
|
|
)
|
|
return true
|
|
case GHOSTTY_ACTION_START_SEARCH:
|
|
guard let terminalSurface = surfaceView.terminalSurface else { return true }
|
|
let needle = action.action.start_search.needle.flatMap { String(cString: $0) }
|
|
DispatchQueue.main.async {
|
|
if let searchState = terminalSurface.searchState {
|
|
if let needle, !needle.isEmpty {
|
|
searchState.needle = needle
|
|
}
|
|
} else {
|
|
terminalSurface.searchState = TerminalSurface.SearchState(needle: needle ?? "")
|
|
}
|
|
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_END_SEARCH:
|
|
guard let terminalSurface = surfaceView.terminalSurface else { return true }
|
|
DispatchQueue.main.async {
|
|
terminalSurface.searchState = nil
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_SEARCH_TOTAL:
|
|
guard let terminalSurface = surfaceView.terminalSurface else { return true }
|
|
let rawTotal = action.action.search_total.total
|
|
let total: UInt? = rawTotal >= 0 ? UInt(rawTotal) : nil
|
|
DispatchQueue.main.async {
|
|
terminalSurface.searchState?.total = total
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_SEARCH_SELECTED:
|
|
guard let terminalSurface = surfaceView.terminalSurface else { return true }
|
|
let rawSelected = action.action.search_selected.selected
|
|
let selected: UInt? = rawSelected >= 0 ? UInt(rawSelected) : nil
|
|
DispatchQueue.main.async {
|
|
terminalSurface.searchState?.selected = selected
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_SET_TITLE:
|
|
let title = action.action.set_title.title
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
if let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id {
|
|
DispatchQueue.main.async {
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidSetTitle,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
GhosttyNotificationKey.tabId: tabId,
|
|
GhosttyNotificationKey.surfaceId: surfaceId,
|
|
GhosttyNotificationKey.title: title,
|
|
]
|
|
)
|
|
}
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_PWD:
|
|
guard let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id else { return true }
|
|
let pwd = action.action.pwd.pwd.flatMap { String(cString: $0) } ?? ""
|
|
DispatchQueue.main.async {
|
|
AppDelegate.shared?.tabManager?.updateSurfaceDirectory(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
directory: pwd
|
|
)
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
|
|
guard let tabId = surfaceView.tabId else { return true }
|
|
let surfaceId = surfaceView.terminalSurface?.id
|
|
let actionTitle = action.action.desktop_notification.title
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
let actionBody = action.action.desktop_notification.body
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
performOnMain {
|
|
// 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
|
|
surfaceView.backgroundColor = NSColor(
|
|
red: CGFloat(change.r) / 255,
|
|
green: CGFloat(change.g) / 255,
|
|
blue: CGFloat(change.b) / 255,
|
|
alpha: 1.0
|
|
)
|
|
if backgroundLogEnabled {
|
|
logBackground(
|
|
"surface override set tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString()) source=action.color_change.surface"
|
|
)
|
|
}
|
|
surfaceView.applySurfaceBackground()
|
|
if backgroundLogEnabled {
|
|
logBackground("OSC background change tab=\(surfaceView.tabId?.uuidString ?? "unknown") color=\(surfaceView.backgroundColor?.description ?? "nil")")
|
|
}
|
|
DispatchQueue.main.async {
|
|
surfaceView.applyWindowBackgroundIfActive()
|
|
}
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_CONFIG_CHANGE:
|
|
if let staleOverride = surfaceView.backgroundColor {
|
|
surfaceView.backgroundColor = nil
|
|
if backgroundLogEnabled {
|
|
logBackground(
|
|
"surface override cleared tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") cleared=\(staleOverride.hexString()) source=action.config_change.surface"
|
|
)
|
|
}
|
|
surfaceView.applySurfaceBackground()
|
|
DispatchQueue.main.async {
|
|
surfaceView.applyWindowBackgroundIfActive()
|
|
}
|
|
}
|
|
updateDefaultBackground(
|
|
from: action.action.config_change.config,
|
|
source: "action.config_change.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")",
|
|
scope: .surface
|
|
)
|
|
if backgroundLogEnabled {
|
|
logBackground(
|
|
"surface config change deferred terminal bg apply tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") override=\(surfaceView.backgroundColor?.hexString() ?? "nil") default=\(defaultBackgroundColor.hexString())"
|
|
)
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_RELOAD_CONFIG:
|
|
let soft = action.action.reload_config.soft
|
|
logThemeAction(
|
|
"reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)"
|
|
)
|
|
return performOnMain {
|
|
// Keep all runtime theme/default-background state in the same path.
|
|
GhosttyApp.shared.reloadConfiguration(
|
|
soft: soft,
|
|
source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")"
|
|
)
|
|
return true
|
|
}
|
|
case GHOSTTY_ACTION_KEY_SEQUENCE:
|
|
return performOnMain {
|
|
surfaceView.updateKeySequence(action.action.key_sequence)
|
|
return true
|
|
}
|
|
case GHOSTTY_ACTION_KEY_TABLE:
|
|
return performOnMain {
|
|
surfaceView.updateKeyTable(action.action.key_table)
|
|
return true
|
|
}
|
|
case GHOSTTY_ACTION_OPEN_URL:
|
|
let openUrl = action.action.open_url
|
|
guard let cstr = openUrl.url else { return false }
|
|
let urlString = String(
|
|
data: Data(bytes: cstr, count: Int(openUrl.len)),
|
|
encoding: .utf8
|
|
) ?? ""
|
|
#if DEBUG
|
|
dlog("link.openURL raw=\(urlString)")
|
|
#endif
|
|
guard let target = resolveTerminalOpenURLTarget(urlString) else {
|
|
#if DEBUG
|
|
dlog("link.openURL resolve failed, returning false")
|
|
#endif
|
|
return false
|
|
}
|
|
if !BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowser() {
|
|
#if DEBUG
|
|
dlog("link.openURL cmuxBrowser=disabled, opening externally url=\(target.url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(target.url)
|
|
}
|
|
}
|
|
switch target {
|
|
case let .external(url):
|
|
#if DEBUG
|
|
dlog("link.openURL target=external, opening externally url=\(url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
case let .embeddedBrowser(url):
|
|
if BrowserLinkOpenSettings.shouldOpenExternally(url) {
|
|
#if DEBUG
|
|
dlog("link.openURL target=embedded but shouldOpenExternally=true url=\(url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
|
|
#if DEBUG
|
|
dlog("link.openURL target=embedded but normalizeHost=nil host=\(url.host ?? "nil") url=\(url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
// If a host whitelist is configured and this host isn't in it, open externally.
|
|
if !BrowserLinkOpenSettings.hostMatchesWhitelist(host) {
|
|
#if DEBUG
|
|
dlog("link.openURL target=embedded but hostWhitelist miss host=\(host) url=\(url)")
|
|
#endif
|
|
return performOnMain {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
let sourceWorkspaceId = callbackTabId ?? surfaceView.tabId
|
|
let sourcePanelId = callbackSurfaceId ?? surfaceView.terminalSurface?.id
|
|
guard let sourceWorkspaceId,
|
|
let sourcePanelId else {
|
|
#if DEBUG
|
|
dlog("link.openURL target=embedded but tabId/surfaceId=nil")
|
|
#endif
|
|
return false
|
|
}
|
|
#if DEBUG
|
|
dlog(
|
|
"link.openURL target=embedded, opening in browser pane " +
|
|
"host=\(host) url=\(url) tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)"
|
|
)
|
|
#endif
|
|
return performOnMain {
|
|
guard let app = AppDelegate.shared,
|
|
let resolved = app.workspaceContainingPanel(
|
|
panelId: sourcePanelId,
|
|
preferredWorkspaceId: sourceWorkspaceId
|
|
) else {
|
|
#if DEBUG
|
|
dlog(
|
|
"link.openURL embedded but workspace lookup failed " +
|
|
"tabId=\(sourceWorkspaceId) surfaceId=\(sourcePanelId)"
|
|
)
|
|
#endif
|
|
return false
|
|
}
|
|
let workspace = resolved.workspace
|
|
#if DEBUG
|
|
if workspace.id != sourceWorkspaceId {
|
|
dlog(
|
|
"link.openURL workspace.remap sourceTab=\(sourceWorkspaceId) " +
|
|
"resolvedTab=\(workspace.id) surfaceId=\(sourcePanelId)"
|
|
)
|
|
}
|
|
#endif
|
|
if let targetPane = workspace.preferredBrowserTargetPane(fromPanelId: sourcePanelId) {
|
|
#if DEBUG
|
|
dlog("link.openURL opening in existing browser pane=\(targetPane)")
|
|
#endif
|
|
return workspace.newBrowserSurface(inPane: targetPane, url: url, focus: true) != nil
|
|
} else {
|
|
#if DEBUG
|
|
dlog("link.openURL opening as new browser split from surface=\(sourcePanelId)")
|
|
#endif
|
|
return workspace.newBrowserSplit(from: sourcePanelId, orientation: .horizontal, url: url) != nil
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func applyBackgroundToKeyWindow() {
|
|
guard let window = activeMainWindow() else { return }
|
|
if cmuxShouldUseClearWindowBackground(for: defaultBackgroundOpacity) {
|
|
window.backgroundColor = cmuxTransparentWindowBaseColor()
|
|
window.isOpaque = false
|
|
if backgroundLogEnabled {
|
|
logBackground("applied transparent window background opacity=\(String(format: "%.3f", defaultBackgroundOpacity))")
|
|
}
|
|
} else {
|
|
let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity)
|
|
window.backgroundColor = color
|
|
window.isOpaque = color.alphaComponent >= 1.0
|
|
if backgroundLogEnabled {
|
|
logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func activeMainWindow() -> NSWindow? {
|
|
let keyWindow = NSApp.keyWindow
|
|
if let raw = keyWindow?.identifier?.rawValue,
|
|
raw == "cmux.main" || raw.hasPrefix("cmux.main.") {
|
|
return keyWindow
|
|
}
|
|
return NSApp.windows.first(where: { window in
|
|
guard let raw = window.identifier?.rawValue else { return false }
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
})
|
|
}
|
|
|
|
func logBackground(_ message: String) {
|
|
let timestamp = Self.backgroundLogTimestampFormatter.string(from: Date())
|
|
let uptimeMs = (ProcessInfo.processInfo.systemUptime - backgroundLogStartUptime) * 1000
|
|
let frame60 = Int((CACurrentMediaTime() * 60.0).rounded(.down))
|
|
let frame120 = Int((CACurrentMediaTime() * 120.0).rounded(.down))
|
|
let threadLabel = Thread.isMainThread ? "main" : "background"
|
|
backgroundLogLock.lock()
|
|
defer { backgroundLogLock.unlock() }
|
|
backgroundLogSequence &+= 1
|
|
let sequence = backgroundLogSequence
|
|
let line =
|
|
"\(timestamp) seq=\(sequence) t+\(String(format: "%.3f", uptimeMs))ms thread=\(threadLabel) frame60=\(frame60) frame120=\(frame120) cmux bg: \(message)\n"
|
|
if let data = line.data(using: .utf8) {
|
|
if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false {
|
|
FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil)
|
|
}
|
|
if let handle = try? FileHandle(forWritingTo: backgroundLogURL) {
|
|
defer { try? handle.close() }
|
|
try? handle.seekToEnd()
|
|
try? handle.write(contentsOf: data)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Debug Render Instrumentation
|
|
|
|
/// Lightweight instrumentation to detect whether Ghostty is actually requesting Metal drawables.
|
|
/// This helps catch "frozen until refocus" regressions without relying on screenshots (which can
|
|
/// mask redraw issues by forcing a window server flush).
|
|
final class GhosttyMetalLayer: CAMetalLayer {
|
|
private let lock = NSLock()
|
|
private var drawableCount: Int = 0
|
|
private var lastDrawableTime: CFTimeInterval = 0
|
|
|
|
func debugStats() -> (count: Int, last: CFTimeInterval) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return (drawableCount, lastDrawableTime)
|
|
}
|
|
|
|
override func nextDrawable() -> CAMetalDrawable? {
|
|
lock.lock()
|
|
drawableCount += 1
|
|
lastDrawableTime = CACurrentMediaTime()
|
|
lock.unlock()
|
|
return super.nextDrawable()
|
|
}
|
|
}
|
|
|
|
final class TerminalSurfaceRegistry {
|
|
static let shared = TerminalSurfaceRegistry()
|
|
|
|
private let lock = NSLock()
|
|
private let surfaces = NSHashTable<AnyObject>.weakObjects()
|
|
|
|
private init() {}
|
|
|
|
func register(_ surface: TerminalSurface) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
surfaces.add(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 terminal surface view is currently attached to a window.
|
|
///
|
|
/// Use the hosted view rather than the inner surface view, since the surface can be
|
|
/// temporarily unattached (surface not yet created / reparenting) even while the panel
|
|
/// is already in the window.
|
|
var isViewInWindow: Bool { hostedView.window != nil }
|
|
let id: UUID
|
|
private(set) var tabId: UUID
|
|
/// Port ordinal for CMUX_PORT range assignment
|
|
var portOrdinal: Int = 0
|
|
/// Snapshotted once per app session so all workspaces use consistent values
|
|
private static let sessionPortBase: Int = {
|
|
let val = UserDefaults.standard.integer(forKey: "cmuxPortBase")
|
|
return val > 0 ? val : 9100
|
|
}()
|
|
private static let sessionPortRangeSize: Int = {
|
|
let val = UserDefaults.standard.integer(forKey: "cmuxPortRange")
|
|
return val > 0 ? val : 10
|
|
}()
|
|
private let surfaceContext: ghostty_surface_context_e
|
|
private let configTemplate: ghostty_surface_config_s?
|
|
private let workingDirectory: String?
|
|
private let 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>?
|
|
/// Tracks the last focus state to avoid sending redundant focus events.
|
|
/// This prevents prompt redraw issues with zsh themes like Powerlevel10k.
|
|
private var lastFocusState: Bool = false
|
|
#if DEBUG
|
|
private var needsConfirmCloseOverrideForTesting: Bool?
|
|
#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: ghostty_surface_config_s?,
|
|
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
|
|
}
|
|
|
|
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
|
|
surface = nil
|
|
|
|
guard let surfaceToFree else {
|
|
callbackContext?.release()
|
|
return
|
|
}
|
|
|
|
Task { @MainActor in
|
|
// Keep free behavior aligned with deinit: perform the runtime teardown on
|
|
// the next main-actor turn so SIGHUP delivery is deterministic but non-reentrant.
|
|
ghostty_surface_free(surfaceToFree)
|
|
callbackContext?.release()
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
private static let surfaceLogPath = "/tmp/cmux-ghostty-surface.log"
|
|
private static let sizeLogPath = "/tmp/cmux-ghostty-size.log"
|
|
|
|
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(line.data(using: .utf8)!)
|
|
handle.closeFile()
|
|
} else {
|
|
FileManager.default.createFile(atPath: surfaceLogPath, contents: line.data(using: .utf8))
|
|
}
|
|
}
|
|
|
|
private static func sizeLog(_ message: String) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1" else { return }
|
|
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
let line = "[\(timestamp)] \(message)\n"
|
|
if let handle = FileHandle(forWritingAtPath: sizeLogPath) {
|
|
handle.seekToEndOfFile()
|
|
handle.write(line.data(using: .utf8)!)
|
|
handle.closeFile()
|
|
} else {
|
|
FileManager.default.createFile(atPath: sizeLogPath, contents: line.data(using: .utf8))
|
|
}
|
|
}
|
|
#endif
|
|
|
|
/// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived
|
|
/// from backing-space points and truncated (never rounded up).
|
|
private func pixelDimension(from value: CGFloat) -> UInt32 {
|
|
guard value.isFinite else { return 0 }
|
|
let floored = floor(max(0, value))
|
|
if floored >= CGFloat(UInt32.max) {
|
|
return UInt32.max
|
|
}
|
|
return UInt32(floored)
|
|
}
|
|
|
|
private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) {
|
|
let scale = max(
|
|
1.0,
|
|
view.window?.backingScaleFactor
|
|
?? view.layer?.contentsScale
|
|
?? NSScreen.main?.backingScaleFactor
|
|
?? 1.0
|
|
)
|
|
return (scale, scale, scale)
|
|
}
|
|
|
|
private func scaleApproximatelyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool {
|
|
abs(lhs - rhs) <= epsilon
|
|
}
|
|
|
|
func attachToView(_ view: GhosttyNSView) {
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.attach surface=\(id.uuidString.prefix(5)) view=\(Unmanaged.passUnretained(view).toOpaque()) " +
|
|
"attached=\(attachedView != nil ? 1 : 0) hasSurface=\(surface != nil ? 1 : 0) inWindow=\(view.window != nil ? 1 : 0)"
|
|
)
|
|
#endif
|
|
|
|
// If already attached to this view, nothing to do.
|
|
// Still re-assert the display id: during split close tree restructuring, the view can be
|
|
// removed/re-added (or briefly have window/screen nil) without recreating the surface.
|
|
// Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing
|
|
// or stale, the surface can appear visually frozen until a focus/visibility change.
|
|
// 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)
|
|
|
|
var surfaceConfig = configTemplate ?? ghostty_surface_config_new()
|
|
surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS
|
|
surfaceConfig.platform = ghostty_platform_u(macos: ghostty_platform_macos_s(
|
|
nsview: Unmanaged.passUnretained(view).toOpaque()
|
|
))
|
|
let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext(surfaceView: view, terminalSurface: self))
|
|
surfaceConfig.userdata = callbackContext.toOpaque()
|
|
surfaceCallbackContext?.release()
|
|
surfaceCallbackContext = callbackContext
|
|
surfaceConfig.scale_factor = scaleFactors.layer
|
|
surfaceConfig.context = surfaceContext
|
|
#if DEBUG
|
|
let templateFontText = String(format: "%.2f", surfaceConfig.font_size)
|
|
dlog(
|
|
"zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " +
|
|
"templateFont=\(templateFontText)"
|
|
)
|
|
#endif
|
|
var envVars: [ghostty_env_var_s] = []
|
|
var envStorage: [(UnsafeMutablePointer<CChar>, UnsafeMutablePointer<CChar>)] = []
|
|
defer {
|
|
for (key, value) in envStorage {
|
|
free(key)
|
|
free(value)
|
|
}
|
|
}
|
|
|
|
var env: [String: String] = [:]
|
|
if surfaceConfig.env_var_count > 0, let existingEnv = surfaceConfig.env_vars {
|
|
let count = Int(surfaceConfig.env_var_count)
|
|
if count > 0 {
|
|
for i in 0..<count {
|
|
let item = existingEnv[i]
|
|
if let key = String(cString: item.key, encoding: .utf8),
|
|
let value = String(cString: item.value, encoding: .utf8) {
|
|
env[key] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
setManagedEnvironmentValue("CMUX_SOCKET_PATH", SocketControlSettings.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 createWithCommandAndWorkingDirectory = { [self] in
|
|
if let initialCommand, !initialCommand.isEmpty {
|
|
initialCommand.withCString { cCommand in
|
|
surfaceConfig.command = cCommand
|
|
if let workingDirectory, !workingDirectory.isEmpty {
|
|
workingDirectory.withCString { cWorkingDir in
|
|
surfaceConfig.working_directory = cWorkingDir
|
|
createSurface()
|
|
}
|
|
} else {
|
|
createSurface()
|
|
}
|
|
}
|
|
} else if let workingDirectory, !workingDirectory.isEmpty {
|
|
workingDirectory.withCString { cWorkingDir in
|
|
surfaceConfig.working_directory = cWorkingDir
|
|
createSurface()
|
|
}
|
|
} else {
|
|
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 }
|
|
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?.font_size,
|
|
inheritedFontPoints > 0 {
|
|
let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface)
|
|
let shouldReapply = {
|
|
guard let currentFontPoints else { return true }
|
|
return abs(currentFontPoints - inheritedFontPoints) > 0.05
|
|
}()
|
|
if shouldReapply {
|
|
let action = String(format: "set_font_size:%.3f", inheritedFontPoints)
|
|
_ = performBindingAction(action)
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
func setFocus(_ focused: Bool) {
|
|
guard let surface = surface else { return }
|
|
// Only send focus events when the state changes to avoid redundant
|
|
// prompt redraws with zsh themes like Powerlevel10k.
|
|
guard focused != lastFocusState else { return }
|
|
lastFocusState = focused
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
surface = nil
|
|
ghostty_surface_free(surfaceToFree)
|
|
callbackContext?.release()
|
|
}
|
|
#endif
|
|
|
|
deinit {
|
|
markPortalLifecycleClosed(reason: "deinit")
|
|
|
|
let callbackContext = surfaceCallbackContext
|
|
surfaceCallbackContext = nil
|
|
|
|
// Nil out the surface pointer so any in-flight closures (e.g. geometry
|
|
// reconcile dispatched via DispatchQueue.main.async) that read self.surface
|
|
// before this object is fully deallocated will see nil and bail out,
|
|
// rather than passing a freed pointer to ghostty_surface_refresh (#432).
|
|
let surfaceToFree = surface
|
|
surface = nil
|
|
|
|
guard let surfaceToFree else {
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.lifecycle.deinit.skip surface=\(id.uuidString.prefix(5)) " +
|
|
"workspace=\(tabId.uuidString.prefix(5)) reason=noRuntimeSurface"
|
|
)
|
|
#endif
|
|
callbackContext?.release()
|
|
return
|
|
}
|
|
|
|
#if DEBUG
|
|
let surfaceToken = String(id.uuidString.prefix(5))
|
|
let workspaceToken = String(tabId.uuidString.prefix(5))
|
|
dlog(
|
|
"surface.lifecycle.deinit.begin surface=\(surfaceToken) " +
|
|
"workspace=\(workspaceToken) hasAttachedView=\(attachedView != nil ? 1 : 0) " +
|
|
"hostedInWindow=\(hostedView.window != nil ? 1 : 0)"
|
|
)
|
|
#endif
|
|
|
|
// Keep teardown asynchronous to avoid re-entrant close/deinit loops, but retain
|
|
// callback userdata until surface free completes so callbacks never dereference
|
|
// a deallocated view pointer.
|
|
Task { @MainActor in
|
|
ghostty_surface_free(surfaceToFree)
|
|
callbackContext?.release()
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.lifecycle.deinit.end surface=\(surfaceToken) " +
|
|
"workspace=\(workspaceToken) freed=1"
|
|
)
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Ghostty Surface View
|
|
|
|
class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|
private static let focusDebugEnabled: Bool = {
|
|
if ProcessInfo.processInfo.environment["CMUX_FOCUS_DEBUG"] == "1" {
|
|
return true
|
|
}
|
|
return UserDefaults.standard.bool(forKey: "cmuxFocusDebug")
|
|
}()
|
|
private static let dropTypes: Set<NSPasteboard.PasteboardType> = [
|
|
.string,
|
|
.fileURL,
|
|
.URL
|
|
]
|
|
private static let tabTransferPasteboardType = NSPasteboard.PasteboardType("com.splittabbar.tabtransfer")
|
|
private static let sidebarTabReorderPasteboardType = NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder")
|
|
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
|
|
|
|
fileprivate static func focusLog(_ message: String) {
|
|
guard focusDebugEnabled else { return }
|
|
FocusLogStore.shared.append(message)
|
|
NSLog("[FOCUSDBG] %@", message)
|
|
}
|
|
|
|
weak var terminalSurface: TerminalSurface?
|
|
var scrollbar: GhosttyScrollbar?
|
|
var cellSize: CGSize = .zero
|
|
var desiredFocus: Bool = false
|
|
var suppressingReparentFocus: Bool = false
|
|
var tabId: UUID?
|
|
var onFocus: (() -> Void)?
|
|
var onTriggerFlash: (() -> Void)?
|
|
var backgroundColor: NSColor?
|
|
private var appliedColorScheme: ghostty_color_scheme_e?
|
|
private var lastLoggedSurfaceBackgroundSignature: String?
|
|
private var lastLoggedWindowBackgroundSignature: String?
|
|
private var keySequence: [ghostty_input_trigger_s] = []
|
|
private var keyTables: [String] = []
|
|
fileprivate private(set) var keyboardCopyModeActive = false
|
|
private var keyboardCopyModeConsumedKeyUps: Set<UInt16> = []
|
|
private var keyboardCopyModeInputState = TerminalKeyboardCopyModeInputState()
|
|
private var keyboardCopyModeViewportRow: Int?
|
|
/// Tracks whether the user has explicitly entered visual selection mode (v).
|
|
/// Separate from Ghostty's `has_selection` because copy mode always maintains
|
|
/// a 1-cell selection as a visible cursor. This flag determines whether
|
|
/// movements should extend the selection (visual) or scroll the viewport.
|
|
private var keyboardCopyModeVisualActive = false
|
|
fileprivate var isKeyboardCopyModeActive: Bool { keyboardCopyModeActive }
|
|
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, not for libghostty occlusion.
|
|
fileprivate var isVisibleInUI: Bool { visibleInUI }
|
|
fileprivate func setVisibleInUI(_ visible: Bool) {
|
|
visibleInUI = visible
|
|
}
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
setup()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
setup()
|
|
}
|
|
|
|
private func setup() {
|
|
// Only enable our instrumented CAMetalLayer in targeted debug/test scenarios.
|
|
// The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs.
|
|
wantsLayer = true
|
|
layer?.masksToBounds = true
|
|
installEventMonitor()
|
|
updateTrackingAreas()
|
|
registerForDraggedTypes(Array(Self.dropTypes))
|
|
}
|
|
|
|
private func effectiveBackgroundColor() -> NSColor {
|
|
let base = backgroundColor ?? GhosttyApp.shared.defaultBackgroundColor
|
|
let opacity = GhosttyApp.shared.defaultBackgroundOpacity
|
|
return base.withAlphaComponent(opacity)
|
|
}
|
|
|
|
func applySurfaceBackground() {
|
|
let color = effectiveBackgroundColor()
|
|
if let layer {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
// GhosttySurfaceScrollView owns the panel background fill. Keeping this layer clear
|
|
// avoids stacking multiple identical translucent backgrounds (which looks opaque).
|
|
layer.backgroundColor = NSColor.clear.cgColor
|
|
layer.isOpaque = false
|
|
CATransaction.commit()
|
|
}
|
|
terminalSurface?.hostedView.setBackgroundColor(color)
|
|
if GhosttyApp.shared.backgroundLogEnabled {
|
|
let signature = "\(color.hexString()):\(String(format: "%.3f", color.alphaComponent))"
|
|
if signature != lastLoggedSurfaceBackgroundSignature {
|
|
lastLoggedSurfaceBackgroundSignature = signature
|
|
let hasOverride = backgroundColor != nil
|
|
let overrideHex = backgroundColor?.hexString() ?? "nil"
|
|
let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString()
|
|
let source = hasOverride ? "surfaceOverride" : "defaultBackground"
|
|
GhosttyApp.shared.logBackground(
|
|
"surface background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Theme/background application is window-local. During cross-window workspace
|
|
// switches (e.g. jump-to-unread), the global active tab manager can lag behind.
|
|
// Prefer the owning window's selected workspace when available.
|
|
static func shouldApplyWindowBackground(
|
|
surfaceTabId: UUID?,
|
|
owningManagerExists: Bool,
|
|
owningSelectedTabId: UUID?,
|
|
activeSelectedTabId: UUID?
|
|
) -> Bool {
|
|
guard let surfaceTabId else { return true }
|
|
if owningManagerExists {
|
|
guard let owningSelectedTabId else { return true }
|
|
return owningSelectedTabId == surfaceTabId
|
|
}
|
|
if let activeSelectedTabId {
|
|
return activeSelectedTabId == surfaceTabId
|
|
}
|
|
return true
|
|
}
|
|
|
|
func applyWindowBackgroundIfActive() {
|
|
guard let window else { return }
|
|
let appDelegate = AppDelegate.shared
|
|
let owningManager = tabId.flatMap { appDelegate?.tabManagerFor(tabId: $0) }
|
|
let owningSelectedTabId = owningManager?.selectedTabId
|
|
let activeSelectedTabId = owningManager == nil ? appDelegate?.tabManager?.selectedTabId : nil
|
|
guard Self.shouldApplyWindowBackground(
|
|
surfaceTabId: tabId,
|
|
owningManagerExists: owningManager != nil,
|
|
owningSelectedTabId: owningSelectedTabId,
|
|
activeSelectedTabId: activeSelectedTabId
|
|
) else {
|
|
return
|
|
}
|
|
applySurfaceBackground()
|
|
let color = effectiveBackgroundColor()
|
|
if cmuxShouldUseClearWindowBackground(for: color.alphaComponent) {
|
|
window.backgroundColor = cmuxTransparentWindowBaseColor()
|
|
window.isOpaque = false
|
|
} else {
|
|
window.backgroundColor = color
|
|
window.isOpaque = color.alphaComponent >= 1.0
|
|
}
|
|
if GhosttyApp.shared.backgroundLogEnabled {
|
|
let signature = "\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent) ? "transparent" : color.hexString()):\(String(format: "%.3f", color.alphaComponent))"
|
|
if signature != lastLoggedWindowBackgroundSignature {
|
|
lastLoggedWindowBackgroundSignature = signature
|
|
let hasOverride = backgroundColor != nil
|
|
let overrideHex = backgroundColor?.hexString() ?? "nil"
|
|
let defaultHex = GhosttyApp.shared.defaultBackgroundColor.hexString()
|
|
let source = hasOverride ? "surfaceOverride" : "defaultBackground"
|
|
GhosttyApp.shared.logBackground(
|
|
"window background applied tab=\(tabId?.uuidString ?? "unknown") surface=\(terminalSurface?.id.uuidString ?? "unknown") source=\(source) override=\(overrideHex) default=\(defaultHex) transparent=\(cmuxShouldUseClearWindowBackground(for: color.alphaComponent)) color=\(color.hexString()) opacity=\(String(format: "%.3f", color.alphaComponent))"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func installEventMonitor() {
|
|
guard eventMonitor == nil else { return }
|
|
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.scrollWheel]) { [weak self] event in
|
|
return self?.localEventHandler(event) ?? event
|
|
}
|
|
}
|
|
|
|
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
|
|
switch event.type {
|
|
case .scrollWheel:
|
|
return localEventScrollWheel(event)
|
|
default:
|
|
return event
|
|
}
|
|
}
|
|
|
|
private func localEventScrollWheel(_ event: NSEvent) -> NSEvent? {
|
|
guard let window,
|
|
let eventWindow = event.window,
|
|
window == eventWindow else { return event }
|
|
|
|
let location = convert(event.locationInWindow, from: nil)
|
|
guard hitTest(location) == self else { return event }
|
|
|
|
Self.focusLog("localEventScrollWheel: window=\(ObjectIdentifier(window)) firstResponder=\(String(describing: window.firstResponder))")
|
|
return event
|
|
}
|
|
|
|
func attachSurface(_ surface: TerminalSurface) {
|
|
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)
|
|
}
|
|
|
|
/// 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,
|
|
]
|
|
)
|
|
}
|
|
ghostty_surface_set_focus(surface, true)
|
|
|
|
// Ghostty only restarts its vsync display link on display-id changes while focused.
|
|
// During rapid split close / SwiftUI reparenting, the view can reattach to a window
|
|
// and get its display id set *before* it becomes first responder; in that case, the
|
|
// renderer can remain stuck until some later screen/focus transition. Reassert the
|
|
// display id now that we're focused to ensure the renderer is running.
|
|
if let displayID = window?.screen?.displayID, displayID != 0 {
|
|
ghostty_surface_set_display_id(surface, displayID)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
override func resignFirstResponder() -> Bool {
|
|
let result = super.resignFirstResponder()
|
|
if result {
|
|
desiredFocus = false
|
|
}
|
|
if result, let surface = surface {
|
|
let now = CACurrentMediaTime()
|
|
let deltaMs = (now - lastScrollEventTime) * 1000
|
|
Self.focusLog("resignFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
|
|
ghostty_surface_set_focus(surface, false)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// For NSTextInputClient - accumulates text during key events
|
|
private var keyTextAccumulator: [String]? = nil
|
|
private var markedText = NSMutableAttributedString()
|
|
private var lastPerformKeyEvent: TimeInterval?
|
|
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: 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
|
|
}()
|
|
|
|
if let bindingFlags {
|
|
let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0
|
|
let isAll = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0
|
|
let isPerformable = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0
|
|
|
|
// If the binding is consumed and not meant for the menu, allow menu first.
|
|
if isConsumed && !isAll && !isPerformable && keySequence.isEmpty && keyTables.isEmpty {
|
|
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
keyDown(with: event)
|
|
return true
|
|
}
|
|
|
|
let equivalent: String
|
|
switch event.charactersIgnoringModifiers {
|
|
case "\r":
|
|
// Pass Ctrl+Return through verbatim (prevent context menu equivalent).
|
|
guard event.modifierFlags.contains(.control) else { return false }
|
|
equivalent = "\r"
|
|
|
|
case "/":
|
|
// Treat Ctrl+/ as Ctrl+_ to avoid the system beep.
|
|
guard event.modifierFlags.contains(.control),
|
|
event.modifierFlags.isDisjoint(with: [.shift, .command, .option]) else {
|
|
return false
|
|
}
|
|
equivalent = "_"
|
|
|
|
default:
|
|
// Ignore synthetic events.
|
|
if event.timestamp == 0 {
|
|
return false
|
|
}
|
|
|
|
// Match AppKit key-equivalent routing for menu-style shortcuts (Command-modified).
|
|
// Control-only terminal input (e.g. Ctrl+D) should not participate in redispatch;
|
|
// it must flow through the normal keyDown path exactly once.
|
|
if !event.modifierFlags.contains(.command) {
|
|
lastPerformKeyEvent = nil
|
|
return false
|
|
}
|
|
|
|
if let lastPerformKeyEvent {
|
|
self.lastPerformKeyEvent = nil
|
|
if lastPerformKeyEvent == event.timestamp {
|
|
equivalent = event.characters ?? ""
|
|
break
|
|
}
|
|
}
|
|
|
|
lastPerformKeyEvent = event.timestamp
|
|
return false
|
|
}
|
|
|
|
let finalEvent = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: event.locationInWindow,
|
|
modifierFlags: event.modifierFlags,
|
|
timestamp: event.timestamp,
|
|
windowNumber: event.windowNumber,
|
|
context: nil,
|
|
characters: equivalent,
|
|
charactersIgnoringModifiers: equivalent,
|
|
isARepeat: event.isARepeat,
|
|
keyCode: event.keyCode
|
|
)
|
|
|
|
if let finalEvent {
|
|
keyDown(with: finalEvent)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
#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 DEBUG
|
|
recordKeyLatency(path: "keyDown", event: event)
|
|
#endif
|
|
|
|
#if DEBUG
|
|
cmuxWriteChildExitProbe(
|
|
[
|
|
"probeKeyDownCharsHex": cmuxScalarHex(event.characters),
|
|
"probeKeyDownCharsIgnoringHex": cmuxScalarHex(event.charactersIgnoringModifiers),
|
|
"probeKeyDownKeyCode": String(event.keyCode),
|
|
"probeKeyDownModsRaw": String(event.modifierFlags.rawValue),
|
|
"probeKeyDownSurfaceId": terminalSurface?.id.uuidString ?? "",
|
|
],
|
|
increments: ["probeKeyDownCount": 1]
|
|
)
|
|
#endif
|
|
|
|
// Fast path for control-modified terminal input (for example Ctrl+D).
|
|
//
|
|
// These keys are terminal control input, not text composition, so we bypass
|
|
// AppKit text interpretation and send a single deterministic Ghostty key event.
|
|
// This avoids intermittent drops after rapid split close/reparent transitions.
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
if flags.contains(.control) && !flags.contains(.command) && !flags.contains(.option) && !hasMarkedText() {
|
|
ghostty_surface_set_focus(surface, true)
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = UInt32(event.keyCode)
|
|
keyEvent.mods = modsFromEvent(event)
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.composing = false
|
|
keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event)
|
|
|
|
let text = (event.charactersIgnoringModifiers ?? event.characters ?? "")
|
|
let handled: Bool
|
|
if text.isEmpty {
|
|
keyEvent.text = nil
|
|
#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)
|
|
}
|
|
|
|
#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 }
|
|
return event.keyCode == 36 || event.keyCode == 76
|
|
}
|
|
|
|
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)
|
|
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: "Trigger Flash", action: #selector(triggerFlash(_:)), keyEquivalent: "")
|
|
flashItem.target = self
|
|
menu.addItem(.separator())
|
|
}
|
|
if ghostty_surface_has_selection(surface) {
|
|
let item = menu.addItem(withTitle: "Copy", action: #selector(copy(_:)), keyEquivalent: "")
|
|
item.target = self
|
|
}
|
|
let pasteItem = menu.addItem(withTitle: "Paste", action: #selector(paste(_:)), keyEquivalent: "")
|
|
pasteItem.target = self
|
|
menu.addItem(.separator())
|
|
let splitHorizontallyItem = menu.addItem(
|
|
withTitle: "Split Horizontally",
|
|
action: #selector(splitHorizontally(_:)),
|
|
keyEquivalent: "d"
|
|
)
|
|
splitHorizontallyItem.target = self
|
|
splitHorizontallyItem.keyEquivalentModifierMask = [.command, .shift]
|
|
splitHorizontallyItem.image = NSImage(
|
|
systemSymbolName: "rectangle.bottomhalf.inset.filled",
|
|
accessibilityDescription: nil
|
|
)
|
|
|
|
let splitVerticallyItem = menu.addItem(
|
|
withTitle: "Split Vertically",
|
|
action: #selector(splitVertically(_:)),
|
|
keyEquivalent: "d"
|
|
)
|
|
splitVerticallyItem.target = self
|
|
splitVerticallyItem.keyEquivalentModifierMask = [.command]
|
|
splitVerticallyItem.image = NSImage(
|
|
systemSymbolName: "rectangle.righthalf.inset.filled",
|
|
accessibilityDescription: nil
|
|
)
|
|
return menu
|
|
}
|
|
|
|
private func canSplitCurrentSurface() -> Bool {
|
|
guard let tabId,
|
|
let surfaceId = terminalSurface?.id,
|
|
let app = AppDelegate.shared,
|
|
let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager,
|
|
let workspace = manager.tabs.first(where: { $0.id == tabId }) else {
|
|
return false
|
|
}
|
|
return workspace.panels[surfaceId] != nil
|
|
}
|
|
|
|
@objc private func splitHorizontally(_ sender: Any?) {
|
|
_ = splitCurrentSurface(direction: .down)
|
|
}
|
|
|
|
@objc private func splitVertically(_ sender: Any?) {
|
|
_ = splitCurrentSurface(direction: .right)
|
|
}
|
|
|
|
@discardableResult
|
|
private func splitCurrentSurface(direction: SplitDirection) -> Bool {
|
|
guard let tabId,
|
|
let surfaceId = terminalSurface?.id,
|
|
let app = AppDelegate.shared,
|
|
let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else {
|
|
return false
|
|
}
|
|
return manager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
|
}
|
|
|
|
@objc private func triggerFlash(_ sender: Any?) {
|
|
onTriggerFlash?()
|
|
}
|
|
|
|
override func mouseMoved(with event: NSEvent) {
|
|
maybeRequestFirstResponderForMouseFocus()
|
|
guard let surface = surface else { return }
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
|
}
|
|
|
|
override func mouseEntered(with event: NSEvent) {
|
|
super.mouseEntered(with: event)
|
|
maybeRequestFirstResponderForMouseFocus()
|
|
guard let surface = surface else { return }
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
|
}
|
|
|
|
private func maybeRequestFirstResponderForMouseFocus() {
|
|
guard let window else { return }
|
|
let alreadyFirstResponder = window.firstResponder === self
|
|
let shouldRequest = Self.shouldRequestFirstResponderForMouseFocus(
|
|
focusFollowsMouseEnabled: GhosttyApp.shared.focusFollowsMouseEnabled(),
|
|
pressedMouseButtons: NSEvent.pressedMouseButtons,
|
|
appIsActive: NSApp.isActive,
|
|
windowIsKey: window.isKeyWindow,
|
|
alreadyFirstResponder: alreadyFirstResponder,
|
|
visibleInUI: isVisibleInUI,
|
|
hasUsableGeometry: hasUsableFocusGeometry,
|
|
hiddenInHierarchy: isHiddenOrHasHiddenAncestor
|
|
)
|
|
guard shouldRequest else { return }
|
|
window.makeFirstResponder(self)
|
|
}
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
guard let surface = surface else { return }
|
|
if NSEvent.pressedMouseButtons != 0 {
|
|
return
|
|
}
|
|
ghostty_surface_mouse_pos(surface, -1, -1, modsFromEvent(event))
|
|
}
|
|
|
|
override func mouseDragged(with event: NSEvent) {
|
|
guard let surface = surface else { return }
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
|
}
|
|
|
|
override func scrollWheel(with event: NSEvent) {
|
|
guard let surface = surface else { return }
|
|
lastScrollEventTime = CACurrentMediaTime()
|
|
Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))")
|
|
var x = event.scrollingDeltaX
|
|
var y = event.scrollingDeltaY
|
|
let precision = event.hasPreciseScrollingDeltas
|
|
if precision {
|
|
x *= 2
|
|
y *= 2
|
|
}
|
|
|
|
var mods: Int32 = 0
|
|
if precision {
|
|
mods |= 0b0000_0001
|
|
}
|
|
|
|
let momentum: Int32
|
|
switch event.momentumPhase {
|
|
case .began:
|
|
momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_BEGAN.rawValue)
|
|
case .stationary:
|
|
momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_STATIONARY.rawValue)
|
|
case .changed:
|
|
momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_CHANGED.rawValue)
|
|
case .ended:
|
|
momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_ENDED.rawValue)
|
|
case .cancelled:
|
|
momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_CANCELLED.rawValue)
|
|
case .mayBegin:
|
|
momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN.rawValue)
|
|
default:
|
|
momentum = Int32(GHOSTTY_MOUSE_MOMENTUM_NONE.rawValue)
|
|
}
|
|
mods |= momentum << 1
|
|
|
|
// Track scroll state for lag detection
|
|
let hasMomentum = event.momentumPhase != [] && event.momentumPhase != .mayBegin
|
|
let momentumEnded = event.momentumPhase == .ended || event.momentumPhase == .cancelled
|
|
GhosttyApp.shared.markScrollActivity(hasMomentum: hasMomentum, momentumEnded: momentumEnded)
|
|
|
|
ghostty_surface_mouse_scroll(
|
|
surface,
|
|
x,
|
|
y,
|
|
ghostty_input_scroll_mods_t(mods)
|
|
)
|
|
}
|
|
|
|
deinit {
|
|
// Surface lifecycle is managed by TerminalSurface, not the view
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.view.deinit view=\(Unmanaged.passUnretained(self).toOpaque()) " +
|
|
"surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
|
|
"inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0)"
|
|
)
|
|
#endif
|
|
if let eventMonitor {
|
|
NSEvent.removeMonitor(eventMonitor)
|
|
}
|
|
if let windowObserver {
|
|
NotificationCenter.default.removeObserver(windowObserver)
|
|
}
|
|
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 {
|
|
var result = value
|
|
for char in shellEscapeCharacters {
|
|
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func droppedContent(from pasteboard: NSPasteboard) -> String? {
|
|
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL], !urls.isEmpty {
|
|
return urls
|
|
.map { Self.escapeDropForShell($0.path) }
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
if let rawURL = pasteboard.string(forType: .URL), !rawURL.isEmpty {
|
|
return Self.escapeDropForShell(rawURL)
|
|
}
|
|
|
|
if let str = pasteboard.string(forType: .string), !str.isEmpty {
|
|
return str
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
@discardableResult
|
|
fileprivate func insertDroppedPasteboard(_ pasteboard: NSPasteboard) -> Bool {
|
|
guard let content = droppedContent(from: pasteboard) else { return false }
|
|
// Use the text/paste path (ghostty_surface_text) instead of the key event
|
|
// path (ghostty_surface_key) so bracketed paste mode is triggered and the
|
|
// insertion is instant, matching upstream Ghostty behaviour.
|
|
terminalSurface?.sendText(content)
|
|
return true
|
|
}
|
|
|
|
#if DEBUG
|
|
@discardableResult
|
|
fileprivate func debugSimulateFileDrop(paths: [String]) -> Bool {
|
|
guard !paths.isEmpty else { return false }
|
|
let urls = paths.map { URL(fileURLWithPath: $0) as NSURL }
|
|
let pbName = NSPasteboard.Name("cmux.debug.drop.\(UUID().uuidString)")
|
|
let pasteboard = NSPasteboard(name: pbName)
|
|
pasteboard.clearContents()
|
|
pasteboard.writeObjects(urls)
|
|
return insertDroppedPasteboard(pasteboard)
|
|
}
|
|
|
|
fileprivate func debugRegisteredDropTypes() -> [String] {
|
|
(registeredDraggedTypes ?? []).map(\.rawValue)
|
|
}
|
|
#endif
|
|
|
|
// MARK: NSDraggingDestination
|
|
|
|
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
|
#if DEBUG
|
|
let types = sender.draggingPasteboard.types ?? []
|
|
dlog("terminal.draggingEntered surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))")
|
|
#endif
|
|
guard let types = sender.draggingPasteboard.types else { return [] }
|
|
if Set(types).isDisjoint(with: Self.dropTypes) {
|
|
return []
|
|
}
|
|
return .copy
|
|
}
|
|
|
|
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
|
#if DEBUG
|
|
let types = sender.draggingPasteboard.types ?? []
|
|
dlog("terminal.draggingUpdated surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") types=\(types.map(\.rawValue))")
|
|
#endif
|
|
guard let types = sender.draggingPasteboard.types else { return [] }
|
|
if Set(types).isDisjoint(with: Self.dropTypes) {
|
|
return []
|
|
}
|
|
return .copy
|
|
}
|
|
|
|
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
|
#if DEBUG
|
|
dlog("terminal.fileDrop surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
|
#endif
|
|
return insertDroppedPasteboard(sender.draggingPasteboard)
|
|
}
|
|
}
|
|
|
|
private extension NSScreen {
|
|
var displayID: UInt32? {
|
|
let key = NSDeviceDescriptionKey("NSScreenNumber")
|
|
if let v = deviceDescription[key] as? UInt32 { return v }
|
|
if let v = deviceDescription[key] as? Int { return UInt32(v) }
|
|
if let v = deviceDescription[key] as? NSNumber { return v.uint32Value }
|
|
return nil
|
|
}
|
|
}
|
|
|
|
struct GhosttyScrollbar {
|
|
let total: UInt64
|
|
let offset: UInt64
|
|
let len: UInt64
|
|
|
|
init(c: ghostty_action_scrollbar_s) {
|
|
total = c.total
|
|
offset = c.offset
|
|
len = c.len
|
|
}
|
|
}
|
|
|
|
enum GhosttyNotificationKey {
|
|
static let scrollbar = "ghostty.scrollbar"
|
|
static let cellSize = "ghostty.cellSize"
|
|
static let tabId = "ghostty.tabId"
|
|
static let surfaceId = "ghostty.surfaceId"
|
|
static let title = "ghostty.title"
|
|
static let backgroundColor = "ghostty.backgroundColor"
|
|
static let backgroundOpacity = "ghostty.backgroundOpacity"
|
|
static let backgroundEventId = "ghostty.backgroundEventId"
|
|
static let backgroundSource = "ghostty.backgroundSource"
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let ghosttyDidUpdateScrollbar = Notification.Name("ghosttyDidUpdateScrollbar")
|
|
static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize")
|
|
static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus")
|
|
static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload")
|
|
static let ghosttyDefaultBackgroundDidChange = Notification.Name("ghosttyDefaultBackgroundDidChange")
|
|
static let browserSearchFocus = Notification.Name("browserSearchFocus")
|
|
}
|
|
|
|
// MARK: - Scroll View Wrapper (Ghostty-style scrollbar)
|
|
|
|
private final class GhosttyScrollView: NSScrollView {
|
|
weak var surfaceView: GhosttyNSView?
|
|
|
|
// Keep keyboard routing on the terminal surface; this wrapper is viewport plumbing.
|
|
override var acceptsFirstResponder: Bool { false }
|
|
|
|
override func scrollWheel(with event: NSEvent) {
|
|
guard let surfaceView else {
|
|
super.scrollWheel(with: event)
|
|
return
|
|
}
|
|
|
|
// Route wheel gestures to the terminal surface so Ghostty handles scrollback.
|
|
// Letting NSScrollView consume these events moves the wrapper viewport itself,
|
|
// which causes pane-content drift instead of terminal scrollback movement.
|
|
GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: surface scroll")
|
|
if window?.firstResponder !== surfaceView {
|
|
window?.makeFirstResponder(surfaceView)
|
|
}
|
|
surfaceView.scrollWheel(with: event)
|
|
}
|
|
}
|
|
|
|
private final class GhosttyFlashOverlayView: NSView {
|
|
override var acceptsFirstResponder: Bool { false }
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
nil
|
|
}
|
|
}
|
|
|
|
private final class GhosttyPassthroughVisualEffectView: NSVisualEffectView {
|
|
override var acceptsFirstResponder: Bool { false }
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
nil
|
|
}
|
|
}
|
|
|
|
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 standardFocus
|
|
case notificationDismiss
|
|
}
|
|
|
|
private enum NotificationRingMetrics {
|
|
static let inset: CGFloat = 2
|
|
static let cornerRadius: CGFloat = 6
|
|
}
|
|
|
|
private let backgroundView: NSView
|
|
private let scrollView: GhosttyScrollView
|
|
private let documentView: NSView
|
|
private let surfaceView: GhosttyNSView
|
|
private let inactiveOverlayView: GhosttyFlashOverlayView
|
|
private let dropZoneOverlayView: GhosttyFlashOverlayView
|
|
private let notificationRingOverlayView: GhosttyFlashOverlayView
|
|
private let notificationRingLayer: CAShapeLayer
|
|
private let flashOverlayView: GhosttyFlashOverlayView
|
|
private let flashLayer: CAShapeLayer
|
|
private let keyboardCopyModeBadgeContainerView: GhosttyFlashOverlayView
|
|
private let keyboardCopyModeBadgeView: GhosttyPassthroughVisualEffectView
|
|
private let keyboardCopyModeBadgeIconView: NSImageView
|
|
private let keyboardCopyModeBadgeLabel: NSTextField
|
|
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
|
|
private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem?
|
|
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
|
|
/// 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 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)
|
|
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 = 2.5
|
|
notificationRingLayer.lineJoin = .round
|
|
notificationRingLayer.lineCap = .round
|
|
notificationRingLayer.shadowColor = NSColor.systemBlue.cgColor
|
|
notificationRingLayer.shadowOpacity = 0.35
|
|
notificationRingLayer.shadowRadius = 3
|
|
notificationRingLayer.shadowOffset = .zero
|
|
notificationRingLayer.opacity = 0
|
|
notificationRingOverlayView.layer?.addSublayer(notificationRingLayer)
|
|
notificationRingOverlayView.isHidden = true
|
|
addSubview(notificationRingOverlayView)
|
|
flashOverlayView.wantsLayer = true
|
|
flashOverlayView.layer?.backgroundColor = NSColor.clear.cgColor
|
|
flashOverlayView.layer?.masksToBounds = false
|
|
flashOverlayView.autoresizingMask = [.width, .height]
|
|
flashLayer.fillColor = NSColor.clear.cgColor
|
|
flashLayer.strokeColor = NSColor.systemBlue.cgColor
|
|
flashLayer.lineWidth = 3
|
|
flashLayer.lineJoin = .round
|
|
flashLayer.lineCap = .round
|
|
flashLayer.shadowColor = NSColor.systemBlue.cgColor
|
|
flashLayer.shadowOpacity = 0.6
|
|
flashLayer.shadowRadius = 6
|
|
flashLayer.shadowOffset = .zero
|
|
flashLayer.opacity = 0
|
|
flashOverlayView.layer?.addSublayer(flashLayer)
|
|
addSubview(flashOverlayView)
|
|
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),
|
|
])
|
|
|
|
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: .ghosttySearchFocus,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard let self,
|
|
let surface = notification.object as? TerminalSurface,
|
|
surface === self.surfaceView.terminalSurface else { return }
|
|
self.searchFocusTarget = .searchField
|
|
// Explicitly unfocus the terminal so the cursor stops blinking
|
|
// when the search field takes over.
|
|
surface.setFocus(false)
|
|
})
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .ghosttyDidUpdateCellSize,
|
|
object: surfaceView,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.synchronizeScrollView()
|
|
})
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) not implemented")
|
|
}
|
|
|
|
deinit {
|
|
#if DEBUG
|
|
dlog(
|
|
"surface.hosted.deinit surface=\(debugSurfaceId?.uuidString.prefix(5) ?? "nil") " +
|
|
"inWindow=\(window != nil ? 1 : 0) hasSuperview=\(superview != nil ? 1 : 0) " +
|
|
"hidden=\(isHidden ? 1 : 0) frame=\(String(format: "%.1fx%.1f", frame.width, frame.height))"
|
|
)
|
|
#endif
|
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
deferredSearchOverlayMutationWorkItem?.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: .standardFocus)
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
private func makeSearchOverlayRootView(
|
|
terminalSurface: TerminalSurface,
|
|
searchState: TerminalSurface.SearchState
|
|
) -> SurfaceSearchOverlay {
|
|
SurfaceSearchOverlay(
|
|
tabId: terminalSurface.tabId,
|
|
surfaceId: terminalSurface.id,
|
|
searchState: searchState,
|
|
onMoveFocusToTerminal: { [weak self] in
|
|
self?.searchFocusTarget = .terminal
|
|
self?.moveFocus()
|
|
},
|
|
onNavigateSearch: { [weak terminalSurface] action in
|
|
_ = terminalSurface?.performBindingAction(action)
|
|
},
|
|
onFieldDidFocus: { [weak self, weak terminalSurface] in
|
|
self?.searchFocusTarget = .searchField
|
|
terminalSurface?.setFocus(false)
|
|
},
|
|
onClose: { [weak self, weak terminalSurface] in
|
|
terminalSurface?.searchState = nil
|
|
self?.moveFocus()
|
|
}
|
|
)
|
|
}
|
|
|
|
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 requestMountedSearchFieldFocus(
|
|
generation: UInt64,
|
|
force: Bool,
|
|
attemptsRemaining: Int = 4
|
|
) {
|
|
guard searchOverlayMutationGeneration == generation else { return }
|
|
guard force || searchFocusTarget == .searchField 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 = .standardFocus) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
#if DEBUG
|
|
if let surfaceId = self.surfaceView.terminalSurface?.id {
|
|
Self.recordFlash(for: surfaceId)
|
|
}
|
|
#endif
|
|
self.updateFlashPath(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 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 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 {
|
|
guard !urls.isEmpty else { return false }
|
|
let content = urls
|
|
.map { GhosttyNSView.escapeDropForShell($0.path) }
|
|
.joined(separator: " ")
|
|
#if DEBUG
|
|
dlog("terminal.swiftUIDrop surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") urls=\(urls.map(\.lastPathComponent))")
|
|
#endif
|
|
surfaceView.terminalSurface?.sendText(content)
|
|
return true
|
|
}
|
|
|
|
func terminalViewForDrop(at point: NSPoint) -> GhosttyNSView? {
|
|
guard bounds.contains(point), !isHidden else { return nil }
|
|
return surfaceView
|
|
}
|
|
|
|
#if DEBUG
|
|
/// Sends a synthetic key press/release pair directly to the surface view.
|
|
/// This exercises the same key path as real keyboard input (ghostty_surface_key),
|
|
/// unlike sendText, which bypasses key translation.
|
|
@discardableResult
|
|
func debugSendSyntheticKeyPressAndReleaseForUITest(
|
|
characters: String,
|
|
charactersIgnoringModifiers: String,
|
|
keyCode: UInt16,
|
|
modifierFlags: NSEvent.ModifierFlags = []
|
|
) -> Bool {
|
|
guard let window else { return false }
|
|
window.makeFirstResponder(surfaceView)
|
|
|
|
let timestamp = ProcessInfo.processInfo.systemUptime
|
|
guard let keyDown = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: modifierFlags,
|
|
timestamp: timestamp,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
characters: characters,
|
|
charactersIgnoringModifiers: charactersIgnoringModifiers,
|
|
isARepeat: false,
|
|
keyCode: keyCode
|
|
) else { return false }
|
|
|
|
guard let keyUp = NSEvent.keyEvent(
|
|
with: .keyUp,
|
|
location: .zero,
|
|
modifierFlags: modifierFlags,
|
|
timestamp: timestamp + 0.001,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
characters: characters,
|
|
charactersIgnoringModifiers: charactersIgnoringModifiers,
|
|
isARepeat: false,
|
|
keyCode: keyCode
|
|
) else { return false }
|
|
|
|
surfaceView.keyDown(with: keyDown)
|
|
surfaceView.keyUp(with: keyUp)
|
|
return true
|
|
}
|
|
|
|
/// Sends a synthetic Ctrl+D key press directly to the surface view.
|
|
/// This exercises the same key path as real keyboard input (ghostty_surface_key),
|
|
/// unlike `sendText`, which bypasses key translation.
|
|
@discardableResult
|
|
func sendSyntheticCtrlDForUITest(modifierFlags: NSEvent.ModifierFlags = [.control]) -> Bool {
|
|
debugSendSyntheticKeyPressAndReleaseForUITest(
|
|
characters: "\u{04}",
|
|
charactersIgnoringModifiers: "d",
|
|
keyCode: 2,
|
|
modifierFlags: modifierFlags
|
|
)
|
|
}
|
|
#endif
|
|
|
|
func ensureFocus(for tabId: UUID, surfaceId: UUID) {
|
|
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 .standardFocus:
|
|
inset = CGFloat(FocusFlashPattern.ringInset)
|
|
radius = CGFloat(FocusFlashPattern.ringCornerRadius)
|
|
case .notificationDismiss:
|
|
inset = NotificationRingMetrics.inset
|
|
radius = NotificationRingMetrics.cornerRadius
|
|
}
|
|
updateOverlayRingPath(
|
|
layer: flashLayer,
|
|
bounds: flashOverlayView.bounds,
|
|
inset: inset,
|
|
radius: radius
|
|
)
|
|
}
|
|
|
|
private func updateOverlayRingPath(
|
|
layer: CAShapeLayer,
|
|
bounds: CGRect,
|
|
inset: CGFloat,
|
|
radius: CGFloat
|
|
) {
|
|
layer.frame = bounds
|
|
guard bounds.width > inset * 2, bounds.height > inset * 2 else {
|
|
layer.path = nil
|
|
return
|
|
}
|
|
let rect = bounds.insetBy(dx: inset, dy: inset)
|
|
layer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
|
}
|
|
|
|
private func synchronizeScrollView() {
|
|
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
|
|
}
|
|
|
|
// Only auto-scroll if user hasn't manually scrolled away from bottom
|
|
// or if we're following terminal output (scrollbar shows we're at bottom)
|
|
let shouldAutoScroll = !userScrolledAwayFromBottom ||
|
|
(scrollbar.offset + scrollbar.len >= scrollbar.total)
|
|
|
|
if shouldAutoScroll && !pointApproximatelyEqual(currentOrigin, targetOrigin) {
|
|
#if DEBUG
|
|
logDragGeometryChange(
|
|
event: "scrollOrigin",
|
|
old: currentOrigin,
|
|
new: targetOrigin
|
|
)
|
|
#endif
|
|
scrollView.contentView.scroll(to: targetOrigin)
|
|
didChangeGeometry = true
|
|
}
|
|
lastSentRow = Int(scrollbar.offset)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
surfaceView.scrollbar = scrollbar
|
|
synchronizeScrollView()
|
|
}
|
|
|
|
private func documentHeight() -> CGFloat {
|
|
let contentHeight = scrollView.contentSize.height
|
|
let cellHeight = surfaceView.cellSize.height
|
|
if cellHeight > 0, let scrollbar = surfaceView.scrollbar {
|
|
let documentGridHeight = CGFloat(scrollbar.total) * cellHeight
|
|
let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight)
|
|
return documentGridHeight + padding
|
|
}
|
|
return contentHeight
|
|
}
|
|
}
|
|
|
|
// MARK: - NSTextInputClient
|
|
|
|
extension GhosttyNSView: NSTextInputClient {
|
|
fileprivate func sendTextToSurface(_ chars: String) {
|
|
guard let surface = surface else { return }
|
|
#if DEBUG
|
|
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() }
|
|
}
|
|
}
|