* Add --panel flag to new-split command
Allows splitting a specific panel without changing focus first.
Usage: cmuxterm new-split <direction> [--panel <id|index>]
Example: cmuxterm new-split down --panel 1
* Return new panel ID from new-split command
new-split now returns the UUID of the newly created panel, enabling
reliable chaining of split operations without index drift issues.
Before: OK
After: OK F2675177-3838-49AF-A1A0-1744C0048E99
Example workflow to create left + 2x2 grid on right:
RIGHT=$(cmuxterm new-split right | awk '{print $2}')
BOTTOM=$(cmuxterm new-split down --panel $RIGHT | awk '{print $2}')
cmuxterm new-split right --panel $RIGHT
cmuxterm new-split right --panel $BOTTOM
2354 lines
86 KiB
Swift
2354 lines
86 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import AppKit
|
|
import Metal
|
|
import QuartzCore
|
|
import Combine
|
|
import Darwin
|
|
import Sentry
|
|
|
|
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()
|
|
}
|
|
|
|
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(0, nil)
|
|
if result != GHOSTTY_SUCCESS {
|
|
print("Failed to initialize ghostty: \(result)")
|
|
return
|
|
}
|
|
|
|
// Load config
|
|
config = ghostty_config_new()
|
|
guard let config = config else {
|
|
print("Failed to create ghostty config")
|
|
return
|
|
}
|
|
|
|
// Load default config
|
|
ghostty_config_load_default_files(config)
|
|
ghostty_config_finalize(config)
|
|
updateDefaultBackground(from: config)
|
|
|
|
// 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, _ in
|
|
guard let userdata else { return }
|
|
let surfaceView = Unmanaged<GhosttyNSView>.fromOpaque(userdata).takeUnretainedValue()
|
|
guard let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id else {
|
|
return
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
if let surface = surfaceView.terminalSurface,
|
|
surface.needsConfirmClose() {
|
|
AppDelegate.shared?.tabManager?.closePanelWithConfirmation(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId
|
|
)
|
|
return
|
|
}
|
|
_ = AppDelegate.shared?.tabManager?.closeSurface(tabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
}
|
|
|
|
// Create app
|
|
app = ghostty_app_new(&runtimeConfig, config)
|
|
if app == nil {
|
|
print("Failed to create ghostty app")
|
|
return
|
|
}
|
|
|
|
#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
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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: () -> T) -> T {
|
|
if Thread.isMainThread {
|
|
return work()
|
|
}
|
|
return DispatchQueue.main.sync(execute: work)
|
|
}
|
|
|
|
private func splitDirection(from direction: ghostty_action_split_direction_e) -> SplitTree<TerminalSurface>.NewDirection? {
|
|
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) -> SplitTree<TerminalSurface>.FocusDirection? {
|
|
switch direction {
|
|
case GHOSTTY_GOTO_SPLIT_PREVIOUS: return .previous
|
|
case GHOSTTY_GOTO_SPLIT_NEXT: return .next
|
|
case GHOSTTY_GOTO_SPLIT_UP: return .spatial(.up)
|
|
case GHOSTTY_GOTO_SPLIT_DOWN: return .spatial(.down)
|
|
case GHOSTTY_GOTO_SPLIT_LEFT: return .spatial(.left)
|
|
case GHOSTTY_GOTO_SPLIT_RIGHT: return .spatial(.right)
|
|
default: return nil
|
|
}
|
|
}
|
|
|
|
private func resizeDirection(from direction: ghostty_action_resize_split_direction_e) -> SplitTree<TerminalSurface>.Spatial.Direction? {
|
|
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 tabManager = AppDelegate.shared?.tabManager,
|
|
let tabId = tabManager.selectedTabId {
|
|
let actionTitle = action.action.desktop_notification.title
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
let actionBody = action.action.desktop_notification.body
|
|
.flatMap { String(cString: $0) } ?? ""
|
|
let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal"
|
|
let command = actionTitle.isEmpty ? tabTitle : actionTitle
|
|
let body = actionBody
|
|
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
|
DispatchQueue.main.async {
|
|
tabManager.moveTabToTop(tabId)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: command,
|
|
subtitle: "",
|
|
body: body
|
|
)
|
|
}
|
|
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),
|
|
let tabManager = AppDelegate.shared?.tabManager else {
|
|
return false
|
|
}
|
|
return performOnMain {
|
|
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),
|
|
let tabManager = AppDelegate.shared?.tabManager else {
|
|
return false
|
|
}
|
|
return performOnMain {
|
|
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),
|
|
let tabManager = AppDelegate.shared?.tabManager else {
|
|
return false
|
|
}
|
|
let amount = action.action.resize_split.amount
|
|
return performOnMain {
|
|
tabManager.resizeSplit(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
direction: direction,
|
|
amount: amount
|
|
)
|
|
}
|
|
case GHOSTTY_ACTION_EQUALIZE_SPLITS:
|
|
guard let tabId = surfaceView.tabId,
|
|
let tabManager = AppDelegate.shared?.tabManager else {
|
|
return false
|
|
}
|
|
return performOnMain {
|
|
tabManager.equalizeSplits(tabId: tabId)
|
|
}
|
|
case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM:
|
|
guard let tabId = surfaceView.tabId,
|
|
let surfaceId = surfaceView.terminalSurface?.id,
|
|
let tabManager = AppDelegate.shared?.tabManager else {
|
|
return false
|
|
}
|
|
return performOnMain {
|
|
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 {
|
|
DispatchQueue.main.async {
|
|
NotificationCenter.default.post(
|
|
name: .ghosttyDidSetTitle,
|
|
object: surfaceView,
|
|
userInfo: [
|
|
GhosttyNotificationKey.tabId: tabId,
|
|
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) } ?? ""
|
|
let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal"
|
|
let command = actionTitle.isEmpty ? tabTitle : actionTitle
|
|
let body = actionBody
|
|
DispatchQueue.main.async {
|
|
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
|
|
TerminalNotificationStore.shared.addNotification(
|
|
tabId: tabId,
|
|
surfaceId: surfaceId,
|
|
title: command,
|
|
subtitle: "",
|
|
body: body
|
|
)
|
|
}
|
|
return true
|
|
case GHOSTTY_ACTION_COLOR_CHANGE:
|
|
if action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND {
|
|
let change = action.action.color_change
|
|
surfaceView.backgroundColor = NSColor(
|
|
red: CGFloat(change.r) / 255,
|
|
green: CGFloat(change.g) / 255,
|
|
blue: CGFloat(change.b) / 255,
|
|
alpha: 1.0
|
|
)
|
|
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_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 = NSApp.keyWindow ?? NSApp.windows.first else { return }
|
|
let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity)
|
|
window.backgroundColor = color
|
|
window.isOpaque = color.alphaComponent >= 1.0
|
|
if backgroundLogEnabled {
|
|
logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))")
|
|
}
|
|
}
|
|
|
|
func 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: - 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?
|
|
let id: UUID
|
|
let 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
|
|
@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)
|
|
let view = GhosttyNSView(frame: .zero)
|
|
self.surfaceView = view
|
|
self.hostedView = GhosttySurfaceScrollView(surfaceView: view)
|
|
// Surface is created when attached to a view
|
|
hostedView.attachSurface(self)
|
|
}
|
|
|
|
private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) {
|
|
let layerScale = view.window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
|
|
return (layerScale, layerScale, layerScale)
|
|
}
|
|
|
|
func attachToView(_ view: GhosttyNSView) {
|
|
// If already attached to this view, nothing to do
|
|
if attachedView === view && surface != nil {
|
|
updateMetalLayer(for: view)
|
|
return
|
|
}
|
|
|
|
if let attachedView, attachedView !== view {
|
|
return
|
|
}
|
|
|
|
attachedView = view
|
|
|
|
// If surface doesn't exist yet, create it
|
|
if surface == nil {
|
|
createSurface(for: view)
|
|
}
|
|
}
|
|
|
|
private func createSurface(for view: GhosttyNSView) {
|
|
guard let app = GhosttyApp.shared.app else {
|
|
print("Ghostty app not initialized")
|
|
return
|
|
}
|
|
|
|
let scaleFactors = scaleFactors(for: view)
|
|
|
|
updateMetalLayer(for: view)
|
|
|
|
var surfaceConfig = configTemplate ?? ghostty_surface_config_new()
|
|
surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS
|
|
surfaceConfig.platform.macos.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_PANEL_ID"] = id.uuidString
|
|
env["CMUX_TAB_ID"] = tabId.uuidString
|
|
env["CMUX_SOCKET_PATH"] = SocketControlSettings.socketPath()
|
|
|
|
if let cliBinPath = Bundle.main.resourceURL?.appendingPathComponent("bin").path {
|
|
let currentPath = env["PATH"]
|
|
?? ProcessInfo.processInfo.environment["PATH"]
|
|
?? ""
|
|
if !currentPath.split(separator: ":").contains(Substring(cliBinPath)) {
|
|
let separator = currentPath.isEmpty ? "" : ":"
|
|
env["PATH"] = "\(cliBinPath)\(separator)\(currentPath)"
|
|
}
|
|
}
|
|
|
|
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")
|
|
return
|
|
}
|
|
|
|
ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y)
|
|
ghostty_surface_set_size(
|
|
surface,
|
|
UInt32(view.bounds.width * scaleFactors.x),
|
|
UInt32(view.bounds.height * scaleFactors.y)
|
|
)
|
|
ghostty_surface_refresh(surface)
|
|
}
|
|
|
|
private func updateMetalLayer(for view: GhosttyNSView) {
|
|
let scale = view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
|
|
if let metalLayer = view.layer as? CAMetalLayer {
|
|
metalLayer.contentsScale = scale
|
|
if view.bounds.width > 0 && view.bounds.height > 0 {
|
|
metalLayer.drawableSize = CGSize(
|
|
width: view.bounds.width * scale,
|
|
height: view.bounds.height * scale
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) {
|
|
guard let surface = surface else { return }
|
|
ghostty_surface_set_content_scale(surface, xScale, yScale)
|
|
ghostty_surface_set_size(surface, UInt32(width * xScale), UInt32(height * yScale))
|
|
ghostty_surface_refresh(surface)
|
|
|
|
if let view = attachedView, let metalLayer = view.layer as? CAMetalLayer {
|
|
metalLayer.contentsScale = layerScale
|
|
metalLayer.drawableSize = CGSize(width: width * layerScale, height: height * layerScale)
|
|
}
|
|
}
|
|
|
|
func applyWindowBackgroundIfActive() {
|
|
surfaceView.applyWindowBackgroundIfActive()
|
|
}
|
|
|
|
func setFocus(_ focused: Bool) {
|
|
guard let surface = surface else { return }
|
|
ghostty_surface_set_focus(surface, focused)
|
|
}
|
|
|
|
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")
|
|
}()
|
|
fileprivate static func focusLog(_ message: String) {
|
|
guard focusDebugEnabled else { return }
|
|
FocusLogStore.shared.append(message)
|
|
NSLog("[FOCUSDBG] %@", message)
|
|
}
|
|
|
|
weak var terminalSurface: TerminalSurface?
|
|
private var surfaceAttached = false
|
|
var scrollbar: GhosttyScrollbar?
|
|
var cellSize: CGSize = .zero
|
|
var desiredFocus: 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 lastSurfaceSize: CGSize = .zero
|
|
private var lastContentScale: CGSize = .zero
|
|
private var lastLayerScale: CGFloat = 0
|
|
private var hasSurfaceMetrics = false
|
|
private var lastScrollEventTime: CFTimeInterval = 0
|
|
|
|
override func makeBackingLayer() -> CALayer {
|
|
let metalLayer = CAMetalLayer()
|
|
metalLayer.device = MTLCreateSystemDefaultDevice()
|
|
metalLayer.pixelFormat = .bgra8Unorm
|
|
metalLayer.framebufferOnly = true
|
|
metalLayer.isOpaque = false
|
|
metalLayer.backgroundColor = NSColor.clear.cgColor
|
|
metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
|
|
return metalLayer
|
|
}
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
setup()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
setup()
|
|
}
|
|
|
|
private func setup() {
|
|
wantsLayer = true
|
|
layerContentsRedrawPolicy = .duringViewResize
|
|
installEventMonitor()
|
|
updateTrackingAreas()
|
|
}
|
|
|
|
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 metalLayer = layer as? CAMetalLayer {
|
|
metalLayer.backgroundColor = color.cgColor
|
|
metalLayer.isOpaque = color.alphaComponent >= 1.0
|
|
}
|
|
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()
|
|
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
|
|
surfaceAttached = false
|
|
hasSurfaceMetrics = false
|
|
attachSurfaceIfNeeded()
|
|
}
|
|
|
|
private func attachSurfaceIfNeeded() {
|
|
guard !surfaceAttached else { return }
|
|
guard let terminalSurface = terminalSurface else { return }
|
|
guard bounds.width > 0 && bounds.height > 0 else { return }
|
|
guard window != nil else { return }
|
|
|
|
surfaceAttached = true
|
|
terminalSurface.attachToView(self)
|
|
terminalSurface.setFocus(desiredFocus)
|
|
}
|
|
|
|
override func viewDidMoveToWindow() {
|
|
super.viewDidMoveToWindow()
|
|
if let windowObserver {
|
|
NotificationCenter.default.removeObserver(windowObserver)
|
|
self.windowObserver = nil
|
|
}
|
|
if let window {
|
|
windowObserver = NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didChangeScreenNotification,
|
|
object: window,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
self?.windowDidChangeScreen(notification)
|
|
}
|
|
attachSurfaceIfNeeded()
|
|
updateSurfaceSize()
|
|
applySurfaceBackground()
|
|
applyWindowBackgroundIfActive()
|
|
}
|
|
}
|
|
|
|
override func viewDidChangeBackingProperties() {
|
|
super.viewDidChangeBackingProperties()
|
|
if let window {
|
|
CATransaction.begin()
|
|
CATransaction.setDisableActions(true)
|
|
layer?.contentsScale = window.backingScaleFactor
|
|
CATransaction.commit()
|
|
}
|
|
updateSurfaceSize()
|
|
}
|
|
|
|
override func setFrameSize(_ newSize: NSSize) {
|
|
super.setFrameSize(newSize)
|
|
attachSurfaceIfNeeded()
|
|
updateSurfaceSize()
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
attachSurfaceIfNeeded()
|
|
}
|
|
|
|
override var isOpaque: Bool { false }
|
|
|
|
private func updateSurfaceSize() {
|
|
guard let terminalSurface = terminalSurface else { return }
|
|
guard bounds.width > 0 && bounds.height > 0 else { return }
|
|
let layerScale = window?.screen?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
|
|
let xScale = layerScale
|
|
let yScale = layerScale
|
|
if hasSurfaceMetrics {
|
|
let sameSize = nearlyEqual(lastSurfaceSize.width, bounds.width, epsilon: 0.01)
|
|
&& nearlyEqual(lastSurfaceSize.height, bounds.height, epsilon: 0.01)
|
|
let sameScale = nearlyEqual(lastContentScale.width, xScale)
|
|
&& nearlyEqual(lastContentScale.height, yScale)
|
|
&& nearlyEqual(lastLayerScale, layerScale)
|
|
if sameSize && sameScale {
|
|
return
|
|
}
|
|
}
|
|
lastSurfaceSize = bounds.size
|
|
lastContentScale = CGSize(width: xScale, height: yScale)
|
|
lastLayerScale = layerScale
|
|
hasSurfaceMetrics = true
|
|
terminalSurface.updateSize(
|
|
width: bounds.width,
|
|
height: bounds.height,
|
|
xScale: xScale,
|
|
yScale: yScale,
|
|
layerScale: layerScale
|
|
)
|
|
}
|
|
|
|
private func nearlyEqual(_ lhs: CGFloat, _ rhs: CGFloat, epsilon: CGFloat = 0.0001) -> Bool {
|
|
abs(lhs - rhs) <= epsilon
|
|
}
|
|
|
|
// Convenience accessor for the ghostty surface
|
|
private var surface: ghostty_surface_t? {
|
|
terminalSurface?.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, let surface = surface {
|
|
let now = CACurrentMediaTime()
|
|
let deltaMs = (now - lastScrollEventTime) * 1000
|
|
Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
|
|
onFocus?()
|
|
#if DEBUG
|
|
if let terminalSurface {
|
|
AppDelegate.shared?.recordJumpUnreadFocusIfExpected(
|
|
tabId: terminalSurface.tabId,
|
|
surfaceId: terminalSurface.id
|
|
)
|
|
}
|
|
#endif
|
|
ghostty_surface_set_focus(surface, true)
|
|
}
|
|
return result
|
|
}
|
|
|
|
override func resignFirstResponder() -> Bool {
|
|
return super.resignFirstResponder()
|
|
}
|
|
|
|
// 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 window?.firstResponder === self else { return false }
|
|
guard let surface = surface else { return false }
|
|
|
|
// 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
|
|
}
|
|
|
|
// Only handle command/control-modified keys here.
|
|
if !event.modifierFlags.contains(.command) &&
|
|
!event.modifierFlags.contains(.control) {
|
|
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 = surface else {
|
|
super.keyDown(with: event)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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 }
|
|
|
|
ghostty_surface_set_display_id(surface, screen.displayID ?? 0)
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.viewDidChangeBackingProperties()
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension NSScreen {
|
|
var displayID: UInt32? {
|
|
deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32
|
|
}
|
|
}
|
|
|
|
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 title = "ghostty.title"
|
|
}
|
|
|
|
extension Notification.Name {
|
|
static let ghosttyDidUpdateScrollbar = Notification.Name("ghosttyDidUpdateScrollbar")
|
|
static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize")
|
|
static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus")
|
|
}
|
|
|
|
// 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
|
|
private var focusWorkItem: DispatchWorkItem?
|
|
#if DEBUG
|
|
private static var flashCounts: [UUID: Int] = [:]
|
|
|
|
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
|
|
}
|
|
#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 = false
|
|
scrollView.contentView.drawsBackground = false
|
|
scrollView.contentView.backgroundColor = .clear
|
|
scrollView.surfaceView = surfaceView
|
|
|
|
documentView = NSView(frame: .zero)
|
|
documentView.wantsLayer = true
|
|
documentView.layer?.backgroundColor = NSColor.clear.cgColor
|
|
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()
|
|
backgroundView.frame = bounds
|
|
scrollView.frame = bounds
|
|
surfaceView.frame.size = scrollView.bounds.size
|
|
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?.updateFocusForWindow()
|
|
self?.requestFocus()
|
|
})
|
|
windowObservers.append(NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didResignKeyNotification,
|
|
object: window,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.updateFocusForWindow()
|
|
})
|
|
updateFocusForWindow()
|
|
if window.isKeyWindow { requestFocus() }
|
|
}
|
|
|
|
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 setActive(_ active: Bool) {
|
|
isActive = active
|
|
updateFocusForWindow()
|
|
if active {
|
|
requestFocus()
|
|
} else {
|
|
cancelFocusRequest()
|
|
}
|
|
}
|
|
|
|
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
|
|
let maxDelay: TimeInterval = 0.5
|
|
guard (delay ?? 0) < maxDelay else { return }
|
|
|
|
let nextDelay: TimeInterval = if let delay {
|
|
delay * 2
|
|
} else {
|
|
0.05
|
|
}
|
|
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
guard let window = self.window else {
|
|
self.moveFocus(from: previous, delay: nextDelay)
|
|
return
|
|
}
|
|
|
|
if let previous, previous !== self {
|
|
_ = previous.surfaceView.resignFirstResponder()
|
|
}
|
|
|
|
window.makeFirstResponder(self.surfaceView)
|
|
}
|
|
|
|
let queue = DispatchQueue.main
|
|
if let delay {
|
|
queue.asyncAfter(deadline: .now() + delay, execute: work)
|
|
} else {
|
|
queue.async(execute: work)
|
|
}
|
|
}
|
|
|
|
func ensureFocus(for tabId: UUID, surfaceId: UUID, attempt: Int = 0) {
|
|
let maxAttempts = 6
|
|
guard attempt < maxAttempts else { return }
|
|
guard let tabManager = AppDelegate.shared?.tabManager,
|
|
tabManager.selectedTabId == tabId,
|
|
tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return }
|
|
if surfaceView.terminalSurface?.searchState != nil {
|
|
return
|
|
}
|
|
|
|
guard let window else {
|
|
scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt)
|
|
return
|
|
}
|
|
|
|
guard window.isKeyWindow else {
|
|
scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt)
|
|
return
|
|
}
|
|
|
|
if window.firstResponder === surfaceView {
|
|
return
|
|
}
|
|
|
|
window.makeFirstResponder(surfaceView)
|
|
|
|
if window.firstResponder !== surfaceView {
|
|
scheduleFocusRetry(for: tabId, surfaceId: surfaceId, attempt: attempt)
|
|
}
|
|
}
|
|
|
|
private func scheduleFocusRetry(for tabId: UUID, surfaceId: UUID, attempt: Int) {
|
|
let delay = 0.05 * pow(2.0, Double(attempt))
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
self?.ensureFocus(for: tabId, surfaceId: surfaceId, attempt: attempt + 1)
|
|
}
|
|
}
|
|
|
|
private func updateFocusForWindow() {
|
|
let shouldFocus = isActive && (window?.isKeyWindow ?? false)
|
|
surfaceView.desiredFocus = shouldFocus
|
|
surfaceView.terminalSurface?.setFocus(shouldFocus)
|
|
}
|
|
|
|
private func requestFocus(delay: TimeInterval? = nil) {
|
|
guard isActive else { return }
|
|
if surfaceView.terminalSurface?.searchState != nil {
|
|
return
|
|
}
|
|
let maxDelay: TimeInterval = 0.5
|
|
guard (delay ?? 0) < maxDelay else { return }
|
|
|
|
let nextDelay: TimeInterval = if let delay {
|
|
delay * 2
|
|
} else {
|
|
0.05
|
|
}
|
|
|
|
cancelFocusRequest()
|
|
let work = DispatchWorkItem { [weak self] in
|
|
guard let self else { return }
|
|
guard self.isActive else { return }
|
|
if self.surfaceView.terminalSurface?.searchState != nil {
|
|
return
|
|
}
|
|
guard let window = self.window else {
|
|
self.requestFocus(delay: nextDelay)
|
|
return
|
|
}
|
|
guard window.isKeyWindow else { return }
|
|
|
|
if window.firstResponder === self.surfaceView {
|
|
return
|
|
}
|
|
|
|
if let responder = window.firstResponder as? NSView, responder !== self.surfaceView {
|
|
_ = responder.resignFirstResponder()
|
|
}
|
|
|
|
window.makeFirstResponder(self.surfaceView)
|
|
|
|
if window.firstResponder !== self.surfaceView {
|
|
self.requestFocus(delay: nextDelay)
|
|
}
|
|
}
|
|
|
|
let queue = DispatchQueue.main
|
|
focusWorkItem = work
|
|
if let delay {
|
|
queue.asyncAfter(deadline: .now() + delay, execute: work)
|
|
} else {
|
|
queue.async(execute: work)
|
|
}
|
|
}
|
|
|
|
func cancelFocusRequest() {
|
|
focusWorkItem?.cancel()
|
|
focusWorkItem = nil
|
|
}
|
|
|
|
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 {
|
|
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) {
|
|
// 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
|
|
if let surface = surface {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - SwiftUI Wrapper
|
|
|
|
struct GhosttyTerminalView: NSViewRepresentable {
|
|
let terminalSurface: TerminalSurface
|
|
var isActive: Bool = true
|
|
var onFocus: ((UUID) -> Void)? = nil
|
|
var onTriggerFlash: (() -> Void)? = nil
|
|
|
|
func makeNSView(context: Context) -> GhosttySurfaceScrollView {
|
|
let view = terminalSurface.hostedView
|
|
view.attachSurface(terminalSurface)
|
|
view.setActive(isActive)
|
|
view.setFocusHandler { onFocus?(terminalSurface.id) }
|
|
view.setTriggerFlashHandler(onTriggerFlash)
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: GhosttySurfaceScrollView, context: Context) {
|
|
nsView.attachSurface(terminalSurface)
|
|
nsView.setActive(isActive)
|
|
nsView.setFocusHandler { onFocus?(terminalSurface.id) }
|
|
nsView.setTriggerFlashHandler(onTriggerFlash)
|
|
}
|
|
}
|