1378 lines
44 KiB
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)
|
|
}
|
|
}
|