初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)
- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连 - M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire - M1.5: tmux -CC 原生 tab(MVP) - 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/ - 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口) - 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名 - 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
346
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalKeyEventHandler@AppKit.swift
vendored
Normal file
346
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalKeyEventHandler@AppKit.swift
vendored
Normal file
@@ -0,0 +1,346 @@
|
||||
//
|
||||
// TerminalKeyEventHandler@AppKit.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
// Reference:
|
||||
// - ghostty-org/ghostty
|
||||
// - macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
|
||||
// Translation modifiers, interpretKeyEvents dispatch, and text emission are
|
||||
// kept close to Ghostty's native AppKit implementation to reduce long-term
|
||||
// drift from upstream keyboard/IME semantics.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
final class TerminalKeyEventHandler {
|
||||
private weak var view: AppTerminalView?
|
||||
var inputMethodHandler: TerminalTextInputHandler?
|
||||
private var interpretedCommandSelector: Selector?
|
||||
|
||||
init(view: AppTerminalView) {
|
||||
self.view = view
|
||||
inputMethodHandler = TerminalTextInputHandler(view: view)
|
||||
}
|
||||
|
||||
nonisolated static func shouldUseDirectInput(
|
||||
modifierFlags: NSEvent.ModifierFlags
|
||||
) -> Bool {
|
||||
modifierFlags.intersection([.shift, .control, .option, .command]).isEmpty
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplayInterpretedCommand(
|
||||
_ selector: Selector
|
||||
) -> Bool {
|
||||
// AppKit sometimes resolves non-text keys into editing commands
|
||||
// (for example Shift-Tab -> insertBacktab:). In a terminal, those
|
||||
// commands still need to reach Ghostty as the original hardware key.
|
||||
let _ = selector
|
||||
return true
|
||||
}
|
||||
|
||||
func handleKeyDown(with event: NSEvent) {
|
||||
guard let view, let surface = view.surface else { return }
|
||||
|
||||
if handleDirectInputIfNeeded(event) {
|
||||
return
|
||||
}
|
||||
|
||||
let action: ghostty_input_action_e = event.isARepeat
|
||||
? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
|
||||
let translationEvent = translatedEvent(for: event, on: surface)
|
||||
|
||||
inputMethodHandler?.startCollectingText()
|
||||
interpretedCommandSelector = nil
|
||||
let markedTextBefore = inputMethodHandler?.hasMarkedText == true
|
||||
let keyboardIdBefore = markedTextBefore ? nil : KeyboardLayout.id
|
||||
view.lastPerformKeyEvent = nil
|
||||
view.interpretKeyEvents([translationEvent])
|
||||
if !markedTextBefore, keyboardIdBefore != KeyboardLayout.id {
|
||||
_ = inputMethodHandler?.finishCollectingText()
|
||||
return
|
||||
}
|
||||
inputMethodHandler?.syncPreedit(clearIfNeeded: markedTextBefore)
|
||||
|
||||
if let collected = inputMethodHandler?.finishCollectingText() {
|
||||
var input = event.buildKeyInput(
|
||||
action: action,
|
||||
translationModifiers: translationEvent.modifierFlags
|
||||
)
|
||||
for text in collected {
|
||||
text.withCString { ptr in
|
||||
input.text = ptr
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let selector = interpretedCommandSelector {
|
||||
interpretedCommandSelector = nil
|
||||
if Self.shouldReplayInterpretedCommand(selector) {
|
||||
sendKeyEvent(
|
||||
for: event,
|
||||
translationEvent: translationEvent,
|
||||
action: action,
|
||||
to: surface,
|
||||
includeText: false,
|
||||
composing: inputMethodHandler?.hasMarkedText == true || markedTextBefore
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sendKeyEvent(
|
||||
for: event,
|
||||
translationEvent: translationEvent,
|
||||
action: action,
|
||||
to: surface,
|
||||
includeText: true,
|
||||
composing: inputMethodHandler?.hasMarkedText == true || markedTextBefore
|
||||
)
|
||||
}
|
||||
|
||||
func handleKeyUp(with event: NSEvent) {
|
||||
guard let view, let surface = view.surface else { return }
|
||||
if shouldBypassGhosttyForDirectInput(event) {
|
||||
return
|
||||
}
|
||||
var input = event.buildKeyInput(action: GHOSTTY_ACTION_RELEASE)
|
||||
input.text = nil
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
|
||||
func handleFlagsChanged(with event: NSEvent) {
|
||||
guard let surface = view?.surface else { return }
|
||||
guard inputMethodHandler?.hasMarkedText != true else { return }
|
||||
|
||||
let mod: UInt32
|
||||
switch event.keyCode {
|
||||
case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue
|
||||
case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue
|
||||
case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue
|
||||
case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue
|
||||
case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue
|
||||
default: return
|
||||
}
|
||||
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags).ghosttyMods
|
||||
|
||||
var action = GHOSTTY_ACTION_RELEASE
|
||||
if mods.rawValue & mod != 0 {
|
||||
let sidePressed: Bool = switch event.keyCode {
|
||||
case 0x3C:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERSHIFTKEYMASK) != 0
|
||||
case 0x3E:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERCTLKEYMASK) != 0
|
||||
case 0x3D:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERALTKEYMASK) != 0
|
||||
case 0x36:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERCMDKEYMASK) != 0
|
||||
default:
|
||||
true
|
||||
}
|
||||
|
||||
if sidePressed {
|
||||
action = GHOSTTY_ACTION_PRESS
|
||||
}
|
||||
}
|
||||
|
||||
var input = event.buildKeyInput(action: action)
|
||||
input.text = nil
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
|
||||
private func sendKeyEvent(
|
||||
for event: NSEvent,
|
||||
translationEvent: NSEvent,
|
||||
action: ghostty_input_action_e,
|
||||
to surface: TerminalSurface,
|
||||
includeText: Bool,
|
||||
composing: Bool = false
|
||||
) {
|
||||
var input = event.buildKeyInput(
|
||||
action: action,
|
||||
translationModifiers: translationEvent.modifierFlags
|
||||
)
|
||||
input.composing = composing
|
||||
guard includeText,
|
||||
let chars = translationEvent.filteredCharacters,
|
||||
!chars.isEmpty
|
||||
else {
|
||||
surface.sendKeyEvent(input)
|
||||
return
|
||||
}
|
||||
|
||||
chars.withCString { ptr in
|
||||
input.text = ptr
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDirectInputIfNeeded(_ event: NSEvent) -> Bool {
|
||||
guard let view else { return false }
|
||||
// During IME composition, AppKit needs to keep ownership of editing
|
||||
// commands so marked text can shrink, cancel, and move correctly.
|
||||
guard inputMethodHandler?.hasMarkedText != true else { return false }
|
||||
guard Self.shouldUseDirectInput(modifierFlags: event.modifierFlags) else {
|
||||
return false
|
||||
}
|
||||
let delivery = TerminalHardwareKeyRouter.routeAppKit(
|
||||
keyCode: event.keyCode,
|
||||
backend: view.configuration.backend
|
||||
)
|
||||
guard case let .data(sequence) = delivery else { return false }
|
||||
guard case let .inMemory(session) = view.configuration.backend else { return false }
|
||||
|
||||
session.sendInput(sequence)
|
||||
return true
|
||||
}
|
||||
|
||||
private func shouldBypassGhosttyForDirectInput(_ event: NSEvent) -> Bool {
|
||||
guard let view else { return false }
|
||||
guard Self.shouldUseDirectInput(modifierFlags: event.modifierFlags) else {
|
||||
return false
|
||||
}
|
||||
return TerminalHardwareKeyRouter.routeAppKit(
|
||||
keyCode: event.keyCode,
|
||||
backend: view.configuration.backend
|
||||
).isDirectInput
|
||||
}
|
||||
|
||||
private func translatedEvent(
|
||||
for event: NSEvent,
|
||||
on surface: TerminalSurface
|
||||
) -> NSEvent {
|
||||
guard let rawSurface = surface.rawValue else {
|
||||
return event
|
||||
}
|
||||
|
||||
let translatedMods = eventModifierFlags(
|
||||
from: ghostty_surface_key_translation_mods(
|
||||
rawSurface,
|
||||
TerminalInputModifiers(from: event.modifierFlags).ghosttyMods
|
||||
)
|
||||
)
|
||||
|
||||
var finalModifiers = event.modifierFlags
|
||||
for flag in [
|
||||
NSEvent.ModifierFlags.shift,
|
||||
.control,
|
||||
.option,
|
||||
.command,
|
||||
] {
|
||||
if translatedMods.contains(flag) {
|
||||
finalModifiers.insert(flag)
|
||||
} else {
|
||||
finalModifiers.remove(flag)
|
||||
}
|
||||
}
|
||||
|
||||
guard finalModifiers != event.modifierFlags else {
|
||||
return event
|
||||
}
|
||||
|
||||
return NSEvent.keyEvent(
|
||||
with: event.type,
|
||||
location: event.locationInWindow,
|
||||
modifierFlags: finalModifiers,
|
||||
timestamp: event.timestamp,
|
||||
windowNumber: event.windowNumber,
|
||||
context: nil,
|
||||
characters: event.characters(byApplyingModifiers: finalModifiers) ?? "",
|
||||
charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "",
|
||||
isARepeat: event.isARepeat,
|
||||
keyCode: event.keyCode
|
||||
) ?? event
|
||||
}
|
||||
|
||||
private func eventModifierFlags(from 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
|
||||
}
|
||||
|
||||
func recordInterpretedCommand(_ selector: Selector) {
|
||||
interpretedCommandSelector = selector
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSEvent Terminal Input Helpers
|
||||
|
||||
extension NSEvent {
|
||||
func buildKeyInput(
|
||||
action: ghostty_input_action_e,
|
||||
translationModifiers: NSEvent.ModifierFlags? = nil
|
||||
) -> ghostty_input_key_s {
|
||||
var input = ghostty_input_key_s()
|
||||
input.action = action
|
||||
input.keycode = UInt32(keyCode)
|
||||
input.composing = false
|
||||
input.text = nil
|
||||
|
||||
input.mods = TerminalInputModifiers(from: modifierFlags).ghosttyMods
|
||||
|
||||
// Consumed modifiers: modifiers the key binding system should
|
||||
// treat as already handled by text generation. We pass through
|
||||
// all modifiers except control and command, which should remain
|
||||
// available for keybind matching.
|
||||
var consumedFlags = translationModifiers ?? modifierFlags
|
||||
consumedFlags.remove(.control)
|
||||
consumedFlags.remove(.command)
|
||||
input.consumed_mods = TerminalInputModifiers(from: consumedFlags).ghosttyMods
|
||||
|
||||
if type == .keyDown || type == .keyUp,
|
||||
let chars = characters(byApplyingModifiers: []),
|
||||
let codepoint = chars.unicodeScalars.first
|
||||
{
|
||||
input.unshifted_codepoint = codepoint.value
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
var filteredCharacters: String? {
|
||||
guard let filtered = TerminalInputText.filteredFunctionKeyText(characters) else {
|
||||
return nil
|
||||
}
|
||||
guard filtered.count == 1,
|
||||
let scalar = filtered.unicodeScalars.first
|
||||
else {
|
||||
return filtered
|
||||
}
|
||||
|
||||
// macOS encodes function keys as Private Use Area scalars —
|
||||
// these have no printable representation.
|
||||
// When the control modifier produces a raw control character,
|
||||
// re-derive printable text without the control modifier so
|
||||
// Ghostty can map the physical key correctly.
|
||||
if scalar.isASCIIControl {
|
||||
var flags = modifierFlags
|
||||
flags.remove(.control)
|
||||
return TerminalInputText.filteredFunctionKeyText(
|
||||
characters(byApplyingModifiers: flags)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
extension UnicodeScalar {
|
||||
var isASCIIControl: Bool {
|
||||
value < 0x20
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user