* Fix terminal keys (arrows, Ctrl+N/P) swallowed after opening browser After a browser panel is shown, SwiftUI's internal focus system activates and its _NSHostingView starts consuming arrow keys and other non-Command key events via performKeyEquivalent, preventing them from reaching the terminal's keyDown handler. Fix: In the NSWindow performKeyEquivalent swizzle, when GhosttyNSView is the first responder and the event has no Command modifier, route directly to the terminal's performKeyEquivalent — bypassing SwiftUI's view hierarchy walk entirely. Also clear stale browserAddressBarFocusedPanelId when a terminal surface has focus, preventing Cmd+N from being eaten by omnibar selection logic after focus transitions away from a browser. Adds DEBUG-only keyboard event ring buffer (KeyDebugLog) that dumps to /tmp/cmux-key-debug.log for diagnosing future key routing issues. * Fix split focus and Cmd+Shift+N swallowed after opening browser Split focus: capture the source terminal's hostedView before bonsplit mutates focusedPaneId, so focusPanel moves focus FROM the old pane instead of from the new pane to itself. Also retry ensureFocus when the new terminal's view has no window yet (matching the existing retry pattern for isVisibleInUI). Cmd+Shift+N: after WKWebView has been in the responder chain, SwiftUI's internal focus system can intercept Command-key events in the content view hierarchy (returning true) without firing the CommandGroup action closure. Fix by dispatching Command-key events directly to NSApp.mainMenu when the terminal is first responder, bypassing the broken SwiftUI path. Also add Cmd+Shift+N to handleCustomShortcut so it's customizable and doesn't depend on SwiftUI menu dispatch at all. * Unified debug event log: merge key/mouse/focus into /tmp/cmux-debug.log - Delete KeyDebugLog, MouseDebugLog, klog(), mlog() from AppDelegate - Replace all klog/mlog calls with dlog() (provided by bonsplit) - Remove debugLogCallback wiring from Workspace - Add focus change logging: focus.panel, focus.firstResponder, split.created, focus.moveFocus - Add import Bonsplit where needed for dlog access - Fix stale drag state on cancelled tab drags (bonsplit submodule) * Fix split focus stolen by re-entrant becomeFirstResponder during reparenting During programmatic splits (Cmd+D / Cmd+Shift+D), SwiftUI reparents the old terminal view, which fires becomeFirstResponder → onFocus → focusPanel for the OLD panel, stealing focus from the newly created pane. Add programmaticFocusTargetPanelId guard to suppress re-entrant focusPanel calls for non-target panels during split creation. Also document the unified debug event log in CLAUDE.md. * Clear stale title/favicon when browser navigation fails When a page fails to load (e.g. connection refused), the tab was still showing the previous page's title and favicon. Now didFailProvisionalNavigation resets pageTitle to the failed URL and clears faviconPNGData. * Fix Cmd+N swallowed by browser omnibar and improve split focus suppression - Only Ctrl+N/P trigger omnibar navigation, not Cmd+N/P (Cmd+N should always create new workspace regardless of address bar focus) - Move split focus suppression from workspace-level guard to source: suppress becomeFirstResponder side-effects (onFocus + ghostty_surface_set_focus) directly on the old GhosttyNSView during reparenting, preventing both model-level and libghostty-level focus divergence - Remove programmaticFocusTargetPanelId from Workspace.focusPanel * Fix omnibar hang, WebView white flash, drag-over-browser, and idle CPU spin - Omnibar: first click selects all without entering NSTextView tracking loop; subsequent clicks have 3s synthetic mouseUp safety net to prevent hang - WebView: set underPageBackgroundColor to match window so new browsers don't flash white before content loads - Drag/drop: register custom UTType (com.splittabbar.tabtransfer) in Info.plist so WKWebView doesn't intercept tab drags; override registerForDraggedTypes on CmuxWebView as belt-and-suspenders - CPU: fix infinite makeFirstResponder loop in controlTextDidEndEditing by checking both the text field and its field editor (the actual first responder)
3672 lines
143 KiB
Swift
3672 lines
143 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import AppKit
|
|
import Metal
|
|
import QuartzCore
|
|
import Combine
|
|
import Darwin
|
|
import Sentry
|
|
import Bonsplit
|
|
import IOSurface
|
|
|
|
#if os(macOS)
|
|
private func cmuxShouldUseTransparentBackgroundWindow() -> Bool {
|
|
let defaults = UserDefaults.standard
|
|
let sidebarBlendMode = defaults.string(forKey: "sidebarBlendMode") ?? "withinWindow"
|
|
let bgGlassEnabled = defaults.object(forKey: "bgGlassEnabled") as? Bool ?? true
|
|
return sidebarBlendMode == "behindWindow" && bgGlassEnabled && !WindowGlassEffect.isAvailable
|
|
}
|
|
#endif
|
|
|
|
#if DEBUG
|
|
private func cmuxChildExitProbePath() -> String? {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_SETUP"] == "1",
|
|
let path = env["CMUX_UI_TEST_CHILD_EXIT_KEYBOARD_PATH"],
|
|
!path.isEmpty else {
|
|
return nil
|
|
}
|
|
return path
|
|
}
|
|
|
|
private func cmuxLoadChildExitProbe(at path: String) -> [String: String] {
|
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
|
return [:]
|
|
}
|
|
return object
|
|
}
|
|
|
|
private func cmuxWriteChildExitProbe(_ updates: [String: String], increments: [String: Int] = [:]) {
|
|
guard let path = cmuxChildExitProbePath() else { return }
|
|
var payload = cmuxLoadChildExitProbe(at: path)
|
|
for (key, by) in increments {
|
|
let current = Int(payload[key] ?? "") ?? 0
|
|
payload[key] = String(current + by)
|
|
}
|
|
for (key, value) in updates {
|
|
payload[key] = value
|
|
}
|
|
guard let out = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
try? out.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
}
|
|
|
|
private func cmuxScalarHex(_ value: String?) -> String {
|
|
guard let value else { return "" }
|
|
return value.unicodeScalars
|
|
.map { String(format: "%04X", $0.value) }
|
|
.joined(separator: ",")
|
|
}
|
|
#endif
|
|
|
|
private enum GhosttyPasteboardHelper {
|
|
private static let selectionPasteboard = NSPasteboard(
|
|
name: NSPasteboard.Name("com.mitchellh.ghostty.selection")
|
|
)
|
|
private static let utf8PlainTextType = NSPasteboard.PasteboardType("public.utf8-plain-text")
|
|
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
|
|
|
|
static func pasteboard(for location: ghostty_clipboard_e) -> NSPasteboard? {
|
|
switch location {
|
|
case GHOSTTY_CLIPBOARD_STANDARD:
|
|
return .general
|
|
case GHOSTTY_CLIPBOARD_SELECTION:
|
|
return selectionPasteboard
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
static func stringContents(from pasteboard: NSPasteboard) -> String? {
|
|
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL],
|
|
!urls.isEmpty {
|
|
return urls
|
|
.map { $0.isFileURL ? escapeForShell($0.path) : $0.absoluteString }
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
if let value = pasteboard.string(forType: .string) {
|
|
return value
|
|
}
|
|
|
|
return pasteboard.string(forType: utf8PlainTextType)
|
|
}
|
|
|
|
static func hasString(for location: ghostty_clipboard_e) -> Bool {
|
|
guard let pasteboard = pasteboard(for: location) else { return false }
|
|
return (stringContents(from: pasteboard) ?? "").isEmpty == false
|
|
}
|
|
|
|
static func writeString(_ string: String, to location: ghostty_clipboard_e) {
|
|
guard let pasteboard = pasteboard(for: location) else { return }
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(string, forType: .string)
|
|
}
|
|
|
|
private static func escapeForShell(_ value: String) -> String {
|
|
var result = value
|
|
for char in shellEscapeCharacters {
|
|
result = result.replacingOccurrences(of: String(char), with: "\\\(char)")
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
// 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(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
|
|
let backgroundLogEnabled = {
|
|
if ProcessInfo.processInfo.environment["CMUX_DEBUG_BG"] == "1" {
|
|
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 = URL(fileURLWithPath: "/tmp/cmux-bg.log")
|
|
private var appObservers: [NSObjectProtocol] = []
|
|
|
|
// 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 = 25 // Alert if tick takes >25ms during scroll
|
|
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
|
|
if maxLag > threshold {
|
|
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")
|
|
}
|
|
}
|
|
// 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.
|
|
ghostty_config_load_default_files(primaryConfig)
|
|
loadLegacyGhosttyConfigIfNeeded(primaryConfig)
|
|
ghostty_config_finalize(primaryConfig)
|
|
updateDefaultBackground(from: 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 userdata else { return }
|
|
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
|
|
guard let surface = surfaceView.terminalSurface?.surface else { return }
|
|
|
|
let pasteboard = GhosttyPasteboardHelper.pasteboard(for: location)
|
|
let value = pasteboard.flatMap { GhosttyPasteboardHelper.stringContents(from: $0) } ?? ""
|
|
|
|
value.withCString { ptr in
|
|
ghostty_surface_complete_clipboard_request(surface, ptr, state, false)
|
|
}
|
|
}
|
|
runtimeConfig.confirm_read_clipboard_cb = { userdata, content, state, _ in
|
|
guard let userdata, let content else { return }
|
|
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
|
|
guard let surface = surfaceView.terminalSurface?.surface 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 userdata else { return }
|
|
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
|
|
let callbackSurfaceId = surfaceView.terminalSurface?.id
|
|
let callbackTabId = surfaceView.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 callbackSurfaceId,
|
|
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)
|
|
|
|
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).
|
|
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 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 let newSize = fileSize(configNew), newSize == 0 else { return }
|
|
guard let legacySize = fileSize(configLegacy), legacySize > 0 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) {
|
|
guard let app else { return }
|
|
if soft, let config {
|
|
ghostty_app_update_config(app, config)
|
|
NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)
|
|
return
|
|
}
|
|
|
|
guard let newConfig = ghostty_config_new() else { return }
|
|
ghostty_config_load_default_files(newConfig)
|
|
ghostty_config_finalize(newConfig)
|
|
ghostty_app_update_config(app, newConfig)
|
|
updateDefaultBackground(from: newConfig)
|
|
DispatchQueue.main.async {
|
|
self.applyBackgroundToKeyWindow()
|
|
}
|
|
if let oldConfig = config {
|
|
ghostty_config_free(oldConfig)
|
|
}
|
|
config = newConfig
|
|
NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)
|
|
}
|
|
|
|
func reloadConfiguration(for surface: ghostty_surface_t, soft: Bool = false) {
|
|
if soft, let config {
|
|
ghostty_surface_update_config(surface, config)
|
|
return
|
|
}
|
|
|
|
guard let newConfig = ghostty_config_new() else { return }
|
|
ghostty_config_load_default_files(newConfig)
|
|
ghostty_config_finalize(newConfig)
|
|
ghostty_surface_update_config(surface, newConfig)
|
|
ghostty_config_free(newConfig)
|
|
}
|
|
|
|
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 updateDefaultBackground(from config: ghostty_config_t?) {
|
|
guard let config else { return }
|
|
|
|
var color = ghostty_config_color_s()
|
|
let bgKey = "background"
|
|
if ghostty_config_get(config, &color, bgKey, UInt(bgKey.lengthOfBytes(using: .utf8))) {
|
|
defaultBackgroundColor = NSColor(
|
|
red: CGFloat(color.r) / 255,
|
|
green: CGFloat(color.g) / 255,
|
|
blue: CGFloat(color.b) / 255,
|
|
alpha: 1.0
|
|
)
|
|
}
|
|
|
|
var opacity: Double = 1.0
|
|
let opacityKey = "background-opacity"
|
|
_ = ghostty_config_get(config, &opacity, opacityKey, UInt(opacityKey.lengthOfBytes(using: .utf8)))
|
|
defaultBackgroundOpacity = opacity
|
|
if backgroundLogEnabled {
|
|
logBackground("default background updated color=\(defaultBackgroundColor) opacity=\(String(format: "%.3f", defaultBackgroundOpacity))")
|
|
}
|
|
}
|
|
|
|
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 func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool {
|
|
if target.tag != GHOSTTY_TARGET_SURFACE {
|
|
if action.tag == GHOSTTY_ACTION_DESKTOP_NOTIFICATION {
|
|
let actionTitle = action.action.desktop_notification.title
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
let actionBody = action.action.desktop_notification.body
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
return performOnMain {
|
|
guard let tabManager = AppDelegate.shared?.tabManager,
|
|
let tabId = tabManager.selectedTabId else {
|
|
return false
|
|
}
|
|
let tabTitle = tabManager.titleForTab(tabId) ?? "Terminal"
|
|
let command = actionTitle.isEmpty ? tabTitle : actionTitle
|
|
let body = actionBody
|
|
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
|
tabManager.moveTabToTop(tabId)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: command,
|
|
subtitle: "",
|
|
body: body
|
|
)
|
|
return true
|
|
}
|
|
}
|
|
|
|
if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG {
|
|
let soft = action.action.reload_config.soft
|
|
performOnMain {
|
|
GhosttyApp.shared.reloadConfiguration(soft: soft)
|
|
}
|
|
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
|
|
defaultBackgroundColor = NSColor(
|
|
red: CGFloat(change.r) / 255,
|
|
green: CGFloat(change.g) / 255,
|
|
blue: CGFloat(change.b) / 255,
|
|
alpha: 1.0
|
|
)
|
|
if backgroundLogEnabled {
|
|
logBackground("OSC background change (app target) color=\(defaultBackgroundColor)")
|
|
}
|
|
DispatchQueue.main.async {
|
|
GhosttyApp.shared.applyBackgroundToKeyWindow()
|
|
}
|
|
return true
|
|
}
|
|
|
|
if action.tag == GHOSTTY_ACTION_CONFIG_CHANGE {
|
|
updateDefaultBackground(from: action.action.config_change.config)
|
|
DispatchQueue.main.async {
|
|
GhosttyApp.shared.applyBackgroundToKeyWindow()
|
|
}
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
guard let userdata = ghostty_surface_userdata(target.target.surface) else { return false }
|
|
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
|
|
|
|
switch action.tag {
|
|
case GHOSTTY_ACTION_NEW_SPLIT:
|
|
guard let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id,
|
|
let direction = splitDirection(from: action.action.new_split) else {
|
|
return false
|
|
}
|
|
return performOnMain {
|
|
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
|
|
return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
|
|
}
|
|
case GHOSTTY_ACTION_GOTO_SPLIT:
|
|
guard let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id,
|
|
let direction = focusDirection(from: action.action.goto_split) else {
|
|
return false
|
|
}
|
|
return performOnMain {
|
|
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
|
|
return tabManager.moveSplitFocus(tabId: tabId, surfaceId: surfaceId, direction: direction)
|
|
}
|
|
case GHOSTTY_ACTION_RESIZE_SPLIT:
|
|
guard let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id,
|
|
let direction = resizeDirection(from: action.action.resize_split.direction) else {
|
|
return false
|
|
}
|
|
let amount = action.action.resize_split.amount
|
|
return performOnMain {
|
|
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
|
|
return tabManager.resizeSplit(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
direction: direction,
|
|
amount: amount
|
|
)
|
|
}
|
|
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
|
|
guard let tabId = surfaceView.tabId else {
|
|
return false
|
|
}
|
|
return performOnMain {
|
|
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
|
|
return tabManager.equalizeSplits(tabId: tabId)
|
|
}
|
|
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
|
|
guard let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id else {
|
|
return false
|
|
}
|
|
return performOnMain {
|
|
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
|
|
return tabManager.toggleSplitZoom(tabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
case GHOSTTY_ACTION_SCROLLBAR:
|
|
let scrollbar = GhosttyScrollbar(c: action.action.scrollbar)
|
|
surfaceView.scrollbar = scrollbar
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidUpdateScrollbar,
|
|
object: surfaceView,
|
|
userInfo: [GhosttyNotificationKey.scrollbar: scrollbar]
|
|
)
|
|
return true
|
|
case GHOSTTY_ACTION_CELL_SIZE:
|
|
let cellSize = CGSize(
|
|
width: CGFloat(action.action.cell_size.width),
|
|
height: CGFloat(action.action.cell_size.height)
|
|
)
|
|
surfaceView.cellSize = cellSize
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidUpdateCellSize,
|
|
object: surfaceView,
|
|
userInfo: [GhosttyNotificationKey.cellSize: cellSize]
|
|
)
|
|
return true
|
|
case GHOSTTY_ACTION_START_SEARCH:
|
|
guard let terminalSurface = surfaceView.terminalSurface else { return true }
|
|
let needle = action.action.start_search.needle.flatMap { String(cString: $0) }
|
|
DispatchQueue.main.async {
|
|
if let searchState = terminalSurface.searchState {
|
|
if let needle, !needle.isEmpty {
|
|
searchState.needle = needle
|
|
}
|
|
} else {
|
|
terminalSurface.searchState = TerminalSurface.SearchState(needle: needle ?? "")
|
|
}
|
|
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_END_SEARCH:
|
|
guard let terminalSurface = surfaceView.terminalSurface else { return true }
|
|
DispatchQueue.main.async {
|
|
terminalSurface.searchState = nil
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_SEARCH_TOTAL:
|
|
guard let terminalSurface = surfaceView.terminalSurface else { return true }
|
|
let rawTotal = action.action.search_total.total
|
|
let total: UInt? = rawTotal >= 0 ? UInt(rawTotal) : nil
|
|
DispatchQueue.main.async {
|
|
terminalSurface.searchState?.total = total
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_SEARCH_SELECTED:
|
|
guard let terminalSurface = surfaceView.terminalSurface else { return true }
|
|
let rawSelected = action.action.search_selected.selected
|
|
let selected: UInt? = rawSelected >= 0 ? UInt(rawSelected) : nil
|
|
DispatchQueue.main.async {
|
|
terminalSurface.searchState?.selected = selected
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_SET_TITLE:
|
|
let title = action.action.set_title.title
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
if let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id {
|
|
DispatchQueue.main.async {
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidSetTitle,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
GhosttyNotificationKey.tabId: tabId,
|
|
GhosttyNotificationKey.surfaceId: surfaceId,
|
|
GhosttyNotificationKey.title: title,
|
|
]
|
|
)
|
|
}
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_PWD:
|
|
guard let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id else { return true }
|
|
let pwd = action.action.pwd.pwd.flatMap { String(cString: $0) } ?? ""
|
|
DispatchQueue.main.async {
|
|
AppDelegate.shared?.tabManager?.updateSurfaceDirectory(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
directory: pwd
|
|
)
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
|
|
guard let tabId = surfaceView.tabId else { return true }
|
|
let surfaceId = surfaceView.terminalSurface?.id
|
|
let actionTitle = action.action.desktop_notification.title
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
let actionBody = action.action.desktop_notification.body
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
performOnMain {
|
|
let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal"
|
|
let command = actionTitle.isEmpty ? tabTitle : actionTitle
|
|
let body = actionBody
|
|
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: command,
|
|
subtitle: "",
|
|
body: body
|
|
)
|
|
}
|
|
return true
|
|
case 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
|
|
cmuxWriteChildExitProbe(
|
|
[
|
|
"probeShowChildExitedTabId": surfaceView.tabId?.uuidString ?? "",
|
|
"probeShowChildExitedSurfaceId": surfaceView.terminalSurface?.id.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 tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id,
|
|
let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager,
|
|
let workspace = manager.tabs.first(where: { $0.id == tabId }),
|
|
workspace.panels[surfaceId] != nil {
|
|
manager.closePanelAfterChildExited(tabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
}
|
|
// Always report handled so Ghostty doesn't print the fallback prompt.
|
|
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
|
|
)
|
|
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:
|
|
updateDefaultBackground(from: action.action.config_change.config)
|
|
DispatchQueue.main.async {
|
|
surfaceView.applyWindowBackgroundIfActive()
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_RELOAD_CONFIG:
|
|
let soft = action.action.reload_config.soft
|
|
return performOnMain {
|
|
if let surface = surfaceView.terminalSurface?.surface {
|
|
GhosttyApp.shared.reloadConfiguration(for: surface, soft: soft)
|
|
} else {
|
|
GhosttyApp.shared.reloadConfiguration(soft: soft)
|
|
}
|
|
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
|
|
}
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func applyBackgroundToKeyWindow() {
|
|
guard let window = activeMainWindow() else { return }
|
|
if cmuxShouldUseTransparentBackgroundWindow() {
|
|
window.backgroundColor = .clear
|
|
window.isOpaque = false
|
|
if backgroundLogEnabled {
|
|
logBackground("applied transparent window for behindWindow blur")
|
|
}
|
|
} 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 line = "cmux bg: \(message)\n"
|
|
if let data = line.data(using: .utf8) {
|
|
if FileManager.default.fileExists(atPath: backgroundLogURL.path) == false {
|
|
FileManager.default.createFile(atPath: backgroundLogURL.path, contents: nil)
|
|
}
|
|
if let handle = try? FileHandle(forWritingTo: backgroundLogURL) {
|
|
defer { try? handle.close() }
|
|
try? handle.seekToEnd()
|
|
try? handle.write(contentsOf: data)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Debug Render Instrumentation
|
|
|
|
/// Lightweight instrumentation to detect whether Ghostty is actually requesting Metal drawables.
|
|
/// This helps catch "frozen until refocus" regressions without relying on screenshots (which can
|
|
/// mask redraw issues by forcing a window server flush).
|
|
final class GhosttyMetalLayer: CAMetalLayer {
|
|
private let lock = NSLock()
|
|
private var drawableCount: Int = 0
|
|
private var lastDrawableTime: CFTimeInterval = 0
|
|
|
|
func debugStats() -> (count: Int, last: CFTimeInterval) {
|
|
lock.lock()
|
|
defer { lock.unlock() }
|
|
return (drawableCount, lastDrawableTime)
|
|
}
|
|
|
|
override func nextDrawable() -> CAMetalDrawable? {
|
|
lock.lock()
|
|
drawableCount += 1
|
|
lastDrawableTime = CACurrentMediaTime()
|
|
lock.unlock()
|
|
return super.nextDrawable()
|
|
}
|
|
}
|
|
|
|
// MARK: - Terminal Surface (owns the ghostty_surface_t lifecycle)
|
|
|
|
final class TerminalSurface: Identifiable, ObservableObject {
|
|
final class SearchState: ObservableObject {
|
|
@Published var needle: String
|
|
@Published var selected: UInt?
|
|
@Published var total: UInt?
|
|
|
|
init(needle: String = "") {
|
|
self.needle = needle
|
|
self.selected = nil
|
|
self.total = nil
|
|
}
|
|
}
|
|
|
|
private(set) var surface: ghostty_surface_t?
|
|
private weak var attachedView: GhosttyNSView?
|
|
/// Whether the terminal surface view is currently attached to a window.
|
|
///
|
|
/// Use the hosted view rather than the inner surface view, since the surface can be
|
|
/// temporarily unattached (surface not yet created / reparenting) even while the panel
|
|
/// is already in the window.
|
|
var isViewInWindow: Bool { hostedView.window != nil }
|
|
let id: UUID
|
|
private(set) var tabId: UUID
|
|
private let surfaceContext: ghostty_surface_context_e
|
|
private let configTemplate: ghostty_surface_config_s?
|
|
private let workingDirectory: 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
|
|
@Published var searchState: SearchState? = nil {
|
|
didSet {
|
|
if let searchState {
|
|
hostedView.cancelFocusRequest()
|
|
NSLog("Find: search state created tab=%@ surface=%@", tabId.uuidString, id.uuidString)
|
|
searchNeedleCancellable = searchState.$needle
|
|
.removeDuplicates()
|
|
.map { needle -> AnyPublisher<String, Never> in
|
|
if needle.isEmpty || needle.count >= 3 {
|
|
return Just(needle).eraseToAnyPublisher()
|
|
}
|
|
|
|
return Just(needle)
|
|
.delay(for: .milliseconds(300), scheduler: DispatchQueue.main)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
.switchToLatest()
|
|
.sink { [weak self] needle in
|
|
NSLog("Find: needle updated tab=%@ surface=%@ needle=%@", self?.tabId.uuidString ?? "unknown", self?.id.uuidString ?? "unknown", needle)
|
|
_ = self?.performBindingAction("search:\(needle)")
|
|
}
|
|
} else if oldValue != nil {
|
|
searchNeedleCancellable = nil
|
|
NSLog("Find: search state cleared tab=%@ surface=%@", tabId.uuidString, id.uuidString)
|
|
_ = performBindingAction("end_search")
|
|
}
|
|
}
|
|
}
|
|
private var searchNeedleCancellable: AnyCancellable?
|
|
|
|
init(
|
|
tabId: UUID,
|
|
context: ghostty_surface_context_e,
|
|
configTemplate: ghostty_surface_config_s?,
|
|
workingDirectory: String? = nil
|
|
) {
|
|
self.id = UUID()
|
|
self.tabId = tabId
|
|
self.surfaceContext = context
|
|
self.configTemplate = configTemplate
|
|
self.workingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
// Match Ghostty's own SurfaceView: ensure a non-zero initial frame so the backing layer
|
|
// has non-zero bounds and the renderer can initialize without presenting a blank/stretched
|
|
// intermediate frame on the first real resize.
|
|
let view = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600))
|
|
self.surfaceView = view
|
|
self.hostedView = GhosttySurfaceScrollView(surfaceView: view)
|
|
// Surface is created when attached to a view
|
|
hostedView.attachSurface(self)
|
|
}
|
|
|
|
|
|
func updateWorkspaceId(_ newTabId: UUID) {
|
|
tabId = newTabId
|
|
attachedView?.tabId = newTabId
|
|
surfaceView.tabId = newTabId
|
|
}
|
|
#if DEBUG
|
|
private static let surfaceLogPath = "/tmp/cmux-ghostty-surface.log"
|
|
private static let sizeLogPath = "/tmp/cmux-ghostty-size.log"
|
|
|
|
private static func surfaceLog(_ message: String) {
|
|
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
let line = "[\(timestamp)] \(message)\n"
|
|
if let handle = FileHandle(forWritingAtPath: surfaceLogPath) {
|
|
handle.seekToEndOfFile()
|
|
handle.write(line.data(using: .utf8)!)
|
|
handle.closeFile()
|
|
} else {
|
|
FileManager.default.createFile(atPath: surfaceLogPath, contents: line.data(using: .utf8))
|
|
}
|
|
}
|
|
|
|
private static func sizeLog(_ message: String) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_SPLIT_CLOSE_RIGHT_VISUAL"] == "1" else { return }
|
|
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
let line = "[\(timestamp)] \(message)\n"
|
|
if let handle = FileHandle(forWritingAtPath: sizeLogPath) {
|
|
handle.seekToEndOfFile()
|
|
handle.write(line.data(using: .utf8)!)
|
|
handle.closeFile()
|
|
} else {
|
|
FileManager.default.createFile(atPath: sizeLogPath, contents: line.data(using: .utf8))
|
|
}
|
|
}
|
|
#endif
|
|
|
|
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
|
|
print("[TerminalSurface] attachToView: \(id) attachedView=\(attachedView != nil) surface=\(surface != nil)")
|
|
#endif
|
|
|
|
// If already attached to this view, nothing to do.
|
|
// Still re-assert the display id: during split close tree restructuring, the view can be
|
|
// removed/re-added (or briefly have window/screen nil) without recreating the surface.
|
|
// Ghostty's vsync-driven renderer depends on having a valid display id; if it is missing
|
|
// or stale, the surface can appear visually frozen until a focus/visibility change.
|
|
if attachedView === view && surface != nil {
|
|
#if DEBUG
|
|
print("[TerminalSurface] attachToView: same view and surface exists")
|
|
#endif
|
|
if let screen = view.window?.screen ?? NSScreen.main,
|
|
let displayID = screen.displayID,
|
|
displayID != 0,
|
|
let s = surface {
|
|
ghostty_surface_set_display_id(s, displayID)
|
|
}
|
|
view.forceRefreshSurface()
|
|
return
|
|
}
|
|
|
|
if let attachedView, attachedView !== view {
|
|
#if DEBUG
|
|
print("[TerminalSurface] attachToView: different view, returning")
|
|
#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 { return }
|
|
#if DEBUG
|
|
print("[TerminalSurface] attachToView: creating surface for \(id)")
|
|
#endif
|
|
createSurface(for: view)
|
|
#if DEBUG
|
|
print("[TerminalSurface] attachToView: after createSurface, surface=\(surface != nil)")
|
|
#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)
|
|
}
|
|
}
|
|
|
|
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()
|
|
))
|
|
surfaceConfig.userdata = Unmanaged.passUnretained(view).toOpaque()
|
|
surfaceConfig.scale_factor = scaleFactors.layer
|
|
surfaceConfig.context = surfaceContext
|
|
var envVars: [ghostty_env_var_s] = []
|
|
var envStorage: [(UnsafeMutablePointer<CChar>, UnsafeMutablePointer<CChar>)] = []
|
|
defer {
|
|
for (key, value) in envStorage {
|
|
free(key)
|
|
free(value)
|
|
}
|
|
}
|
|
|
|
var env: [String: String] = [:]
|
|
if surfaceConfig.env_var_count > 0, let existingEnv = surfaceConfig.env_vars {
|
|
let count = Int(surfaceConfig.env_var_count)
|
|
if count > 0 {
|
|
for i in 0..<count {
|
|
let item = existingEnv[i]
|
|
if let key = String(cString: item.key, encoding: .utf8),
|
|
let value = String(cString: item.value, encoding: .utf8) {
|
|
env[key] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
env["CMUX_SURFACE_ID"] = id.uuidString
|
|
env["CMUX_WORKSPACE_ID"] = tabId.uuidString
|
|
// Backward-compatible shell integration keys used by existing scripts/tests.
|
|
env["CMUX_PANEL_ID"] = id.uuidString
|
|
env["CMUX_TAB_ID"] = tabId.uuidString
|
|
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
|
|
|
|
let claudeHooksEnabled = UserDefaults.standard.object(forKey: "claudeCodeHooksEnabled") as? Bool ?? true
|
|
if !claudeHooksEnabled {
|
|
env["CMUX_CLAUDE_HOOKS_DISABLED"] = "1"
|
|
}
|
|
|
|
if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path {
|
|
let currentPath = env["PATH"]
|
|
?? getenv("PATH").map { String(cString: $0) }
|
|
?? ProcessInfo.processInfo.environment["PATH"]
|
|
?? ""
|
|
if !currentPath.split(separator: ":").contains(Substring(cliBinPath)) {
|
|
let separator = currentPath.isEmpty ? "" : ":"
|
|
env["PATH"] = "\(cliBinPath)\(separator)\(currentPath)"
|
|
}
|
|
}
|
|
|
|
// Shell integration: inject ZDOTDIR wrapper for zsh shells.
|
|
let shellIntegrationEnabled = UserDefaults.standard.object(forKey: "sidebarShellIntegration") as? Bool ?? true
|
|
if shellIntegrationEnabled,
|
|
let integrationDir = Bundle.main.resourceURL?.appendingPathComponent("shell-integration").path {
|
|
env["CMUX_SHELL_INTEGRATION"] = "1"
|
|
env["CMUX_SHELL_INTEGRATION_DIR"] = integrationDir
|
|
|
|
let shell = (env["SHELL"]?.isEmpty == false ? env["SHELL"] : nil)
|
|
?? getenv("SHELL").map { String(cString: $0) }
|
|
?? ProcessInfo.processInfo.environment["SHELL"]
|
|
?? "/bin/zsh"
|
|
let shellName = URL(fileURLWithPath: shell).lastPathComponent
|
|
if shellName == "zsh" {
|
|
let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil)
|
|
?? getenv("ZDOTDIR").map { String(cString: $0) }
|
|
?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil)
|
|
|
|
if let candidateZdotdir, !candidateZdotdir.isEmpty {
|
|
var isGhosttyInjected = false
|
|
let ghosttyResources = (env["GHOSTTY_RESOURCES_DIR"]?.isEmpty == false ? env["GHOSTTY_RESOURCES_DIR"] : nil)
|
|
?? getenv("GHOSTTY_RESOURCES_DIR").map { String(cString: $0) }
|
|
?? (ProcessInfo.processInfo.environment["GHOSTTY_RESOURCES_DIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["GHOSTTY_RESOURCES_DIR"] : nil)
|
|
if let ghosttyResources {
|
|
let ghosttyZdotdir = URL(fileURLWithPath: ghosttyResources)
|
|
.appendingPathComponent("shell-integration/zsh").path
|
|
isGhosttyInjected = (candidateZdotdir == ghosttyZdotdir)
|
|
}
|
|
if !isGhosttyInjected {
|
|
env["CMUX_ZSH_ZDOTDIR"] = candidateZdotdir
|
|
}
|
|
}
|
|
|
|
env["ZDOTDIR"] = integrationDir
|
|
}
|
|
}
|
|
|
|
if !env.isEmpty {
|
|
envVars.reserveCapacity(env.count)
|
|
envStorage.reserveCapacity(env.count)
|
|
for (key, value) in env {
|
|
guard let keyPtr = strdup(key), let valuePtr = strdup(value) else { continue }
|
|
envStorage.append((keyPtr, valuePtr))
|
|
envVars.append(ghostty_env_var_s(key: keyPtr, value: valuePtr))
|
|
}
|
|
}
|
|
|
|
let createSurface = { [self] in
|
|
if !envVars.isEmpty {
|
|
let envVarsCount = envVars.count
|
|
envVars.withUnsafeMutableBufferPointer { buffer in
|
|
surfaceConfig.env_vars = buffer.baseAddress
|
|
surfaceConfig.env_var_count = envVarsCount
|
|
self.surface = ghostty_surface_new(app, &surfaceConfig)
|
|
}
|
|
} else {
|
|
self.surface = ghostty_surface_new(app, &surfaceConfig)
|
|
}
|
|
}
|
|
|
|
if let workingDirectory, !workingDirectory.isEmpty {
|
|
workingDirectory.withCString { cWorkingDir in
|
|
surfaceConfig.working_directory = cWorkingDir
|
|
createSurface()
|
|
}
|
|
} else {
|
|
createSurface()
|
|
}
|
|
|
|
if surface == nil {
|
|
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
|
|
}
|
|
|
|
// 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(surface, displayID)
|
|
}
|
|
|
|
ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y)
|
|
let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero))
|
|
let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero))
|
|
if wpx > 0, hpx > 0 {
|
|
ghostty_surface_set_size(surface, wpx, hpx)
|
|
lastPixelWidth = wpx
|
|
lastPixelHeight = hpx
|
|
lastXScale = scaleFactors.x
|
|
lastYScale = scaleFactors.y
|
|
}
|
|
}
|
|
|
|
func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) {
|
|
guard let surface = surface else { return }
|
|
_ = layerScale
|
|
|
|
let wpx = UInt32((width * xScale).rounded(.toNearestOrAwayFromZero))
|
|
let hpx = UInt32((height * yScale).rounded(.toNearestOrAwayFromZero))
|
|
guard wpx > 0, hpx > 0 else { return }
|
|
|
|
let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale)
|
|
let sizeChanged = wpx != lastPixelWidth || hpx != lastPixelHeight
|
|
|
|
#if DEBUG
|
|
Self.sizeLog("updateSize-call surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) changed=\((scaleChanged || sizeChanged) ? 1 : 0)")
|
|
#endif
|
|
|
|
guard scaleChanged || sizeChanged else { return }
|
|
|
|
#if DEBUG
|
|
if sizeChanged {
|
|
let win = attachedView?.window != nil ? "1" : "0"
|
|
Self.sizeLog("updateSize surface=\(id.uuidString.prefix(8)) size=\(wpx)x\(hpx) prev=\(lastPixelWidth)x\(lastPixelHeight) win=\(win)")
|
|
}
|
|
#endif
|
|
|
|
if scaleChanged {
|
|
ghostty_surface_set_content_scale(surface, xScale, yScale)
|
|
lastXScale = xScale
|
|
lastYScale = yScale
|
|
}
|
|
|
|
if sizeChanged {
|
|
ghostty_surface_set_size(surface, wpx, hpx)
|
|
lastPixelWidth = wpx
|
|
lastPixelHeight = hpx
|
|
}
|
|
|
|
// Let Ghostty continue rendering on its own wakeups for steady-state frames.
|
|
}
|
|
|
|
/// Force a full size recalculation and surface redraw.
|
|
func forceRefresh() {
|
|
let 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)"
|
|
} else {
|
|
viewState = "NO_ATTACHED_VIEW"
|
|
}
|
|
#if DEBUG
|
|
let ts = ISO8601DateFormatter().string(from: Date())
|
|
let line = "[\(ts)] forceRefresh: \(id) \(viewState)\n"
|
|
let logPath = "/tmp/cmux-refresh-debug.log"
|
|
if let handle = FileHandle(forWritingAtPath: logPath) {
|
|
handle.seekToEndOfFile()
|
|
handle.write(line.data(using: .utf8)!)
|
|
handle.closeFile()
|
|
} else {
|
|
FileManager.default.createFile(atPath: logPath, contents: line.data(using: .utf8))
|
|
}
|
|
#endif
|
|
guard let view = attachedView,
|
|
view.window != nil,
|
|
view.bounds.width > 0,
|
|
view.bounds.height > 0 else {
|
|
return
|
|
}
|
|
|
|
view.forceRefreshSurface()
|
|
ghostty_surface_refresh(surface)
|
|
}
|
|
|
|
func applyWindowBackgroundIfActive() {
|
|
surfaceView.applyWindowBackgroundIfActive()
|
|
}
|
|
|
|
func setFocus(_ focused: Bool) {
|
|
guard let surface = surface else { return }
|
|
ghostty_surface_set_focus(surface, focused)
|
|
|
|
// If we focus a surface while it is being rapidly reparented (closing splits, etc),
|
|
// Ghostty's CVDisplayLink can end up started before the display id is valid, leaving
|
|
// hasVsync() true but with no callbacks ("stuck-vsync-no-frames"). Reasserting the
|
|
// display id *after* focusing lets Ghostty restart the display link when needed.
|
|
if focused {
|
|
if let view = attachedView,
|
|
let displayID = (view.window?.screen ?? NSScreen.main)?.displayID,
|
|
displayID != 0 {
|
|
ghostty_surface_set_display_id(surface, displayID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func setOcclusion(_ visible: Bool) {
|
|
guard let surface = surface else { return }
|
|
ghostty_surface_set_occlusion(surface, visible)
|
|
}
|
|
|
|
func needsConfirmClose() -> Bool {
|
|
guard let surface = surface else { return false }
|
|
return ghostty_surface_needs_confirm_quit(surface)
|
|
}
|
|
|
|
func sendText(_ text: String) {
|
|
guard let surface = surface else { return }
|
|
guard let data = text.data(using: .utf8), !data.isEmpty else { return }
|
|
data.withUnsafeBytes { rawBuffer in
|
|
guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) else { return }
|
|
ghostty_surface_text(surface, baseAddress, UInt(rawBuffer.count))
|
|
}
|
|
}
|
|
|
|
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)))
|
|
}
|
|
}
|
|
|
|
func hasSelection() -> Bool {
|
|
guard let surface = surface else { return false }
|
|
return ghostty_surface_has_selection(surface)
|
|
}
|
|
|
|
deinit {
|
|
if let surface = surface {
|
|
ghostty_surface_free(surface)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Ghostty Surface View
|
|
|
|
class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|
private static let focusDebugEnabled: Bool = {
|
|
if ProcessInfo.processInfo.environment["CMUX_FOCUS_DEBUG"] == "1" {
|
|
return true
|
|
}
|
|
return UserDefaults.standard.bool(forKey: "cmuxFocusDebug")
|
|
}()
|
|
private static let dropTypes: Set<NSPasteboard.PasteboardType> = [
|
|
.string,
|
|
.fileURL,
|
|
.URL
|
|
]
|
|
private static let shellEscapeCharacters = "\\ ()[]{}<>\"'`!#$&;|*?\t"
|
|
|
|
fileprivate static func focusLog(_ message: String) {
|
|
guard focusDebugEnabled else { return }
|
|
FocusLogStore.shared.append(message)
|
|
NSLog("[FOCUSDBG] %@", message)
|
|
}
|
|
|
|
weak var terminalSurface: TerminalSurface?
|
|
var scrollbar: GhosttyScrollbar?
|
|
var cellSize: CGSize = .zero
|
|
var desiredFocus: Bool = false
|
|
var suppressingReparentFocus: Bool = false
|
|
var tabId: UUID?
|
|
var onFocus: (() -> Void)?
|
|
var onTriggerFlash: (() -> Void)?
|
|
var backgroundColor: NSColor?
|
|
private var keySequence: [ghostty_input_trigger_s] = []
|
|
private var keyTables: [String] = []
|
|
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?
|
|
|
|
// 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.
|
|
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)
|
|
layer.backgroundColor = color.cgColor
|
|
layer.isOpaque = color.alphaComponent >= 1.0
|
|
CATransaction.commit()
|
|
}
|
|
terminalSurface?.hostedView.setBackgroundColor(color)
|
|
}
|
|
|
|
func applyWindowBackgroundIfActive() {
|
|
guard let window else { return }
|
|
if let tabId, let selectedId = AppDelegate.shared?.tabManager?.selectedTabId, tabId != selectedId {
|
|
return
|
|
}
|
|
applySurfaceBackground()
|
|
let color = effectiveBackgroundColor()
|
|
if cmuxShouldUseTransparentBackgroundWindow() {
|
|
window.backgroundColor = .clear
|
|
window.isOpaque = false
|
|
} else {
|
|
window.backgroundColor = color
|
|
window.isOpaque = color.alphaComponent >= 1.0
|
|
}
|
|
if GhosttyApp.shared.backgroundLogEnabled {
|
|
GhosttyApp.shared.logBackground("applied window background tab=\(tabId?.uuidString ?? "unknown") color=\(color) 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) {
|
|
terminalSurface = surface
|
|
tabId = surface.tabId
|
|
surface.attachToView(self)
|
|
updateSurfaceSize()
|
|
applySurfaceBackground()
|
|
}
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
if let windowObserver {
|
|
NotificationCenter.default.removeObserver(windowObserver)
|
|
self.windowObserver = nil
|
|
}
|
|
guard let window else { return }
|
|
|
|
// If the surface creation was deferred while detached, create/attach it now.
|
|
terminalSurface?.attachToView(self)
|
|
|
|
windowObserver = NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didChangeScreenNotification,
|
|
object: window,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
self?.windowDidChangeScreen(notification)
|
|
}
|
|
|
|
if let surface = terminalSurface?.surface,
|
|
let displayID = window.screen?.displayID,
|
|
displayID != 0 {
|
|
ghostty_surface_set_display_id(surface, displayID)
|
|
}
|
|
|
|
// Recompute from current bounds after layout, not stale pending sizes.
|
|
superview?.layoutSubtreeIfNeeded()
|
|
layoutSubtreeIfNeeded()
|
|
let targetSize: CGSize = {
|
|
let current = bounds.size
|
|
if current.width > 0, current.height > 0 {
|
|
return current
|
|
}
|
|
return pendingSurfaceSize ?? current
|
|
}()
|
|
updateSurfaceSize(size: targetSize)
|
|
applySurfaceBackground()
|
|
applyWindowBackgroundIfActive()
|
|
}
|
|
|
|
fileprivate func updateOcclusionState() {
|
|
// Intentionally no-op: we don't drive libghostty occlusion from AppKit occlusion state.
|
|
// This avoids transient clears during reparenting and keeps rendering logic minimal.
|
|
}
|
|
|
|
override func viewDidChangeBackingProperties() {
|
|
super.viewDidChangeBackingProperties()
|
|
if let window {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
layer?.contentsScale = window.backingScaleFactor
|
|
CATransaction.commit()
|
|
}
|
|
updateSurfaceSize()
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
updateSurfaceSize()
|
|
}
|
|
|
|
override var isOpaque: Bool { false }
|
|
|
|
private func updateSurfaceSize(size: CGSize? = nil) {
|
|
guard let terminalSurface = terminalSurface else { return }
|
|
let size = size ?? bounds.size
|
|
guard size.width > 0 && size.height > 0 else { return }
|
|
pendingSurfaceSize = size
|
|
guard let window else { return }
|
|
|
|
// First principles: derive pixel size from AppKit's backing conversion for the current
|
|
// window/screen. Avoid updating Ghostty while detached from a window.
|
|
let backingSize = convertToBacking(NSRect(origin: .zero, size: size)).size
|
|
guard backingSize.width > 0, backingSize.height > 0 else { return }
|
|
let xScale = backingSize.width / size.width
|
|
let yScale = backingSize.height / size.height
|
|
let layerScale = max(1.0, window.backingScaleFactor)
|
|
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
layer?.contentsScale = layerScale
|
|
if let metalLayer = layer as? CAMetalLayer {
|
|
metalLayer.drawableSize = backingSize
|
|
}
|
|
CATransaction.commit()
|
|
|
|
terminalSurface.updateSize(
|
|
width: size.width,
|
|
height: size.height,
|
|
xScale: xScale,
|
|
yScale: yScale,
|
|
layerScale: layerScale
|
|
)
|
|
pendingSurfaceSize = nil
|
|
}
|
|
|
|
fileprivate func pushTargetSurfaceSize(_ size: CGSize) {
|
|
updateSurfaceSize(size: size)
|
|
}
|
|
|
|
/// Force a full size recalculation and Metal layer refresh.
|
|
/// Resets cached metrics so updateSurfaceSize() re-runs unconditionally.
|
|
func forceRefreshSurface() {
|
|
updateSurfaceSize()
|
|
}
|
|
|
|
private func nearlyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool {
|
|
abs(lhs - rhs) <= epsilon
|
|
}
|
|
|
|
func expectedPixelSize(for pointsSize: CGSize) -> CGSize {
|
|
let backing = convertToBacking(NSRect(origin: .zero, size: pointsSize)).size
|
|
if backing.width > 0, backing.height > 0 {
|
|
return backing
|
|
}
|
|
let scale = max(1.0, window?.backingScaleFactor ?? layer?.contentsScale ?? 1.0)
|
|
return CGSize(width: pointsSize.width * scale, height: pointsSize.height * scale)
|
|
}
|
|
|
|
// Convenience accessor for the ghostty surface
|
|
private var surface: ghostty_surface_t? {
|
|
terminalSurface?.surface
|
|
}
|
|
|
|
@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)
|
|
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)))
|
|
}
|
|
}
|
|
|
|
// MARK: - Input Handling
|
|
|
|
@IBAction func copy(_ sender: Any?) {
|
|
_ = performBindingAction("copy_to_clipboard")
|
|
}
|
|
|
|
@IBAction func paste(_ sender: Any?) {
|
|
_ = performBindingAction("paste_from_clipboard")
|
|
}
|
|
|
|
@IBAction func pasteAsPlainText(_ sender: Any?) {
|
|
_ = performBindingAction("paste_from_clipboard")
|
|
}
|
|
|
|
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(_:)), #selector(pasteAsPlainText(_:)):
|
|
return GhosttyPasteboardHelper.hasString(for: GHOSTTY_CLIPBOARD_STANDARD)
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
override var acceptsFirstResponder: Bool { true }
|
|
|
|
override func becomeFirstResponder() -> Bool {
|
|
let result = super.becomeFirstResponder()
|
|
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.
|
|
if isVisibleInUI {
|
|
onFocus?()
|
|
}
|
|
}
|
|
if result, 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
|
|
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?
|
|
|
|
// Prevents NSBeep for unimplemented actions from interpretKeyEvents
|
|
override func doCommand(by selector: Selector) {
|
|
// Intentionally empty - prevents system beep on unhandled key commands
|
|
}
|
|
|
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
|
guard event.type == .keyDown else { return false }
|
|
guard let fr = window?.firstResponder as? NSView,
|
|
fr === self || fr.isDescendant(of: self) else { return false }
|
|
guard let surface = ensureSurfaceReadyForInput() else { return false }
|
|
|
|
#if DEBUG
|
|
cmuxWriteChildExitProbe(
|
|
[
|
|
"probePerformCharsHex": cmuxScalarHex(event.characters),
|
|
"probePerformCharsIgnoringHex": cmuxScalarHex(event.charactersIgnoringModifiers),
|
|
"probePerformKeyCode": String(event.keyCode),
|
|
"probePerformModsRaw": String(event.modifierFlags.rawValue),
|
|
"probePerformSurfaceId": terminalSurface?.id.uuidString ?? "",
|
|
],
|
|
increments: ["probePerformKeyEquivalentCount": 1]
|
|
)
|
|
#endif
|
|
|
|
// Check if this event matches a Ghostty keybinding.
|
|
let bindingFlags: ghostty_binding_flags_e? = {
|
|
var keyEvent = ghosttyKeyEvent(for: event, surface: surface)
|
|
let text = event.characters ?? ""
|
|
var flags = ghostty_binding_flags_e(0)
|
|
let isBinding = text.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
return ghostty_surface_key_is_binding(surface, keyEvent, &flags)
|
|
}
|
|
return isBinding ? flags : nil
|
|
}()
|
|
|
|
if let bindingFlags {
|
|
let isConsumed = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_CONSUMED.rawValue) != 0
|
|
let isAll = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_ALL.rawValue) != 0
|
|
let isPerformable = (bindingFlags.rawValue & GHOSTTY_BINDING_FLAGS_PERFORMABLE.rawValue) != 0
|
|
|
|
// If the binding is consumed and not meant for the menu, allow menu first.
|
|
if isConsumed && !isAll && !isPerformable && keySequence.isEmpty && keyTables.isEmpty {
|
|
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
keyDown(with: event)
|
|
return true
|
|
}
|
|
|
|
let equivalent: String
|
|
switch event.charactersIgnoringModifiers {
|
|
case "\r":
|
|
// Pass Ctrl+Return through verbatim (prevent context menu equivalent).
|
|
guard event.modifierFlags.contains(.control) else { return false }
|
|
equivalent = "\r"
|
|
|
|
case "/":
|
|
// Treat Ctrl+/ as Ctrl+_ to avoid the system beep.
|
|
guard event.modifierFlags.contains(.control),
|
|
event.modifierFlags.isDisjoint(with: [.shift, .command, .option]) else {
|
|
return false
|
|
}
|
|
equivalent = "_"
|
|
|
|
default:
|
|
// Ignore synthetic events.
|
|
if event.timestamp == 0 {
|
|
return false
|
|
}
|
|
|
|
// Match AppKit key-equivalent routing for menu-style shortcuts (Command-modified).
|
|
// Control-only terminal input (e.g. Ctrl+D) should not participate in redispatch;
|
|
// it must flow through the normal keyDown path exactly once.
|
|
if !event.modifierFlags.contains(.command) {
|
|
lastPerformKeyEvent = nil
|
|
return false
|
|
}
|
|
|
|
if let lastPerformKeyEvent {
|
|
self.lastPerformKeyEvent = nil
|
|
if lastPerformKeyEvent == event.timestamp {
|
|
equivalent = event.characters ?? ""
|
|
break
|
|
}
|
|
}
|
|
|
|
lastPerformKeyEvent = event.timestamp
|
|
return false
|
|
}
|
|
|
|
let finalEvent = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: event.locationInWindow,
|
|
modifierFlags: event.modifierFlags,
|
|
timestamp: event.timestamp,
|
|
windowNumber: event.windowNumber,
|
|
context: nil,
|
|
characters: equivalent,
|
|
charactersIgnoringModifiers: equivalent,
|
|
isARepeat: event.isARepeat,
|
|
keyCode: event.keyCode
|
|
)
|
|
|
|
if let finalEvent {
|
|
keyDown(with: finalEvent)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
guard let surface = ensureSurfaceReadyForInput() else {
|
|
super.keyDown(with: event)
|
|
return
|
|
}
|
|
|
|
#if DEBUG
|
|
cmuxWriteChildExitProbe(
|
|
[
|
|
"probeKeyDownCharsHex": cmuxScalarHex(event.characters),
|
|
"probeKeyDownCharsIgnoringHex": cmuxScalarHex(event.charactersIgnoringModifiers),
|
|
"probeKeyDownKeyCode": String(event.keyCode),
|
|
"probeKeyDownModsRaw": String(event.modifierFlags.rawValue),
|
|
"probeKeyDownSurfaceId": terminalSurface?.id.uuidString ?? "",
|
|
],
|
|
increments: ["probeKeyDownCount": 1]
|
|
)
|
|
#endif
|
|
|
|
// Fast path for control-modified terminal input (for example Ctrl+D).
|
|
//
|
|
// These keys are terminal control input, not text composition, so we bypass
|
|
// AppKit text interpretation and send a single deterministic Ghostty key event.
|
|
// This avoids intermittent drops after rapid split close/reparent transitions.
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
if flags.contains(.control) && !flags.contains(.command) && !flags.contains(.option) {
|
|
ghostty_surface_set_focus(surface, true)
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = UInt32(event.keyCode)
|
|
keyEvent.mods = modsFromEvent(event)
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.composing = false
|
|
keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event)
|
|
|
|
let text = (event.charactersIgnoringModifiers ?? event.characters ?? "")
|
|
if text.isEmpty {
|
|
keyEvent.text = nil
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
} else {
|
|
text.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
}
|
|
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 }
|
|
|
|
// Let the input system handle the event (for IME, dead keys, etc.)
|
|
interpretKeyEvents([translationEvent])
|
|
|
|
// 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.composing = markedText.length > 0
|
|
keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event)
|
|
|
|
// Use accumulated text from insertText (for IME), or compute text for key
|
|
if let accumulated = keyTextAccumulator, !accumulated.isEmpty {
|
|
for text in accumulated {
|
|
if shouldSendText(text) {
|
|
text.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
} else {
|
|
keyEvent.text = nil
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
}
|
|
} else {
|
|
// Get the appropriate text for this key event
|
|
// For control characters, this returns the unmodified character
|
|
// so Ghostty's KeyEncoder can handle ctrl encoding
|
|
if let text = textForKeyEvent(translationEvent) {
|
|
if shouldSendText(text) {
|
|
text.withCString { ptr in
|
|
keyEvent.text = ptr
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
} else {
|
|
keyEvent.text = nil
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
} else {
|
|
keyEvent.text = nil
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
}
|
|
|
|
// Rendering is driven by Ghostty's wakeups/renderer.
|
|
}
|
|
|
|
override func keyUp(with event: NSEvent) {
|
|
guard let surface = surface else {
|
|
super.keyUp(with: event)
|
|
return
|
|
}
|
|
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_RELEASE
|
|
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)
|
|
}
|
|
|
|
override func flagsChanged(with event: NSEvent) {
|
|
guard let surface = surface else {
|
|
super.flagsChanged(with: event)
|
|
return
|
|
}
|
|
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = UInt32(event.keyCode)
|
|
keyEvent.mods = modsFromEvent(event)
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.text = nil
|
|
keyEvent.composing = false
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
|
|
private func modsFromEvent(_ event: NSEvent) -> ghostty_input_mods_e {
|
|
var mods = GHOSTTY_MODS_NONE.rawValue
|
|
if event.modifierFlags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
|
|
if event.modifierFlags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue }
|
|
if event.modifierFlags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue }
|
|
if event.modifierFlags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue }
|
|
return ghostty_input_mods_e(rawValue: mods)
|
|
}
|
|
|
|
/// Consumed mods are modifiers that were used for text translation.
|
|
/// Control and Command never contribute to text translation, so they
|
|
/// should be excluded from consumed_mods.
|
|
private func consumedModsFromFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
|
|
var mods = GHOSTTY_MODS_NONE.rawValue
|
|
// Only include Shift and Option as potentially consumed
|
|
// Control and Command are never consumed for text translation
|
|
if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
|
|
if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue }
|
|
return ghostty_input_mods_e(rawValue: mods)
|
|
}
|
|
|
|
/// Get the characters for a key event with control character handling.
|
|
/// When control is pressed, we get the character without the control modifier
|
|
/// so Ghostty's KeyEncoder can apply its own control character encoding.
|
|
private func textForKeyEvent(_ event: NSEvent) -> String? {
|
|
guard let chars = event.characters, !chars.isEmpty else { return nil }
|
|
|
|
if chars.count == 1, let scalar = chars.unicodeScalars.first {
|
|
// If we have a single control character, return the character without
|
|
// the control modifier so Ghostty's KeyEncoder can handle it.
|
|
if scalar.value < 0x20 {
|
|
return event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.control))
|
|
}
|
|
// 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 {
|
|
guard let chars = event.characters(byApplyingModifiers: []),
|
|
let scalar = chars.unicodeScalars.first else { return 0 }
|
|
return scalar.value
|
|
}
|
|
|
|
private func shouldSendText(_ text: String) -> Bool {
|
|
guard let first = text.utf8.first else { return false }
|
|
return first >= 0x20
|
|
}
|
|
|
|
private func ghosttyKeyEvent(for event: NSEvent, surface: ghostty_surface_t) -> ghostty_input_key_s {
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = UInt32(event.keyCode)
|
|
keyEvent.mods = modsFromEvent(event)
|
|
|
|
// Translate mods to respect Ghostty config (e.g., macos-option-as-alt).
|
|
let translationModsGhostty = ghostty_surface_key_translation_mods(surface, modsFromEvent(event))
|
|
var translationMods = event.modifierFlags
|
|
for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] {
|
|
let hasFlag: Bool
|
|
switch flag {
|
|
case .shift:
|
|
hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SHIFT.rawValue) != 0
|
|
case .control:
|
|
hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_CTRL.rawValue) != 0
|
|
case .option:
|
|
hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_ALT.rawValue) != 0
|
|
case .command:
|
|
hasFlag = (translationModsGhostty.rawValue & GHOSTTY_MODS_SUPER.rawValue) != 0
|
|
default:
|
|
hasFlag = translationMods.contains(flag)
|
|
}
|
|
if hasFlag {
|
|
translationMods.insert(flag)
|
|
} else {
|
|
translationMods.remove(flag)
|
|
}
|
|
}
|
|
|
|
keyEvent.consumed_mods = consumedModsFromFlags(translationMods)
|
|
keyEvent.text = nil
|
|
keyEvent.composing = false
|
|
keyEvent.unshifted_codepoint = unshiftedCodepointFromEvent(event)
|
|
return keyEvent
|
|
}
|
|
|
|
func updateKeySequence(_ action: ghostty_action_key_sequence_s) {
|
|
if action.active {
|
|
keySequence.append(action.trigger)
|
|
} else {
|
|
keySequence.removeAll()
|
|
}
|
|
}
|
|
|
|
func updateKeyTable(_ action: ghostty_action_key_table_s) {
|
|
switch action.tag {
|
|
case GHOSTTY_KEY_TABLE_ACTIVATE:
|
|
let namePtr = action.value.activate.name
|
|
let nameLen = Int(action.value.activate.len)
|
|
if let namePtr, nameLen > 0 {
|
|
let data = Data(bytes: namePtr, count: nameLen)
|
|
if let name = String(data: data, encoding: .utf8) {
|
|
keyTables.append(name)
|
|
}
|
|
}
|
|
case GHOSTTY_KEY_TABLE_DEACTIVATE:
|
|
_ = keyTables.popLast()
|
|
case GHOSTTY_KEY_TABLE_DEACTIVATE_ALL:
|
|
keyTables.removeAll()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Mouse Handling
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
#if DEBUG
|
|
dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
|
#endif
|
|
window?.makeFirstResponder(self)
|
|
guard let surface = surface else { return }
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
|
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
|
|
}
|
|
|
|
override func mouseUp(with event: NSEvent) {
|
|
guard let surface = surface else { return }
|
|
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
|
|
}
|
|
|
|
override func rightMouseDown(with event: NSEvent) {
|
|
guard let surface = surface else { return }
|
|
if !ghostty_surface_mouse_captured(surface) {
|
|
super.rightMouseDown(with: event)
|
|
return
|
|
}
|
|
|
|
window?.makeFirstResponder(self)
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
|
|
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event))
|
|
}
|
|
|
|
override func rightMouseUp(with event: NSEvent) {
|
|
guard let surface = surface else { return }
|
|
if !ghostty_surface_mouse_captured(surface) {
|
|
super.rightMouseUp(with: event)
|
|
return
|
|
}
|
|
|
|
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, modsFromEvent(event))
|
|
}
|
|
|
|
override func 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
|
|
return menu
|
|
}
|
|
|
|
@objc private func triggerFlash(_ sender: Any?) {
|
|
onTriggerFlash?()
|
|
}
|
|
|
|
override func mouseMoved(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 mouseEntered(with event: NSEvent) {
|
|
super.mouseEntered(with: event)
|
|
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 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 let eventMonitor {
|
|
NSEvent.removeMonitor(eventMonitor)
|
|
}
|
|
if let windowObserver {
|
|
NotificationCenter.default.removeObserver(windowObserver)
|
|
}
|
|
terminalSurface = nil
|
|
}
|
|
|
|
override func updateTrackingAreas() {
|
|
super.updateTrackingAreas()
|
|
|
|
if let trackingArea {
|
|
removeTrackingArea(trackingArea)
|
|
}
|
|
|
|
trackingArea = NSTrackingArea(
|
|
rect: bounds,
|
|
options: [
|
|
.mouseEnteredAndExited,
|
|
.mouseMoved,
|
|
.inVisibleRect,
|
|
.activeAlways,
|
|
],
|
|
owner: self,
|
|
userInfo: nil
|
|
)
|
|
|
|
if let trackingArea {
|
|
addTrackingArea(trackingArea)
|
|
}
|
|
}
|
|
|
|
private func windowDidChangeScreen(_ notification: Notification) {
|
|
guard let window else { return }
|
|
guard let object = notification.object as? NSWindow, window == object else { return }
|
|
guard let screen = window.screen else { return }
|
|
guard let surface = terminalSurface?.surface else { return }
|
|
|
|
if let displayID = screen.displayID,
|
|
displayID != 0 {
|
|
ghostty_surface_set_display_id(surface, displayID)
|
|
}
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.viewDidChangeBackingProperties()
|
|
}
|
|
}
|
|
|
|
private 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 }
|
|
if let window {
|
|
window.makeFirstResponder(self)
|
|
}
|
|
sendTextToSurface(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 {
|
|
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"
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// MARK: - Scroll View Wrapper (Ghostty-style scrollbar)
|
|
|
|
private final class GhosttyScrollView: NSScrollView {
|
|
weak var surfaceView: GhosttyNSView?
|
|
|
|
override func scrollWheel(with event: NSEvent) {
|
|
guard let surfaceView else {
|
|
super.scrollWheel(with: event)
|
|
return
|
|
}
|
|
|
|
if let surface = surfaceView.terminalSurface?.surface,
|
|
ghostty_surface_mouse_captured(surface) {
|
|
GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: mouseCaptured -> surface scroll")
|
|
if window?.firstResponder !== surfaceView {
|
|
window?.makeFirstResponder(surfaceView)
|
|
}
|
|
surfaceView.scrollWheel(with: event)
|
|
} else {
|
|
GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: super scroll")
|
|
super.scrollWheel(with: event)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class GhosttyFlashOverlayView: NSView {
|
|
override var acceptsFirstResponder: Bool { false }
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
nil
|
|
}
|
|
}
|
|
|
|
final class GhosttySurfaceScrollView: NSView {
|
|
private let backgroundView: NSView
|
|
private let scrollView: GhosttyScrollView
|
|
private let documentView: NSView
|
|
private let surfaceView: GhosttyNSView
|
|
private let flashOverlayView: GhosttyFlashOverlayView
|
|
private let flashLayer: CAShapeLayer
|
|
private var observers: [NSObjectProtocol] = []
|
|
private var windowObservers: [NSObjectProtocol] = []
|
|
private var isLiveScrolling = false
|
|
private var lastSentRow: Int?
|
|
private var isActive = true
|
|
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
|
|
#if DEBUG
|
|
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 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)
|
|
}
|
|
#endif
|
|
|
|
init(surfaceView: GhosttyNSView) {
|
|
self.surfaceView = surfaceView
|
|
backgroundView = NSView(frame: .zero)
|
|
scrollView = GhosttyScrollView()
|
|
flashOverlayView = GhosttyFlashOverlayView(frame: .zero)
|
|
flashLayer = CAShapeLayer()
|
|
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)
|
|
|
|
backgroundView.wantsLayer = true
|
|
backgroundView.layer?.backgroundColor =
|
|
GhosttyApp.shared.defaultBackgroundColor
|
|
.withAlphaComponent(GhosttyApp.shared.defaultBackgroundOpacity)
|
|
.cgColor
|
|
addSubview(backgroundView)
|
|
addSubview(scrollView)
|
|
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)
|
|
|
|
scrollView.contentView.postsBoundsChangedNotifications = true
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: NSView.boundsDidChangeNotification,
|
|
object: scrollView.contentView,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.handleScrollChange()
|
|
})
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: NSScrollView.willStartLiveScrollNotification,
|
|
object: scrollView,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.isLiveScrolling = true
|
|
})
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: NSScrollView.didEndLiveScrollNotification,
|
|
object: scrollView,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.isLiveScrolling = false
|
|
})
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: NSScrollView.didLiveScrollNotification,
|
|
object: scrollView,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.handleLiveScroll()
|
|
})
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .ghosttyDidUpdateScrollbar,
|
|
object: surfaceView,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
self?.handleScrollbarUpdate(notification)
|
|
})
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .ghosttyDidUpdateCellSize,
|
|
object: surfaceView,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.synchronizeScrollView()
|
|
})
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) not implemented")
|
|
}
|
|
|
|
deinit {
|
|
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
cancelFocusRequest()
|
|
}
|
|
|
|
override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero }
|
|
|
|
// Avoid stealing focus on scroll; focus is managed explicitly by the surface view.
|
|
override var acceptsFirstResponder: Bool { false }
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
synchronizeGeometryAndContent()
|
|
}
|
|
|
|
/// Reconcile AppKit geometry with ghostty surface geometry synchronously.
|
|
/// Used after split topology mutations (close/split) to prevent a stale one-frame
|
|
/// IOSurface size from being presented after pane expansion.
|
|
func reconcileGeometryNow() {
|
|
guard Thread.isMainThread else {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.reconcileGeometryNow()
|
|
}
|
|
return
|
|
}
|
|
|
|
synchronizeGeometryAndContent()
|
|
}
|
|
|
|
private func synchronizeGeometryAndContent() {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
defer { CATransaction.commit() }
|
|
|
|
backgroundView.frame = bounds
|
|
scrollView.frame = bounds
|
|
let targetSize = scrollView.bounds.size
|
|
surfaceView.frame.size = targetSize
|
|
surfaceView.pushTargetSurfaceSize(targetSize)
|
|
documentView.frame.size.width = scrollView.bounds.width
|
|
flashOverlayView.frame = bounds
|
|
updateFlashPath()
|
|
synchronizeScrollView()
|
|
synchronizeSurfaceView()
|
|
}
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
windowObservers.removeAll()
|
|
guard let window else { return }
|
|
windowObservers.append(NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didBecomeKeyNotification,
|
|
object: window,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.applyFirstResponderIfNeeded()
|
|
})
|
|
windowObservers.append(NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didResignKeyNotification,
|
|
object: window,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
// No-op: focus is driven by first-responder changes.
|
|
_ = self
|
|
})
|
|
if window.isKeyWindow { applyFirstResponderIfNeeded() }
|
|
}
|
|
|
|
func attachSurface(_ terminalSurface: TerminalSurface) {
|
|
surfaceView.attachSurface(terminalSurface)
|
|
}
|
|
|
|
func setFocusHandler(_ handler: (() -> Void)?) {
|
|
surfaceView.onFocus = handler
|
|
}
|
|
|
|
func setTriggerFlashHandler(_ handler: (() -> Void)?) {
|
|
surfaceView.onTriggerFlash = handler
|
|
}
|
|
|
|
func setBackgroundColor(_ color: NSColor) {
|
|
guard let layer = backgroundView.layer else { return }
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
layer.backgroundColor = color.cgColor
|
|
CATransaction.commit()
|
|
}
|
|
|
|
func triggerFlash() {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
#if DEBUG
|
|
if let surfaceId = self.surfaceView.terminalSurface?.id {
|
|
Self.recordFlash(for: surfaceId)
|
|
}
|
|
#endif
|
|
self.updateFlashPath()
|
|
self.flashLayer.removeAllAnimations()
|
|
self.flashLayer.opacity = 0
|
|
let animation = CAKeyframeAnimation(keyPath: "opacity")
|
|
animation.values = [0, 1, 0, 1, 0]
|
|
animation.keyTimes = [0, 0.25, 0.5, 0.75, 1]
|
|
animation.duration = 0.9
|
|
animation.timingFunctions = [
|
|
CAMediaTimingFunction(name: .easeOut),
|
|
CAMediaTimingFunction(name: .easeIn),
|
|
CAMediaTimingFunction(name: .easeOut),
|
|
CAMediaTimingFunction(name: .easeIn)
|
|
]
|
|
self.flashLayer.add(animation, forKey: "cmux.flash")
|
|
}
|
|
}
|
|
|
|
func setVisibleInUI(_ visible: Bool) {
|
|
surfaceView.setVisibleInUI(visible)
|
|
if !visible {
|
|
// If we were focused, yield first responder.
|
|
if let window, let fr = window.firstResponder as? NSView,
|
|
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
|
window.makeFirstResponder(nil)
|
|
}
|
|
} else {
|
|
applyFirstResponderIfNeeded()
|
|
}
|
|
}
|
|
|
|
func setActive(_ active: Bool) {
|
|
isActive = active
|
|
if active {
|
|
applyFirstResponderIfNeeded()
|
|
} else if let window,
|
|
let fr = window.firstResponder as? NSView,
|
|
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
|
window.makeFirstResponder(nil)
|
|
}
|
|
}
|
|
|
|
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
|
|
#if DEBUG
|
|
dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
|
#endif
|
|
let work = { [weak self] in
|
|
guard let self else { return }
|
|
guard let window = self.window else { return }
|
|
if let previous, previous !== self {
|
|
_ = previous.surfaceView.resignFirstResponder()
|
|
}
|
|
window.makeFirstResponder(self.surfaceView)
|
|
}
|
|
|
|
if let delay, delay > 0 {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { work() }
|
|
} else {
|
|
if Thread.isMainThread {
|
|
work()
|
|
} else {
|
|
DispatchQueue.main.async { work() }
|
|
}
|
|
}
|
|
}
|
|
|
|
#if DEBUG
|
|
@discardableResult
|
|
func debugSimulateFileDrop(paths: [String]) -> Bool {
|
|
surfaceView.debugSimulateFileDrop(paths: paths)
|
|
}
|
|
|
|
func debugRegisteredDropTypes() -> [String] {
|
|
surfaceView.debugRegisteredDropTypes()
|
|
}
|
|
|
|
#endif
|
|
|
|
#if DEBUG
|
|
/// 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() -> 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: [.control],
|
|
timestamp: timestamp,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
characters: "\u{04}",
|
|
charactersIgnoringModifiers: "d",
|
|
isARepeat: false,
|
|
keyCode: 2
|
|
) else { return false }
|
|
|
|
guard let keyUp = NSEvent.keyEvent(
|
|
with: .keyUp,
|
|
location: .zero,
|
|
modifierFlags: [.control],
|
|
timestamp: timestamp + 0.001,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
characters: "\u{04}",
|
|
charactersIgnoringModifiers: "d",
|
|
isARepeat: false,
|
|
keyCode: 2
|
|
) else { return false }
|
|
|
|
surfaceView.keyDown(with: keyDown)
|
|
surfaceView.keyUp(with: keyUp)
|
|
return true
|
|
}
|
|
#endif
|
|
|
|
func ensureFocus(for tabId: UUID, surfaceId: UUID, attemptsRemaining: Int = 3) {
|
|
func retry() {
|
|
guard attemptsRemaining > 0 else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
|
|
self?.ensureFocus(for: tabId, surfaceId: surfaceId, attemptsRemaining: attemptsRemaining - 1)
|
|
}
|
|
}
|
|
|
|
guard isActive else { return }
|
|
guard surfaceView.terminalSurface?.searchState == nil else { return }
|
|
guard let window else { return }
|
|
guard surfaceView.isVisibleInUI else {
|
|
retry()
|
|
return
|
|
}
|
|
|
|
guard let delegate = AppDelegate.shared,
|
|
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
|
|
tabManager.selectedTabId == tabId else {
|
|
retry()
|
|
return
|
|
}
|
|
|
|
guard let tab = tabManager.tabs.first(where: { $0.id == tabId }),
|
|
let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId),
|
|
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
|
|
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
|
|
}) else {
|
|
retry()
|
|
return
|
|
}
|
|
|
|
guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface,
|
|
tab.bonsplitController.focusedPaneId == paneId else {
|
|
retry()
|
|
return
|
|
}
|
|
|
|
if let fr = window.firstResponder as? NSView,
|
|
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
|
return
|
|
}
|
|
|
|
if !window.isKeyWindow {
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
_ = window.makeFirstResponder(surfaceView)
|
|
|
|
if !isSurfaceViewFirstResponder() {
|
|
retry()
|
|
}
|
|
}
|
|
|
|
/// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during
|
|
/// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles.
|
|
func suppressReparentFocus() {
|
|
surfaceView.suppressingReparentFocus = true
|
|
}
|
|
|
|
func clearSuppressReparentFocus() {
|
|
surfaceView.suppressingReparentFocus = false
|
|
}
|
|
|
|
/// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder.
|
|
/// This is stricter than checking `hostedView` descendants, since the scroll view can sometimes become
|
|
/// first responder transiently while focus is being applied.
|
|
func isSurfaceViewFirstResponder() -> Bool {
|
|
guard let window, let fr = window.firstResponder as? NSView else { return false }
|
|
return fr === surfaceView || fr.isDescendant(of: surfaceView)
|
|
}
|
|
|
|
private func applyFirstResponderIfNeeded() {
|
|
guard isActive else { return }
|
|
guard surfaceView.isVisibleInUI else { return }
|
|
guard surfaceView.terminalSurface?.searchState == nil else { return }
|
|
guard let window, window.isKeyWindow else { return }
|
|
if let fr = window.firstResponder as? NSView,
|
|
fr === surfaceView || fr.isDescendant(of: surfaceView) {
|
|
return
|
|
}
|
|
window.makeFirstResponder(surfaceView)
|
|
}
|
|
|
|
#if DEBUG
|
|
struct DebugRenderStats {
|
|
let drawCount: Int
|
|
let lastDrawTime: CFTimeInterval
|
|
let metalDrawableCount: Int
|
|
let metalLastDrawableTime: CFTimeInterval
|
|
let presentCount: Int
|
|
let lastPresentTime: CFTimeInterval
|
|
let layerClass: String
|
|
let layerContentsKey: String
|
|
let inWindow: Bool
|
|
let windowIsKey: Bool
|
|
let windowOcclusionVisible: Bool
|
|
let appIsActive: Bool
|
|
let isActive: Bool
|
|
let desiredFocus: Bool
|
|
let isFirstResponder: Bool
|
|
}
|
|
|
|
func debugRenderStats() -> DebugRenderStats {
|
|
let layerClass = surfaceView.layer.map { String(describing: type(of: $0)) } ?? "nil"
|
|
let (metalCount, metalLast) = (surfaceView.layer as? GhosttyMetalLayer)?.debugStats() ?? (0, 0)
|
|
let (drawCount, lastDraw): (Int, CFTimeInterval) = surfaceView.terminalSurface.map { terminalSurface in
|
|
Self.drawStats(for: terminalSurface.id)
|
|
} ?? (0, 0)
|
|
let (presentCount, lastPresent, contentsKey): (Int, CFTimeInterval, String) = surfaceView.terminalSurface.map { terminalSurface in
|
|
let stats = Self.updatePresentStats(surfaceId: terminalSurface.id, layer: surfaceView.layer)
|
|
return (stats.count, stats.last, stats.key)
|
|
} ?? (0, 0, Self.contentsKey(for: surfaceView.layer))
|
|
let inWindow = (window != nil)
|
|
let windowIsKey = window?.isKeyWindow ?? false
|
|
let windowOcclusionVisible = (window?.occlusionState.contains(.visible) ?? false) || (window?.isKeyWindow ?? false)
|
|
let appIsActive = NSApp.isActive
|
|
let fr = window?.firstResponder as? NSView
|
|
let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false)
|
|
return DebugRenderStats(
|
|
drawCount: drawCount,
|
|
lastDrawTime: lastDraw,
|
|
metalDrawableCount: metalCount,
|
|
metalLastDrawableTime: metalLast,
|
|
presentCount: presentCount,
|
|
lastPresentTime: lastPresent,
|
|
layerClass: layerClass,
|
|
layerContentsKey: contentsKey,
|
|
inWindow: inWindow,
|
|
windowIsKey: windowIsKey,
|
|
windowOcclusionVisible: windowOcclusionVisible,
|
|
appIsActive: appIsActive,
|
|
isActive: isActive,
|
|
desiredFocus: surfaceView.desiredFocus,
|
|
isFirstResponder: isFirstResponder
|
|
)
|
|
}
|
|
#endif
|
|
|
|
#if DEBUG
|
|
struct DebugFrameSample {
|
|
let sampleCount: Int
|
|
let uniqueQuantized: Int
|
|
let lumaStdDev: Double
|
|
let modeFraction: Double
|
|
let fingerprint: UInt64
|
|
let iosurfaceWidthPx: Int
|
|
let iosurfaceHeightPx: Int
|
|
let expectedWidthPx: Int
|
|
let expectedHeightPx: Int
|
|
let layerClass: String
|
|
let 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 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,
|
|
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,
|
|
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,
|
|
layerContentsKey: contentsKey
|
|
)
|
|
}
|
|
#endif
|
|
|
|
func cancelFocusRequest() {
|
|
// Intentionally no-op (no retry loops).
|
|
}
|
|
|
|
private func synchronizeSurfaceView() {
|
|
let visibleRect = scrollView.contentView.documentVisibleRect
|
|
surfaceView.frame.origin = visibleRect.origin
|
|
}
|
|
|
|
private func updateFlashPath() {
|
|
let inset: CGFloat = 2
|
|
let radius: CGFloat = 6
|
|
let bounds = flashOverlayView.bounds
|
|
flashLayer.frame = bounds
|
|
guard bounds.width > inset * 2, bounds.height > inset * 2 else {
|
|
flashLayer.path = nil
|
|
return
|
|
}
|
|
let rect = bounds.insetBy(dx: inset, dy: inset)
|
|
flashLayer.path = CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
|
}
|
|
|
|
private func synchronizeScrollView() {
|
|
documentView.frame.size.height = documentHeight()
|
|
|
|
if !isLiveScrolling {
|
|
let cellHeight = surfaceView.cellSize.height
|
|
if cellHeight > 0, let scrollbar = surfaceView.scrollbar {
|
|
let offsetY =
|
|
CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight
|
|
scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY))
|
|
lastSentRow = Int(scrollbar.offset)
|
|
}
|
|
}
|
|
|
|
scrollView.reflectScrolledClipView(scrollView.contentView)
|
|
}
|
|
|
|
private func handleScrollChange() {
|
|
synchronizeSurfaceView()
|
|
}
|
|
|
|
private func handleLiveScroll() {
|
|
let cellHeight = surfaceView.cellSize.height
|
|
guard cellHeight > 0 else { return }
|
|
|
|
let visibleRect = scrollView.contentView.documentVisibleRect
|
|
let documentHeight = documentView.frame.height
|
|
let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height
|
|
let row = Int(scrollOffset / cellHeight)
|
|
|
|
guard row != lastSentRow else { return }
|
|
lastSentRow = row
|
|
_ = surfaceView.performBindingAction("scroll_to_row:\(row)")
|
|
}
|
|
|
|
private func handleScrollbarUpdate(_ notification: Notification) {
|
|
guard let scrollbar = notification.userInfo?[GhosttyNotificationKey.scrollbar] as? GhosttyScrollbar else {
|
|
return
|
|
}
|
|
surfaceView.scrollbar = scrollbar
|
|
synchronizeScrollView()
|
|
}
|
|
|
|
private func documentHeight() -> CGFloat {
|
|
let contentHeight = scrollView.contentSize.height
|
|
let cellHeight = surfaceView.cellSize.height
|
|
if cellHeight > 0, let scrollbar = surfaceView.scrollbar {
|
|
let documentGridHeight = CGFloat(scrollbar.total) * cellHeight
|
|
let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight)
|
|
return documentGridHeight + padding
|
|
}
|
|
return contentHeight
|
|
}
|
|
}
|
|
|
|
// MARK: - NSTextInputClient
|
|
|
|
extension GhosttyNSView: NSTextInputClient {
|
|
fileprivate func sendTextToSurface(_ chars: String) {
|
|
guard let surface = surface else { return }
|
|
#if DEBUG
|
|
cmuxWriteChildExitProbe(
|
|
[
|
|
"probeInsertTextCharsHex": cmuxScalarHex(chars),
|
|
"probeInsertTextSurfaceId": terminalSurface?.id.uuidString ?? "",
|
|
],
|
|
increments: ["probeInsertTextCount": 1]
|
|
)
|
|
#endif
|
|
chars.withCString { ptr in
|
|
var keyEvent = ghostty_input_key_s()
|
|
keyEvent.action = GHOSTTY_ACTION_PRESS
|
|
keyEvent.keycode = 0
|
|
keyEvent.mods = GHOSTTY_MODS_NONE
|
|
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
|
|
keyEvent.text = ptr
|
|
keyEvent.composing = false
|
|
_ = ghostty_surface_key(surface, keyEvent)
|
|
}
|
|
}
|
|
|
|
func hasMarkedText() -> Bool {
|
|
return markedText.length > 0
|
|
}
|
|
|
|
func markedRange() -> NSRange {
|
|
guard markedText.length > 0 else { return NSRange(location: NSNotFound, length: 0) }
|
|
return NSRange(location: 0, length: markedText.length)
|
|
}
|
|
|
|
func selectedRange() -> NSRange {
|
|
return NSRange(location: NSNotFound, length: 0)
|
|
}
|
|
|
|
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
|
|
switch string {
|
|
case let v as NSAttributedString:
|
|
markedText = NSMutableAttributedString(attributedString: v)
|
|
case let v as String:
|
|
markedText = NSMutableAttributedString(string: v)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
func unmarkText() {
|
|
markedText.mutableString.setString("")
|
|
}
|
|
|
|
func validAttributesForMarkedText() -> [NSAttributedString.Key] {
|
|
return []
|
|
}
|
|
|
|
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
|
|
return nil
|
|
}
|
|
|
|
func characterIndex(for point: NSPoint) -> Int {
|
|
return 0
|
|
}
|
|
|
|
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
|
|
guard let window = self.window else {
|
|
return NSRect(x: frame.origin.x, y: frame.origin.y, width: 0, height: 0)
|
|
}
|
|
let viewRect = NSRect(x: 0, y: 0, width: 0, height: 0)
|
|
let winRect = convert(viewRect, to: nil)
|
|
return window.convertToScreen(winRect)
|
|
}
|
|
|
|
func insertText(_ string: Any, replacementRange: NSRange) {
|
|
guard NSApp.currentEvent != nil else { return }
|
|
|
|
// Get the string value
|
|
var chars = ""
|
|
switch string {
|
|
case let v as NSAttributedString:
|
|
chars = v.string
|
|
case let v as String:
|
|
chars = v
|
|
default:
|
|
return
|
|
}
|
|
|
|
// Clear marked text since we're inserting
|
|
unmarkText()
|
|
|
|
// If we have an accumulator, we're in a keyDown event - accumulate the text
|
|
if keyTextAccumulator != nil {
|
|
keyTextAccumulator?.append(chars)
|
|
return
|
|
}
|
|
|
|
// Otherwise send directly to the terminal
|
|
sendTextToSurface(chars)
|
|
}
|
|
}
|
|
|
|
// MARK: - SwiftUI Wrapper
|
|
|
|
struct GhosttyTerminalView: NSViewRepresentable {
|
|
let terminalSurface: TerminalSurface
|
|
var isActive: Bool = true
|
|
var isVisibleInUI: Bool = true
|
|
var reattachToken: UInt64 = 0
|
|
var onFocus: ((UUID) -> Void)? = nil
|
|
var onTriggerFlash: (() -> Void)? = nil
|
|
|
|
/// SwiftUI can create NSViewRepresentable containers that are not yet inserted into a
|
|
/// window (or never inserted at all) during bonsplit structural updates. We must avoid
|
|
/// re-parenting the hosted terminal view into an off-window container, since it can get
|
|
/// "stuck" there and leave the visible terminal blank/frozen.
|
|
private final class HostContainerView: NSView {
|
|
var onDidMoveToWindow: (() -> Void)?
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
guard window != nil else { return }
|
|
onDidMoveToWindow?()
|
|
}
|
|
}
|
|
|
|
final class Coordinator {
|
|
var constraints: [NSLayoutConstraint] = []
|
|
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
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let container = HostContainerView()
|
|
container.wantsLayer = true
|
|
return container
|
|
}
|
|
|
|
private static func attachHostedView(_ hostedView: GhosttySurfaceScrollView, to host: NSView, coordinator: Coordinator) {
|
|
// Avoid implicit animations during reparenting and constraint updates. Even a single
|
|
// CoreAnimation scale/bounds animation can produce a 1-frame "blank" or stretched
|
|
// compositor frame when the IOSurface-backed layer is resized or moved.
|
|
NSAnimationContext.runAnimationGroup { ctx in
|
|
ctx.duration = 0
|
|
ctx.allowsImplicitAnimation = false
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
defer { CATransaction.commit() }
|
|
|
|
// Remove any stale content views in the host, but avoid unnecessarily removing
|
|
// the hosted terminal view if it is already attached.
|
|
for v in host.subviews where v !== hostedView {
|
|
v.removeFromSuperview()
|
|
}
|
|
|
|
if hostedView.superview !== host {
|
|
hostedView.removeFromSuperview()
|
|
host.addSubview(hostedView)
|
|
}
|
|
|
|
hostedView.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.deactivate(coordinator.constraints)
|
|
coordinator.constraints = [
|
|
hostedView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
|
hostedView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
|
hostedView.topAnchor.constraint(equalTo: host.topAnchor),
|
|
hostedView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
|
]
|
|
NSLayoutConstraint.activate(coordinator.constraints)
|
|
host.needsLayout = true
|
|
host.layoutSubtreeIfNeeded()
|
|
}
|
|
|
|
// Re-apply visible/active state after re-parenting so focus/occlusion requests run with
|
|
// a valid window.
|
|
// Without this, a focus attempt issued while the hosted view is off-window can time out,
|
|
// leaving the visible terminal unfocused (keys appear to go to the wrong surface).
|
|
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
|
|
hostedView.setActive(coordinator.desiredIsActive)
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {
|
|
let hostedView = terminalSurface.hostedView
|
|
context.coordinator.desiredIsActive = isActive
|
|
context.coordinator.desiredIsVisibleInUI = isVisibleInUI
|
|
|
|
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
|
|
hostedView.attachSurface(terminalSurface)
|
|
hostedView.setVisibleInUI(isVisibleInUI)
|
|
hostedView.setActive(isActive)
|
|
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
|
|
hostedView.setTriggerFlashHandler(onTriggerFlash)
|
|
|
|
if hostedView.superview !== nsView {
|
|
context.coordinator.attachGeneration += 1
|
|
let generation = context.coordinator.attachGeneration
|
|
|
|
// If this container isn't in a window yet, defer attaching until it is.
|
|
// Importantly: do NOT detach the hosted view from its current superview
|
|
// until we have a valid window, otherwise it can disappear and become
|
|
// "stuck" in an off-window container.
|
|
if let host = nsView as? HostContainerView {
|
|
host.onDidMoveToWindow = { [weak coordinator = context.coordinator, weak host, weak hostedView] in
|
|
guard let coordinator, coordinator.attachGeneration == generation else { return }
|
|
guard let host, let hostedView else { return }
|
|
guard host.window != nil else { return }
|
|
Self.attachHostedView(hostedView, to: host, coordinator: coordinator)
|
|
}
|
|
}
|
|
|
|
if nsView.window != nil {
|
|
Self.attachHostedView(hostedView, to: nsView, coordinator: context.coordinator)
|
|
}
|
|
} else {
|
|
context.coordinator.attachGeneration += 1
|
|
if let host = nsView as? HostContainerView {
|
|
host.onDidMoveToWindow = nil
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
|
coordinator.attachGeneration += 1
|
|
|
|
NSLayoutConstraint.deactivate(coordinator.constraints)
|
|
coordinator.constraints.removeAll()
|
|
|
|
if let host = nsView as? HostContainerView {
|
|
host.onDidMoveToWindow = nil
|
|
}
|
|
|
|
// Avoid proactively detaching the hosted terminal view during SwiftUI structural updates.
|
|
// When bonsplit rearranges panes, SwiftUI can dismantle the "old" container before the
|
|
// "new" container has re-parented the hosted view; removing it here creates a visible
|
|
// transient blank (and can strand the view off-window if the re-attach is missed).
|
|
let hasHostedTerminal = nsView.subviews.contains(where: { $0 is GhosttySurfaceScrollView })
|
|
if !hasHostedTerminal {
|
|
nsView.subviews.forEach { $0.removeFromSuperview() }
|
|
}
|
|
}
|
|
}
|