Files
downterm/CommandNotch/CommandNotch/Models/GhosttyBackendSession.swift

1378 lines
44 KiB
Swift

import AppKit
import Darwin
import GhosttyKit
import os
import SwiftTerm
private final class GhosttyRuntime {
static let shared = GhosttyRuntime()
private struct WakeupState {
var isScheduled = false
var needsAnotherTick = false
}
private static var didInitialize = false
private static let wakeupLock = OSAllocatedUnfairLock(initialState: WakeupState())
let app: ghostty_app_t
private let appConfig: ghostty_config_t
init?() {
if !Self.didInitialize {
let argv = CommandLine.unsafeArgv
guard ghostty_init(UInt(CommandLine.argc), argv) == GHOSTTY_SUCCESS else { return nil }
Self.didInitialize = true
}
guard let appConfig = ghostty_config_new() else { return nil }
ghostty_config_finalize(appConfig)
self.appConfig = appConfig
var runtimeConfig = ghostty_runtime_config_s(
userdata: nil,
supports_selection_clipboard: false,
wakeup_cb: ghosttyWakeupCallback,
action_cb: ghosttyActionCallback,
read_clipboard_cb: ghosttyReadClipboardCallback,
confirm_read_clipboard_cb: ghosttyConfirmReadClipboardCallback,
write_clipboard_cb: ghosttyWriteClipboardCallback,
close_surface_cb: ghosttyCloseSurfaceCallback
)
guard let app = ghostty_app_new(&runtimeConfig, appConfig) else {
ghostty_config_free(appConfig)
return nil
}
self.app = app
ghostty_app_set_focus(app, NSApp.isActive)
}
deinit {
ghostty_app_free(app)
ghostty_config_free(appConfig)
}
func tick() {
ghostty_app_tick(app)
}
static func requestTick() {
let shouldSchedule = wakeupLock.withLock { state in
if state.isScheduled {
state.needsAnotherTick = true
return false
}
state.isScheduled = true
return true
}
guard shouldSchedule else { return }
DispatchQueue.main.async {
performScheduledTick()
}
}
private static func performScheduledTick() {
shared?.tick()
let shouldReschedule = wakeupLock.withLock { state in
if state.needsAnotherTick {
state.needsAnotherTick = false
return true
}
state.isScheduled = false
return false
}
guard shouldReschedule else { return }
DispatchQueue.main.async {
performScheduledTick()
}
}
private static func backend(from userdata: UnsafeMutableRawPointer?) -> GhosttyBackendSession? {
guard let userdata else { return nil }
return Unmanaged<GhosttyBackendSession>.fromOpaque(userdata).takeUnretainedValue()
}
private static func backend(from target: ghostty_target_s) -> GhosttyBackendSession? {
guard target.tag == GHOSTTY_TARGET_SURFACE,
let surface = target.target.surface,
let userdata = ghostty_surface_userdata(surface) else { return nil }
return backend(from: userdata)
}
static func handleAction(
_ app: ghostty_app_t?,
target: ghostty_target_s,
action: ghostty_action_s
) -> Bool {
_ = app
switch action.tag {
case GHOSTTY_ACTION_SET_TITLE:
guard let backend = backend(from: target),
let titleCString = action.action.set_title.title else { return false }
let title = String(cString: titleCString)
MainActor.assumeIsolated {
backend.handleTitleChanged(title)
}
return true
case GHOSTTY_ACTION_SET_TAB_TITLE:
guard let backend = backend(from: target),
let titleCString = action.action.set_tab_title.title else { return false }
let title = String(cString: titleCString)
MainActor.assumeIsolated {
backend.handleTitleChanged(title)
}
return true
case GHOSTTY_ACTION_PWD:
guard let backend = backend(from: target),
let pwdCString = action.action.pwd.pwd else { return false }
let pwd = String(cString: pwdCString)
MainActor.assumeIsolated {
backend.handleCurrentDirectoryChanged(pwd)
}
return true
case GHOSTTY_ACTION_RING_BELL:
NSSound.beep()
return true
case GHOSTTY_ACTION_OPEN_URL:
return openURL(action.action.open_url)
case GHOSTTY_ACTION_MOUSE_SHAPE:
if let backend = backend(from: target) {
MainActor.assumeIsolated {
backend.setPointerStyle(action.action.mouse_shape)
}
}
return true
case GHOSTTY_ACTION_MOUSE_VISIBILITY:
if let backend = backend(from: target) {
MainActor.assumeIsolated {
backend.setPointerVisible(action.action.mouse_visibility == GHOSTTY_MOUSE_VISIBLE)
}
}
return true
case GHOSTTY_ACTION_SHOW_CHILD_EXITED:
if let backend = backend(from: target) {
MainActor.assumeIsolated {
backend.handleProcessExit()
}
}
return true
case GHOSTTY_ACTION_COMMAND_FINISHED:
if let backend = backend(from: target) {
MainActor.assumeIsolated {
backend.handleCommandFinished()
}
}
return true
default:
return false
}
}
private static func openURL(_ value: ghostty_action_open_url_s) -> Bool {
guard let urlPointer = value.url else { return false }
let data = Data(bytes: urlPointer, count: Int(value.len))
guard let urlString = String(data: data, encoding: .utf8),
let url = URL(string: urlString) else { return false }
NSWorkspace.shared.open(url)
return true
}
static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {
guard let backend = backend(from: userdata) else { return }
Task { @MainActor in
backend.handleSurfaceClosed(processAlive: processAlive)
}
}
static func readClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
state: UnsafeMutableRawPointer?
) -> Bool {
guard location == GHOSTTY_CLIPBOARD_STANDARD,
let backend = backend(from: userdata) else { return false }
return MainActor.assumeIsolated {
guard let surface = backend.surface,
let string = NSPasteboard.general.string(forType: .string) else { return false }
string.withCString { pointer in
ghostty_surface_complete_clipboard_request(surface, pointer, state, true)
}
return true
}
}
static func confirmReadClipboard(
_ userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
state: UnsafeMutableRawPointer?,
request: ghostty_clipboard_request_e
) {
_ = userdata
_ = string
_ = state
_ = request
}
static func writeClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
content: UnsafePointer<ghostty_clipboard_content_s>?,
contentLen: Int,
shouldConfirm: Bool
) {
_ = userdata
_ = shouldConfirm
guard location == GHOSTTY_CLIPBOARD_STANDARD,
let content else { return }
for index in 0..<contentLen {
let item = content[index]
guard let mime = item.mime,
String(cString: mime) == "text/plain",
let data = item.data else { continue }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(cString: data), forType: .string)
break
}
}
}
private func ghosttyWakeupCallback(_ userdata: UnsafeMutableRawPointer?) {
_ = userdata
GhosttyRuntime.requestTick()
}
private func ghosttyActionCallback(
_ app: ghostty_app_t?,
_ target: ghostty_target_s,
_ action: ghostty_action_s
) -> Bool {
GhosttyRuntime.handleAction(app, target: target, action: action)
}
private func ghosttyReadClipboardCallback(
_ userdata: UnsafeMutableRawPointer?,
_ location: ghostty_clipboard_e,
_ state: UnsafeMutableRawPointer?
) -> Bool {
GhosttyRuntime.readClipboard(userdata, location: location, state: state)
}
private func ghosttyConfirmReadClipboardCallback(
_ userdata: UnsafeMutableRawPointer?,
_ string: UnsafePointer<CChar>?,
_ state: UnsafeMutableRawPointer?,
_ request: ghostty_clipboard_request_e
) {
GhosttyRuntime.confirmReadClipboard(userdata, string: string, state: state, request: request)
}
private func ghosttyWriteClipboardCallback(
_ userdata: UnsafeMutableRawPointer?,
_ location: ghostty_clipboard_e,
_ content: UnsafePointer<ghostty_clipboard_content_s>?,
_ contentLen: Int,
_ shouldConfirm: Bool
) {
GhosttyRuntime.writeClipboard(
userdata,
location: location,
content: content,
contentLen: contentLen,
shouldConfirm: shouldConfirm
)
}
private func ghosttyCloseSurfaceCallback(_ userdata: UnsafeMutableRawPointer?, _ processAlive: Bool) {
GhosttyRuntime.closeSurface(userdata, processAlive: processAlive)
}
private enum GhosttyConfigBuilder {
static func withConfig<T>(
fontSize: CGFloat,
theme: TerminalTheme,
scrollbackBytes: Int,
body: (ghostty_config_t) -> T
) -> T? {
guard let config = ghostty_config_new() else { return nil }
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("commandnotch-ghostty-\(UUID().uuidString).config")
do {
try contents(
fontSize: fontSize,
theme: theme,
scrollbackBytes: scrollbackBytes
).write(to: url, atomically: true, encoding: .utf8)
defer {
ghostty_config_free(config)
try? FileManager.default.removeItem(at: url)
}
ghostty_config_load_file(config, url.path)
ghostty_config_finalize(config)
return body(config)
} catch {
ghostty_config_free(config)
try? FileManager.default.removeItem(at: url)
return nil
}
}
private static func contents(
fontSize: CGFloat,
theme: TerminalTheme,
scrollbackBytes: Int
) -> String {
var lines = [
"font-size = \(fontSize)",
"scrollback-limit = \(max(0, scrollbackBytes))",
"background = #\(hex(theme.backgroundColor))",
"foreground = #\(hex(theme.foregroundColor))",
"palette-generate = false",
]
for (index, color) in theme.ansiColors.enumerated() {
lines.append("palette = \(index)=#\(hex(color))")
}
return lines.joined(separator: "\n")
}
private static func hex(_ color: NSColor) -> String {
guard let converted = color.usingColorSpace(.deviceRGB) else {
return "FFFFFF"
}
let red = Int(round(converted.redComponent * 255))
let green = Int(round(converted.greenComponent * 255))
let blue = Int(round(converted.blueComponent * 255))
return String(format: "%02X%02X%02X", red, green, blue)
}
private static func hex(_ color: Color) -> String {
let red = Int(color.red / 257)
let green = Int(color.green / 257)
let blue = Int(color.blue / 257)
return String(format: "%02X%02X%02X", red, green, blue)
}
}
@MainActor
final class GhosttyBackendSession: NSObject, TerminalBackendSession {
static func makeIfAvailable(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
scrollbackLines: Int,
termTypePreference: TerminalTermTypePreference,
initialDirectory: String
) -> GhosttyBackendSession? {
guard GhosttyRuntime.shared != nil else { return nil }
return GhosttyBackendSession(
fontSize: fontSize,
theme: theme,
shellPath: shellPath,
scrollbackLines: scrollbackLines,
termTypePreference: termTypePreference,
initialDirectory: initialDirectory
)
}
var onTitleChange: ((String) -> Void)?
var onCurrentDirectoryChange: ((String?) -> Void)?
var onExit: (() -> Void)?
private lazy var terminalView = GhosttyTerminalView(backend: self)
private var fontSize: CGFloat
private var theme: TerminalTheme
private let configuredShellPath: String
private var scrollbackLines: Int
private let termTypePreference: TerminalTermTypePreference
private let launchDirectory: String
private var hasStarted = false
private var hasExited = false
init(
fontSize: CGFloat,
theme: TerminalTheme,
shellPath: String,
scrollbackLines: Int,
termTypePreference: TerminalTermTypePreference,
initialDirectory: String
) {
self.fontSize = fontSize
self.theme = theme
configuredShellPath = shellPath
self.scrollbackLines = scrollbackLines
self.termTypePreference = termTypePreference
launchDirectory = initialDirectory
super.init()
}
var view: NSView {
terminalView
}
var surface: ghostty_surface_t? {
terminalView.surface
}
func start() {
guard !hasStarted else { return }
hasStarted = true
hasExited = false
guard let runtime = GhosttyRuntime.shared else {
hasExited = true
onExit?()
return
}
guard terminalView.startIfNeeded(
app: runtime.app,
fontSize: fontSize,
shellCommand: shellCommand(),
workingDirectory: launchDirectory,
environment: terminalEnvironment()
) else {
hasExited = true
onExit?()
return
}
onTitleChange?((resolveShell() as NSString).lastPathComponent)
onCurrentDirectoryChange?(launchDirectory)
applyLiveConfig()
}
func terminate() {
terminalView.closeSurface()
hasStarted = false
handleProcessExit()
}
func focus() {
terminalView.focusTerminal()
}
func updateFontSize(_ size: CGFloat) {
fontSize = size
applyLiveConfig()
}
func updateTheme(_ theme: TerminalTheme) {
self.theme = theme
applyLiveConfig()
}
func updateScrollbackLines(_ scrollbackLines: Int) {
self.scrollbackLines = scrollbackLines
applyLiveConfig()
}
func handleTitleChanged(_ title: String) {
onTitleChange?(title)
}
func handleCurrentDirectoryChanged(_ directory: String?) {
onCurrentDirectoryChange?(directory)
}
func handleProcessExit() {
guard !hasExited else { return }
hasExited = true
onExit?()
}
func handleCommandFinished() {
terminalView.handleCommandFinished()
}
func handleSurfaceClosed(processAlive: Bool) {
if processAlive || ghosttySurfaceHasExited {
handleProcessExit()
}
}
func setPointerStyle(_ shape: ghostty_action_mouse_shape_e) {
terminalView.setPointerStyle(shape)
}
func setPointerVisible(_ visible: Bool) {
terminalView.setPointerVisible(visible)
}
private var ghosttySurfaceHasExited: Bool {
guard let surface else { return false }
return ghostty_surface_process_exited(surface)
}
private func applyLiveConfig() {
guard let surface else { return }
let scrollbackBytes = estimatedScrollbackBytes()
_ = GhosttyConfigBuilder.withConfig(
fontSize: fontSize,
theme: theme,
scrollbackBytes: scrollbackBytes
) { config in
ghostty_surface_update_config(surface, config)
}
}
private func estimatedScrollbackBytes() -> Int {
let display = AppSettingsController.shared.settings.display
return TerminalScrollbackEstimator.estimate(
scrollbackLines: scrollbackLines,
fontSize: Double(fontSize),
openWidth: display.openWidth,
openHeight: display.openHeight
).bytes
}
private func resolveShell() -> String {
let custom = configuredShellPath.trimmingCharacters(in: .whitespacesAndNewlines)
if !custom.isEmpty && FileManager.default.isExecutableFile(atPath: custom) {
return custom
}
let environmentShell = ProcessInfo.processInfo.environment["SHELL"]?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if FileManager.default.isExecutableFile(atPath: environmentShell) {
return environmentShell
}
return "/bin/zsh"
}
private func shellCommand() -> String {
"\(resolveShell()) -l"
}
private func terminalEnvironment() -> [String: String] {
[
"TERM": termTypePreference.resolvedTermValue(for: .ghostty),
"COLORTERM": "truecolor"
]
}
}
@MainActor
private final class GhosttyTerminalView: NSView, @preconcurrency NSTextInputClient {
private unowned let backend: GhosttyBackendSession
private let markedTextBuffer = NSMutableAttributedString()
private let mouseCaptureCoordinator = TerminalMouseCaptureCoordinator()
private var keyTextAccumulator: [String]?
private var trackingAreaToken: NSTrackingArea?
var surface: ghostty_surface_t?
override var acceptsFirstResponder: Bool { true }
init(backend: GhosttyBackendSession) {
self.backend = backend
super.init(frame: NSRect(x: 0, y: 0, width: 600, height: 300))
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let surface {
ghostty_surface_free(surface)
}
}
override func layout() {
super.layout()
refreshSurfaceMetrics()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
refreshSurfaceMetrics()
}
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
refreshSurfaceMetrics()
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let trackingAreaToken {
removeTrackingArea(trackingAreaToken)
}
let trackingAreaToken = NSTrackingArea(
rect: .zero,
options: [.activeAlways, .inVisibleRect, .mouseEnteredAndExited, .mouseMoved],
owner: self,
userInfo: nil
)
addTrackingArea(trackingAreaToken)
self.trackingAreaToken = trackingAreaToken
}
override func becomeFirstResponder() -> Bool {
let didBecomeFirstResponder = super.becomeFirstResponder()
if didBecomeFirstResponder {
setSurfaceFocused(true)
}
return didBecomeFirstResponder
}
override func resignFirstResponder() -> Bool {
let didResignFirstResponder = super.resignFirstResponder()
if didResignFirstResponder {
setSurfaceFocused(false)
}
return didResignFirstResponder
}
func startIfNeeded(
app: ghostty_app_t,
fontSize: CGFloat,
shellCommand: String,
workingDirectory: String,
environment: [String: String]
) -> Bool {
guard surface == nil else { return true }
var surfaceConfig = ghostty_surface_config_new()
surfaceConfig.userdata = Unmanaged.passUnretained(backend).toOpaque()
surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS
surfaceConfig.platform = ghostty_platform_u(
macos: ghostty_platform_macos_s(nsview: Unmanaged.passUnretained(self).toOpaque())
)
surfaceConfig.scale_factor = effectiveScaleFactor
surfaceConfig.font_size = Float(fontSize)
surfaceConfig.context = GHOSTTY_SURFACE_CONTEXT_WINDOW
let allocatedEnvironment = makeEnvironmentVariables(environment)
defer {
for pointer in allocatedEnvironment.allocatedPointers {
free(pointer)
}
}
let createdSurface: ghostty_surface_t?
if allocatedEnvironment.envVars.isEmpty {
surfaceConfig.env_vars = nil
surfaceConfig.env_var_count = 0
createdSurface = workingDirectory.withCString { currentDirectoryPointer in
surfaceConfig.working_directory = currentDirectoryPointer
return shellCommand.withCString { shellCommandPointer in
surfaceConfig.command = shellCommandPointer
return ghostty_surface_new(app, &surfaceConfig)
}
}
} else {
createdSurface = allocatedEnvironment.envVars.withUnsafeBufferPointer { buffer in
surfaceConfig.env_vars = UnsafeMutablePointer(mutating: buffer.baseAddress)
surfaceConfig.env_var_count = buffer.count
return workingDirectory.withCString { currentDirectoryPointer in
surfaceConfig.working_directory = currentDirectoryPointer
return shellCommand.withCString { shellCommandPointer in
surfaceConfig.command = shellCommandPointer
return ghostty_surface_new(app, &surfaceConfig)
}
}
}
}
guard let createdSurface else { return false }
surface = createdSurface
refreshSurfaceMetrics()
setSurfaceFocused(window?.firstResponder === self)
return true
}
private func makeEnvironmentVariables(
_ environment: [String: String]
) -> (envVars: [ghostty_env_var_s], allocatedPointers: [UnsafeMutablePointer<CChar>?]) {
let sortedEnvironment = environment.sorted { $0.key < $1.key }
guard !sortedEnvironment.isEmpty else { return ([], []) }
var envVars = Array(
repeating: ghostty_env_var_s(key: nil, value: nil),
count: sortedEnvironment.count
)
var allocatedPointers: [UnsafeMutablePointer<CChar>?] = []
allocatedPointers.reserveCapacity(sortedEnvironment.count * 2)
for (index, entry) in sortedEnvironment.enumerated() {
let keyPointer = strdup(entry.key)
let valuePointer = strdup(entry.value)
allocatedPointers.append(keyPointer)
allocatedPointers.append(valuePointer)
envVars[index] = ghostty_env_var_s(key: UnsafePointer(keyPointer), value: UnsafePointer(valuePointer))
}
return (envVars, allocatedPointers)
}
func closeSurface() {
guard let surface else { return }
self.surface = nil
ghostty_surface_free(surface)
}
func focusTerminal() {
guard let window else { return }
window.makeFirstResponder(self)
}
func handleCommandFinished() {
let mouseCaptured = surface.map(ghostty_surface_mouse_captured) ?? false
let requiresTerminalReset = mouseCaptureCoordinator.commandDidFinish(mouseCaptured: mouseCaptured)
if requiresTerminalReset {
resetTerminalState()
}
}
func setPointerStyle(_ shape: ghostty_action_mouse_shape_e) {
let cursor: NSCursor
switch shape {
case GHOSTTY_MOUSE_SHAPE_TEXT:
cursor = .iBeam
case GHOSTTY_MOUSE_SHAPE_POINTER:
cursor = .pointingHand
case GHOSTTY_MOUSE_SHAPE_CROSSHAIR:
cursor = .crosshair
case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED:
cursor = .operationNotAllowed
case GHOSTTY_MOUSE_SHAPE_GRAB, GHOSTTY_MOUSE_SHAPE_GRABBING:
cursor = .openHand
case GHOSTTY_MOUSE_SHAPE_E_RESIZE, GHOSTTY_MOUSE_SHAPE_W_RESIZE, GHOSTTY_MOUSE_SHAPE_EW_RESIZE:
cursor = .resizeLeftRight
case GHOSTTY_MOUSE_SHAPE_N_RESIZE, GHOSTTY_MOUSE_SHAPE_S_RESIZE, GHOSTTY_MOUSE_SHAPE_NS_RESIZE:
cursor = .resizeUpDown
default:
cursor = .arrow
}
cursor.set()
}
func setPointerVisible(_ visible: Bool) {
NSCursor.setHiddenUntilMouseMoves(!visible)
}
override func keyDown(with event: NSEvent) {
guard let surface else {
interpretKeyEvents([event])
return
}
if shouldBypassEnhancedKeyboardInput() {
handlePlainKeyDown(event, surface: surface)
return
}
if event.keyCode == 36 || event.keyCode == 76 {
mouseCaptureCoordinator.userDidSubmitCommand()
}
let translationFlags = ghosttySurfaceModifierFlags(
ghostty_surface_key_translation_mods(surface, ghosttyMods(event.modifierFlags))
)
var translatedModifiers = event.modifierFlags
for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] {
if translationFlags.contains(flag) {
translatedModifiers.insert(flag)
} else {
translatedModifiers.remove(flag)
}
}
let translatedEvent: NSEvent
if translatedModifiers == event.modifierFlags {
translatedEvent = event
} else {
translatedEvent = NSEvent.keyEvent(
with: event.type,
location: event.locationInWindow,
modifierFlags: translatedModifiers,
timestamp: event.timestamp,
windowNumber: event.windowNumber,
context: nil,
characters: event.characters(byApplyingModifiers: translatedModifiers) ?? event.characters ?? "",
charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "",
isARepeat: event.isARepeat,
keyCode: event.keyCode
) ?? event
}
keyTextAccumulator = []
defer { keyTextAccumulator = nil }
interpretKeyEvents([translatedEvent])
syncPreedit()
if let keyTextAccumulator, !keyTextAccumulator.isEmpty {
for text in keyTextAccumulator {
_ = sendKey(
event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS,
event: event,
translationEvent: translatedEvent,
text: text,
composing: false
)
}
return
}
_ = sendKey(
event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS,
event: event,
translationEvent: translatedEvent,
text: translatedEvent.ghosttyCharacters,
composing: hasMarkedText()
)
}
override func keyUp(with event: NSEvent) {
if shouldBypassEnhancedKeyboardInput() {
return
}
_ = sendKey(GHOSTTY_ACTION_RELEASE, event: event, translationEvent: nil, text: nil, composing: false)
}
override func flagsChanged(with event: NSEvent) {
if shouldBypassEnhancedKeyboardInput() {
return
}
let modifierBit: UInt32
switch event.keyCode {
case 0x39:
modifierBit = GHOSTTY_MODS_CAPS.rawValue
case 0x38, 0x3C:
modifierBit = GHOSTTY_MODS_SHIFT.rawValue
case 0x3B, 0x3E:
modifierBit = GHOSTTY_MODS_CTRL.rawValue
case 0x3A, 0x3D:
modifierBit = GHOSTTY_MODS_ALT.rawValue
case 0x37, 0x36:
modifierBit = GHOSTTY_MODS_SUPER.rawValue
default:
return
}
let mods = ghosttyMods(event.modifierFlags)
let action: ghostty_input_action_e = mods.rawValue & modifierBit != 0 ? GHOSTTY_ACTION_PRESS : GHOSTTY_ACTION_RELEASE
_ = sendKey(action, event: event, translationEvent: nil, text: nil, composing: false)
}
override func doCommand(by selector: Selector) {}
override func mouseEntered(with event: NSEvent) {
super.mouseEntered(with: event)
guard shouldForwardMouseInput() else { return }
sendMousePosition(for: event)
}
override func mouseExited(with event: NSEvent) {
super.mouseExited(with: event)
guard let surface else { return }
guard shouldForwardMouseInput() else { return }
ghostty_surface_mouse_pos(surface, -1, -1, ghosttyMods(event.modifierFlags))
}
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
guard shouldForwardMouseInput() else { return }
sendMousePosition(for: event)
}
override func mouseDragged(with event: NSEvent) {
mouseMoved(with: event)
}
override func rightMouseDragged(with event: NSEvent) {
mouseMoved(with: event)
}
override func otherMouseDragged(with event: NSEvent) {
mouseMoved(with: event)
}
override func mouseDown(with event: NSEvent) {
guard shouldForwardMouseInput() else { return }
sendMouseButton(GHOSTTY_MOUSE_PRESS, event: event, button: GHOSTTY_MOUSE_LEFT)
}
override func mouseUp(with event: NSEvent) {
guard shouldForwardMouseInput() else { return }
sendMouseButton(GHOSTTY_MOUSE_RELEASE, event: event, button: GHOSTTY_MOUSE_LEFT)
}
override func rightMouseDown(with event: NSEvent) {
guard shouldForwardMouseInput() else { return }
sendMouseButton(GHOSTTY_MOUSE_PRESS, event: event, button: GHOSTTY_MOUSE_RIGHT)
}
override func rightMouseUp(with event: NSEvent) {
guard shouldForwardMouseInput() else { return }
sendMouseButton(GHOSTTY_MOUSE_RELEASE, event: event, button: GHOSTTY_MOUSE_RIGHT)
}
override func otherMouseDown(with event: NSEvent) {
guard shouldForwardMouseInput() else { return }
sendMouseButton(GHOSTTY_MOUSE_PRESS, event: event, button: ghosttyMouseButton(for: event.buttonNumber))
}
override func otherMouseUp(with event: NSEvent) {
guard shouldForwardMouseInput() else { return }
sendMouseButton(GHOSTTY_MOUSE_RELEASE, event: event, button: ghosttyMouseButton(for: event.buttonNumber))
}
override func scrollWheel(with event: NSEvent) {
guard let surface else { return }
guard shouldForwardMouseInput() else { return }
ghostty_surface_mouse_scroll(surface, event.scrollingDeltaX, event.scrollingDeltaY, 0)
}
func insertText(_ string: Any, replacementRange: NSRange) {
_ = replacementRange
let text: String
switch string {
case let attributedString as NSAttributedString:
text = attributedString.string
case let string as String:
text = string
default:
return
}
unmarkText()
if keyTextAccumulator != nil {
keyTextAccumulator?.append(text)
return
}
sendText(text)
}
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
_ = selectedRange
_ = replacementRange
switch string {
case let attributedString as NSAttributedString:
markedTextBuffer.setAttributedString(attributedString)
case let string as String:
markedTextBuffer.setAttributedString(NSAttributedString(string: string))
default:
markedTextBuffer.setAttributedString(NSAttributedString(string: ""))
}
syncPreedit()
}
func unmarkText() {
markedTextBuffer.mutableString.setString("")
syncPreedit(clearIfNeeded: true)
}
func selectedRange() -> NSRange {
NSRange(location: NSNotFound, length: 0)
}
func markedRange() -> NSRange {
if markedTextBuffer.length == 0 {
return NSRange(location: NSNotFound, length: 0)
}
return NSRange(location: 0, length: markedTextBuffer.length)
}
func hasMarkedText() -> Bool {
markedTextBuffer.length > 0
}
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
actualRange?.pointee = range
return nil
}
func validAttributesForMarkedText() -> [NSAttributedString.Key] {
[]
}
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
_ = range
actualRange?.pointee = NSRange(location: NSNotFound, length: 0)
guard let surface else { return .zero }
var x: Double = 0
var y: Double = 0
var width: Double = 0
var height: Double = 0
ghostty_surface_ime_point(surface, &x, &y, &width, &height)
let windowRect = convert(NSRect(x: x, y: bounds.height - y - height, width: width, height: height), to: nil)
guard let window else { return windowRect }
return window.convertToScreen(windowRect)
}
func characterIndex(for point: NSPoint) -> Int {
_ = point
return 0
}
private func sendKey(
_ action: ghostty_input_action_e,
event: NSEvent,
translationEvent: NSEvent?,
text: String?,
composing: Bool
) -> Bool {
guard let surface else { return false }
var keyEvent = event.ghosttyKeyEvent(action, translationMods: translationEvent?.modifierFlags)
keyEvent.composing = composing
if let text, !text.isEmpty {
return text.withCString { pointer in
keyEvent.text = pointer
return ghostty_surface_key(surface, keyEvent)
}
}
return ghostty_surface_key(surface, keyEvent)
}
private func handlePlainKeyDown(_ event: NSEvent, surface: ghostty_surface_t) {
if let sequence = TerminalCommandArrowBehavior.sequence(
for: event.modifierFlags,
keyCode: event.keyCode,
applicationCursor: false
) {
sendBytes(sequence, to: surface)
return
}
if let controlSequence = plainControlSequence(for: event) {
sendBytes(controlSequence, to: surface)
if event.keyCode == 36 || event.keyCode == 76 {
mouseCaptureCoordinator.userDidSubmitCommand()
}
return
}
keyTextAccumulator = []
defer { keyTextAccumulator = nil }
interpretKeyEvents([event])
syncPreedit()
if let keyTextAccumulator, !keyTextAccumulator.isEmpty {
for text in keyTextAccumulator where !text.isEmpty {
sendText(text)
}
return
}
if let text = event.ghosttyCharacters, !text.isEmpty {
sendText(text)
}
}
private func plainControlSequence(for event: NSEvent) -> [UInt8]? {
if event.keyCode == 36 || event.keyCode == 76 {
return [0x0D]
}
if event.keyCode == 48 {
return [0x09]
}
if event.keyCode == 51 {
return [0x7F]
}
if event.keyCode == 53 {
return [0x1B]
}
guard event.modifierFlags.intersection([.control, .option, .command, .shift]) == [.control],
let baseCharacter = event.charactersIgnoringModifiers?.unicodeScalars.first else {
return nil
}
switch baseCharacter.value {
case 0x40, 0x60:
return [0x00]
case 0x20:
return [0x00]
case 0x32:
return [0x00]
case 0x33:
return [0x1B]
case 0x34:
return [0x1C]
case 0x35:
return [0x1D]
case 0x36:
return [0x1E]
case 0x37:
return [0x1F]
case 0x38:
return [0x7F]
case 0x3F:
return [0x7F]
case 0x41...0x5A, 0x61...0x7A:
return [UInt8(baseCharacter.value & 0x1F)]
default:
return nil
}
}
private func shouldForwardMouseInput() -> Bool {
guard let surface else { return false }
return mouseCaptureCoordinator.shouldForwardMouseInput(
mouseCaptured: ghostty_surface_mouse_captured(surface)
)
}
private func shouldBypassEnhancedKeyboardInput() -> Bool {
guard let surface else { return false }
return mouseCaptureCoordinator.shouldBypassEnhancedKeyboardInput(
mouseCaptured: ghostty_surface_mouse_captured(surface)
)
}
private func resetTerminalState() {
guard let surface else { return }
let action = "reset"
action.withCString { pointer in
_ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count))
}
}
private func sendBytes(_ bytes: [UInt8], to surface: ghostty_surface_t) {
guard !bytes.isEmpty else { return }
bytes.withUnsafeBufferPointer { buffer in
guard let baseAddress = buffer.baseAddress else { return }
let pointer = UnsafeRawPointer(baseAddress).assumingMemoryBound(to: CChar.self)
ghostty_surface_text(surface, pointer, UInt(buffer.count))
}
}
private func sendText(_ text: String) {
guard let surface, !text.isEmpty else { return }
text.withCString { pointer in
ghostty_surface_text(surface, pointer, UInt(text.utf8.count))
}
}
private func syncPreedit(clearIfNeeded: Bool = true) {
guard let surface else { return }
if markedTextBuffer.length > 0 {
let text = markedTextBuffer.string
text.withCString { pointer in
ghostty_surface_preedit(surface, pointer, UInt(text.utf8.count))
}
} else if clearIfNeeded {
ghostty_surface_preedit(surface, nil, 0)
}
}
private func sendMouseButton(
_ state: ghostty_input_mouse_state_e,
event: NSEvent,
button: ghostty_input_mouse_button_e
) {
guard let surface else { return }
ghostty_surface_mouse_button(surface, state, button, ghosttyMods(event.modifierFlags))
}
private func sendMousePosition(for event: NSEvent) {
guard let surface else { return }
let position = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(
surface,
position.x,
frame.height - position.y,
ghosttyMods(event.modifierFlags)
)
}
private func refreshSurfaceMetrics() {
guard let surface else { return }
let logicalSize = CGSize(width: max(bounds.width, 1), height: max(bounds.height, 1))
let backingRect = convertToBacking(NSRect(origin: .zero, size: logicalSize))
let xScale = logicalSize.width > 0 ? backingRect.width / logicalSize.width : effectiveScaleFactor
let yScale = logicalSize.height > 0 ? backingRect.height / logicalSize.height : effectiveScaleFactor
ghostty_surface_set_content_scale(surface, xScale, yScale)
ghostty_surface_set_size(
surface,
UInt32(max(1, Int(backingRect.width))),
UInt32(max(1, Int(backingRect.height)))
)
if let window,
let screenNumber = window.screen?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber {
ghostty_surface_set_display_id(surface, screenNumber.uint32Value)
}
}
private func setSurfaceFocused(_ focused: Bool) {
guard let surface else { return }
ghostty_surface_set_focus(surface, focused)
}
private var effectiveScaleFactor: Double {
Double(window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2)
}
private func ghosttyMouseButton(for buttonNumber: Int) -> ghostty_input_mouse_button_e {
switch buttonNumber {
case 0:
return GHOSTTY_MOUSE_LEFT
case 1:
return GHOSTTY_MOUSE_RIGHT
case 2:
return GHOSTTY_MOUSE_MIDDLE
case 3:
return GHOSTTY_MOUSE_FOUR
case 4:
return GHOSTTY_MOUSE_FIVE
case 5:
return GHOSTTY_MOUSE_SIX
case 6:
return GHOSTTY_MOUSE_SEVEN
case 7:
return GHOSTTY_MOUSE_EIGHT
case 8:
return GHOSTTY_MOUSE_NINE
case 9:
return GHOSTTY_MOUSE_TEN
case 10:
return GHOSTTY_MOUSE_ELEVEN
default:
return GHOSTTY_MOUSE_UNKNOWN
}
}
private func ghosttyMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue
if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
if flags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue }
if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue }
if flags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue }
if flags.contains(.capsLock) { mods |= GHOSTTY_MODS_CAPS.rawValue }
let rawFlags = flags.rawValue
if rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0 { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }
if rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0 { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }
if rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0 { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue }
if rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0 { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue }
return ghostty_input_mods_e(mods)
}
private func ghosttySurfaceModifierFlags(_ mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags {
var flags = NSEvent.ModifierFlags()
if mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0 { flags.insert(.shift) }
if mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0 { flags.insert(.control) }
if mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0 { flags.insert(.option) }
if mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0 { flags.insert(.command) }
return flags
}
}
private extension NSEvent {
func ghosttyKeyEvent(
_ action: ghostty_input_action_e,
translationMods: NSEvent.ModifierFlags? = nil
) -> ghostty_input_key_s {
var keyEvent = ghostty_input_key_s()
keyEvent.action = action
keyEvent.keycode = UInt32(keyCode)
keyEvent.text = nil
keyEvent.composing = false
keyEvent.mods = ghosttyModifierFlags(modifierFlags)
keyEvent.consumed_mods = ghosttyModifierFlags(
(translationMods ?? modifierFlags).subtracting([.control, .command])
)
if type == .keyDown || type == .keyUp,
let characters = characters(byApplyingModifiers: []),
let codepoint = characters.unicodeScalars.first {
keyEvent.unshifted_codepoint = codepoint.value
} else {
keyEvent.unshifted_codepoint = 0
}
return keyEvent
}
var ghosttyCharacters: String? {
guard let characters else { return nil }
if characters.count == 1,
let scalar = characters.unicodeScalars.first {
if scalar.value < 0x20 {
return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control))
}
if scalar.value >= 0xF700 && scalar.value <= 0xF8FF {
return nil
}
}
return characters
}
private func ghosttyModifierFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue
if flags.contains(.shift) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
if flags.contains(.control) { mods |= GHOSTTY_MODS_CTRL.rawValue }
if flags.contains(.option) { mods |= GHOSTTY_MODS_ALT.rawValue }
if flags.contains(.command) { mods |= GHOSTTY_MODS_SUPER.rawValue }
if flags.contains(.capsLock) { mods |= GHOSTTY_MODS_CAPS.rawValue }
let rawFlags = flags.rawValue
if rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0 { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }
if rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0 { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }
if rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0 { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue }
if rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0 { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue }
return ghostty_input_mods_e(mods)
}
}