初始提交: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:
295
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Input.swift
vendored
Normal file
295
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Input.swift
vendored
Normal file
@@ -0,0 +1,295 @@
|
||||
//
|
||||
// AppTerminalView+Input.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
extension AppTerminalView {
|
||||
override open func keyDown(with event: NSEvent) {
|
||||
inputHandler?.handleKeyDown(with: event)
|
||||
}
|
||||
|
||||
override open func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
guard event.type == .keyDown else { return false }
|
||||
guard window?.firstResponder === self else { return false }
|
||||
guard let surface else { return false }
|
||||
|
||||
if keyIsBinding(event, on: surface) {
|
||||
keyDown(with: event)
|
||||
return true
|
||||
}
|
||||
|
||||
let equivalent: String
|
||||
switch event.charactersIgnoringModifiers {
|
||||
case "\r":
|
||||
guard event.modifierFlags.contains(.control) else {
|
||||
return false
|
||||
}
|
||||
equivalent = "\r"
|
||||
|
||||
case "/":
|
||||
guard event.modifierFlags.contains(.control),
|
||||
event.modifierFlags.isDisjoint(with: [.shift, .command, .option])
|
||||
else {
|
||||
return false
|
||||
}
|
||||
equivalent = "_"
|
||||
|
||||
default:
|
||||
if event.timestamp == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if !event.modifierFlags.contains(.command),
|
||||
!event.modifierFlags.contains(.control)
|
||||
{
|
||||
lastPerformKeyEvent = nil
|
||||
return false
|
||||
}
|
||||
|
||||
if let lastPerformKeyEvent,
|
||||
lastPerformKeyEvent == event.timestamp
|
||||
{
|
||||
self.lastPerformKeyEvent = nil
|
||||
equivalent = event.characters ?? ""
|
||||
break
|
||||
}
|
||||
|
||||
lastPerformKeyEvent = event.timestamp
|
||||
return false
|
||||
}
|
||||
|
||||
guard let translatedEvent = 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
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
|
||||
keyDown(with: translatedEvent)
|
||||
return true
|
||||
}
|
||||
|
||||
override open func keyUp(with event: NSEvent) {
|
||||
inputHandler?.handleKeyUp(with: event)
|
||||
}
|
||||
|
||||
override open func flagsChanged(with event: NSEvent) {
|
||||
inputHandler?.handleFlagsChanged(with: event)
|
||||
}
|
||||
|
||||
override open func doCommand(by selector: Selector) {
|
||||
if let lastPerformKeyEvent,
|
||||
let current = NSApp.currentEvent,
|
||||
lastPerformKeyEvent == current.timestamp
|
||||
{
|
||||
NSApp.sendEvent(current)
|
||||
return
|
||||
}
|
||||
|
||||
if TerminalKeyEventHandler.shouldReplayInterpretedCommand(selector) {
|
||||
inputHandler?.recordInterpretedCommand(selector)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction open func copy(_: Any?) {
|
||||
_ = copySelectedTextToPasteboard()
|
||||
}
|
||||
|
||||
@IBAction func paste(_: Any?) {
|
||||
if let text = NSPasteboard.general.string(forType: .string) {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"paste binding bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text))"
|
||||
)
|
||||
}
|
||||
_ = surface?.performBindingAction("paste_from_clipboard")
|
||||
}
|
||||
|
||||
@IBAction override open func selectAll(_: Any?) {
|
||||
_ = surface?.performBindingAction("select_all")
|
||||
}
|
||||
|
||||
internal func mousePoint(from event: NSEvent) -> (x: CGFloat, y: CGFloat) {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
return (point.x, bounds.height - point.y)
|
||||
}
|
||||
|
||||
override open func mouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
pointerSelectionStartPoint = CGPoint(x: x, y: y)
|
||||
pendingSelectionMenuPoint = nil
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_LEFT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func mouseUp(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_LEFT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
finishPointerSelection(at: CGPoint(x: x, y: y))
|
||||
}
|
||||
|
||||
override open func rightMouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
if let menuPoint = selectionMenuPoint(at: CGPoint(x: x, y: y)) {
|
||||
pendingSelectionMenuPoint = menuPoint
|
||||
return
|
||||
}
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_RIGHT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func rightMouseUp(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
if pendingSelectionMenuPoint != nil {
|
||||
pendingSelectionMenuPoint = nil
|
||||
showSelectionCopyMenu(with: event)
|
||||
return
|
||||
}
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_RIGHT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func menu(for event: NSEvent) -> NSMenu? {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
guard selectionMenuPoint(at: CGPoint(x: x, y: y)) != nil else {
|
||||
return super.menu(for: event)
|
||||
}
|
||||
return selectionContextMenu()
|
||||
}
|
||||
|
||||
override open func otherMouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_MIDDLE,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func otherMouseUp(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_MIDDLE,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func mouseMoved(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
}
|
||||
|
||||
override open func mouseDragged(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
updatePointerSelectionRect(to: CGPoint(x: x, y: y))
|
||||
mouseMoved(with: event)
|
||||
}
|
||||
|
||||
override open func rightMouseDragged(with event: NSEvent) {
|
||||
mouseMoved(with: event)
|
||||
}
|
||||
|
||||
override open func otherMouseDragged(with event: NSEvent) {
|
||||
mouseMoved(with: event)
|
||||
}
|
||||
|
||||
override open func scrollWheel(with event: NSEvent) {
|
||||
let scrollMods = TerminalScrollModifiers(
|
||||
precision: event.hasPreciseScrollingDeltas,
|
||||
momentum: TerminalScrollModifiers.momentumFrom(phase: event.momentumPhase)
|
||||
)
|
||||
surface?.sendMouseScroll(
|
||||
x: event.scrollingDeltaX,
|
||||
y: event.scrollingDeltaY,
|
||||
mods: scrollMods.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
private func updatePointerSelectionRect(to point: CGPoint) {
|
||||
guard let start = pointerSelectionStartPoint else { return }
|
||||
lastPointerSelectionRect = CGRect(
|
||||
x: min(start.x, point.x),
|
||||
y: min(start.y, point.y),
|
||||
width: abs(start.x - point.x),
|
||||
height: abs(start.y - point.y)
|
||||
).insetBy(dx: -2, dy: -2)
|
||||
}
|
||||
|
||||
private func finishPointerSelection(at point: CGPoint) {
|
||||
defer { pointerSelectionStartPoint = nil }
|
||||
guard let start = pointerSelectionStartPoint else { return }
|
||||
let dragDistance = hypot(point.x - start.x, point.y - start.y)
|
||||
if dragDistance < 2 {
|
||||
lastPointerSelectionRect = nil
|
||||
} else {
|
||||
updatePointerSelectionRect(to: point)
|
||||
}
|
||||
}
|
||||
|
||||
private func showSelectionCopyMenu(with event: NSEvent) {
|
||||
let menu = selectionContextMenu()
|
||||
NSMenu.popUpContextMenu(menu, with: event, for: self)
|
||||
}
|
||||
|
||||
private func keyIsBinding(
|
||||
_ event: NSEvent,
|
||||
on surface: TerminalSurface
|
||||
) -> Bool {
|
||||
guard let rawSurface = surface.rawValue else {
|
||||
return false
|
||||
}
|
||||
|
||||
var keyEvent = event.buildKeyInput(action: GHOSTTY_ACTION_PRESS)
|
||||
var bindingFlags = ghostty_binding_flags_e(rawValue: 0)
|
||||
let text = event.characters ?? ""
|
||||
return text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
return ghostty_surface_key_is_binding(rawSurface, keyEvent, &bindingFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
230
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Lifecycle.swift
vendored
Normal file
230
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Lifecycle.swift
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
//
|
||||
// AppTerminalView+Lifecycle.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
|
||||
extension AppTerminalView {
|
||||
func setupTrackingArea() {
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.mouseMoved,
|
||||
.inVisibleRect,
|
||||
.activeAlways,
|
||||
]
|
||||
let area = NSTrackingArea(
|
||||
rect: bounds,
|
||||
options: options,
|
||||
owner: self,
|
||||
userInfo: nil
|
||||
)
|
||||
addTrackingArea(area)
|
||||
}
|
||||
|
||||
override open func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
trackingAreas.forEach { removeTrackingArea($0) }
|
||||
setupTrackingArea()
|
||||
}
|
||||
|
||||
override open var acceptsFirstResponder: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override open func becomeFirstResponder() -> Bool {
|
||||
let result = super.becomeFirstResponder()
|
||||
core.setFocus(true)
|
||||
onFocusChange?(true)
|
||||
return result
|
||||
}
|
||||
|
||||
override open func resignFirstResponder() -> Bool {
|
||||
let result = super.resignFirstResponder()
|
||||
core.setFocus(false)
|
||||
onFocusChange?(false)
|
||||
return result
|
||||
}
|
||||
|
||||
override open func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
removeWindowObservers()
|
||||
if window != nil {
|
||||
// SwiftUI/AppKit can temporarily detach and reattach the terminal view while
|
||||
// diffing the view hierarchy. Rebuilding on every reattach discards Ghostty's
|
||||
// scrollback/state, so only create a new surface when one does not already exist.
|
||||
if surface == nil {
|
||||
core.rebuildIfReady()
|
||||
} else {
|
||||
core.synchronizeMetrics()
|
||||
}
|
||||
updateMetalLayerMetrics()
|
||||
updateColorScheme()
|
||||
core.startDisplayLink()
|
||||
core.requestImmediateTick()
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidBecomeKey),
|
||||
name: NSWindow.didBecomeKeyNotification,
|
||||
object: window
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidResignKey),
|
||||
name: NSWindow.didResignKeyNotification,
|
||||
object: window
|
||||
)
|
||||
// Cross-display rescue: AppKit posts didChangeScreen when the
|
||||
// window's screen reference changes, even when the new screen
|
||||
// has the same backingScaleFactor (in which case
|
||||
// viewDidChangeBackingProperties does not fire). Listening
|
||||
// here lets us re-run metric sync on every screen transition
|
||||
// — required for the case where two displays share scale but
|
||||
// differ in geometry / color profile, and harmless when
|
||||
// viewDidChangeBackingProperties also fires for the
|
||||
// different-scale case.
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidChangeScreen),
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: window
|
||||
)
|
||||
} else {
|
||||
core.stopDisplayLink()
|
||||
core.setFocus(false)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func windowDidBecomeKey(_: Notification) {
|
||||
let focused = window?.isKeyWindow == true
|
||||
&& window?.firstResponder === self
|
||||
core.setFocus(focused)
|
||||
onFocusChange?(focused)
|
||||
}
|
||||
|
||||
@objc func windowDidResignKey(_: Notification) {
|
||||
core.setFocus(false)
|
||||
onFocusChange?(false)
|
||||
}
|
||||
|
||||
@objc func windowDidChangeScreen(_: Notification) {
|
||||
// Defer one runloop tick so AppKit's layout pass and the
|
||||
// window's new backingScaleFactor have both settled before we
|
||||
// re-derive metrics. Calling synchronously can race with the
|
||||
// layout pass and re-introduce the drift we're trying to fix.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
updateMetalLayerMetrics()
|
||||
core.synchronizeMetrics()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
}
|
||||
|
||||
private func removeWindowObservers() {
|
||||
// Remove any existing key-window observers before registering for the
|
||||
// current window. AppKit can move the view directly between windows
|
||||
// without an intermediate nil attachment.
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.didBecomeKeyNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.didResignKeyNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override open func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
core.fitToSize()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
|
||||
override open func layout() {
|
||||
super.layout()
|
||||
core.fitToSize()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
|
||||
override open func viewDidChangeBackingProperties() {
|
||||
super.viewDidChangeBackingProperties()
|
||||
updateMetalLayerMetrics()
|
||||
core.fitToSize()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
|
||||
public func fitToSize() {
|
||||
core.fitToSize()
|
||||
}
|
||||
|
||||
func updateMetalLayerMetrics() {
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
let scale = core.scaleFactor()
|
||||
// Write to the actually-attached backing layer (not just the
|
||||
// cached `metalLayer` ivar). The render pipeline can swap
|
||||
// `self.layer` to an IOSurfaceLayer for IOSurface-backed
|
||||
// compositing; once that happens the cached CAMetalLayer
|
||||
// reference is detached from the view tree and writes to its
|
||||
// contentsScale are no-ops as far as what's visible. The
|
||||
// observable symptom is text rendered at half size after the
|
||||
// window crosses to a display with a different
|
||||
// backingScaleFactor.
|
||||
layer?.contentsScale = scale
|
||||
if let metal = layer as? CAMetalLayer {
|
||||
metal.drawableSize = CGSize(
|
||||
width: bounds.width * scale,
|
||||
height: bounds.height * scale
|
||||
)
|
||||
}
|
||||
// Mirror to the cached ivar in case anything else still
|
||||
// reads through it during a transitional layout pass.
|
||||
metalLayer?.contentsScale = scale
|
||||
metalLayer?.drawableSize = CGSize(
|
||||
width: bounds.width * scale,
|
||||
height: bounds.height * scale
|
||||
)
|
||||
}
|
||||
|
||||
func enforceMetalLayerScale() {
|
||||
let scale = core.scaleFactor()
|
||||
if let layer, layer.contentsScale != scale {
|
||||
layer.contentsScale = scale
|
||||
}
|
||||
if let metalLayer, metalLayer.contentsScale != scale {
|
||||
metalLayer.contentsScale = scale
|
||||
}
|
||||
}
|
||||
|
||||
override open func viewDidChangeEffectiveAppearance() {
|
||||
super.viewDidChangeEffectiveAppearance()
|
||||
updateColorScheme()
|
||||
}
|
||||
|
||||
func updateColorScheme() {
|
||||
let scheme: TerminalColorScheme = switch effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) {
|
||||
case .darkAqua: .dark
|
||||
default: .light
|
||||
}
|
||||
surface?.setColorScheme(scheme.ghosttyValue)
|
||||
if let controller,
|
||||
let viewState = delegate as? TerminalViewState,
|
||||
viewState.controller === controller
|
||||
{
|
||||
viewState.adopt(terminalColorScheme: scheme)
|
||||
} else {
|
||||
controller?.setColorScheme(scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// AppTerminalView+NSTextInputClient.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
|
||||
extension AppTerminalView: @preconcurrency NSTextInputClient {
|
||||
open func insertText(_ string: Any, replacementRange _: NSRange) {
|
||||
inputHandler?.inputMethodHandler?.insertText(string)
|
||||
}
|
||||
|
||||
open func setMarkedText(
|
||||
_ string: Any,
|
||||
selectedRange: NSRange,
|
||||
replacementRange _: NSRange
|
||||
) {
|
||||
inputHandler?.inputMethodHandler?.setMarkedText(
|
||||
string,
|
||||
selectedRange: selectedRange
|
||||
)
|
||||
}
|
||||
|
||||
open func unmarkText() {
|
||||
inputHandler?.inputMethodHandler?.unmarkText()
|
||||
}
|
||||
|
||||
open func selectedRange() -> NSRange {
|
||||
inputHandler?.inputMethodHandler?.currentSelectedRange()
|
||||
?? NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
|
||||
open func markedRange() -> NSRange {
|
||||
inputHandler?.inputMethodHandler?.markedRange()
|
||||
?? NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
|
||||
open func hasMarkedText() -> Bool {
|
||||
inputHandler?.inputMethodHandler?.hasMarkedText ?? false
|
||||
}
|
||||
|
||||
open func attributedSubstring(
|
||||
forProposedRange range: NSRange,
|
||||
actualRange: NSRangePointer?
|
||||
) -> NSAttributedString? {
|
||||
inputHandler?.inputMethodHandler?.attributedSubstring(
|
||||
forProposedRange: range,
|
||||
actualRange: actualRange
|
||||
)
|
||||
}
|
||||
|
||||
open func validAttributesForMarkedText() -> [NSAttributedString.Key] {
|
||||
[]
|
||||
}
|
||||
|
||||
open func firstRect(
|
||||
forCharacterRange _: NSRange,
|
||||
actualRange _: NSRangePointer?
|
||||
) -> NSRect {
|
||||
guard let surface else { return .zero }
|
||||
|
||||
let point = surface.imePoint()
|
||||
let viewRect = NSRect(
|
||||
x: point.x,
|
||||
y: bounds.height - point.y - point.height,
|
||||
width: point.width,
|
||||
height: point.height
|
||||
)
|
||||
|
||||
guard let window else { return viewRect }
|
||||
let windowRect = convert(viewRect, to: nil)
|
||||
return window.convertToScreen(windowRect)
|
||||
}
|
||||
|
||||
open func characterIndex(for _: NSPoint) -> Int {
|
||||
NSNotFound
|
||||
}
|
||||
}
|
||||
#endif
|
||||
43
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift
vendored
Normal file
43
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// AppTerminalView+PublicInput.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Public wrappers around `TerminalSurface` write paths so hosts can
|
||||
// inject bytes into the pty without reaching for internal API.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
|
||||
extension AppTerminalView {
|
||||
/// Send raw UTF-8 text directly to the underlying pty (bypassing
|
||||
/// key translation). Use this for synthetic input like `\x1b[Z`
|
||||
/// (Shift+Tab / CSI Z) or multi-line paste-style injections.
|
||||
/// No-op when the surface has not been created yet.
|
||||
public func sendText(_ text: String) {
|
||||
surface?.sendText(text)
|
||||
}
|
||||
|
||||
/// Invoke a named Ghostty binding action (e.g. "copy_to_clipboard",
|
||||
/// "clear_screen"). Returns true when the action dispatched.
|
||||
@discardableResult
|
||||
public func performBindingAction(_ action: String) -> Bool {
|
||||
surface?.performBindingAction(action) ?? false
|
||||
}
|
||||
|
||||
/// Jump the viewport by a number of shell prompts.
|
||||
///
|
||||
/// Negative offsets move toward older prompts and positive offsets move
|
||||
/// toward newer prompts. Prompt navigation requires shell integration.
|
||||
@discardableResult
|
||||
public func jumpToPrompt(by offset: Int16) -> Bool {
|
||||
surface?.jumpToPrompt(by: offset) ?? false
|
||||
}
|
||||
|
||||
/// Reveal an absolute scrollback row, where zero is the first row.
|
||||
@discardableResult
|
||||
public func scrollToRow(_ row: UInt) -> Bool {
|
||||
surface?.scrollToRow(row) ?? false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
176
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView.swift
vendored
Normal file
176
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView.swift
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// AppTerminalView.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
open class AppTerminalView: NSView {
|
||||
let core = TerminalSurfaceCoordinator()
|
||||
var metalLayer: CAMetalLayer?
|
||||
var inputHandler: TerminalKeyEventHandler?
|
||||
var lastPerformKeyEvent: TimeInterval?
|
||||
var pointerSelectionStartPoint: CGPoint?
|
||||
var lastPointerSelectionRect: CGRect?
|
||||
var pendingSelectionMenuPoint: CGPoint?
|
||||
var onFocusChange: ((Bool) -> Void)?
|
||||
|
||||
open weak var delegate: (any TerminalSurfaceViewDelegate)? {
|
||||
get { core.delegate }
|
||||
set { core.delegate = newValue }
|
||||
}
|
||||
|
||||
open var controller: TerminalController? {
|
||||
get { core.controller }
|
||||
set { core.controller = newValue }
|
||||
}
|
||||
|
||||
open var configuration: TerminalSurfaceOptions {
|
||||
get { core.configuration }
|
||||
set { core.configuration = newValue }
|
||||
}
|
||||
|
||||
open func setSurfaceVisible(_ visible: Bool) {
|
||||
core.setDisplayVisible(visible)
|
||||
}
|
||||
|
||||
var surface: TerminalSurface? {
|
||||
core.surface
|
||||
}
|
||||
|
||||
override public init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
public required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func commonInit() {
|
||||
wantsLayer = true
|
||||
|
||||
let metal = CAMetalLayer()
|
||||
metal.device = MTLCreateSystemDefaultDevice()
|
||||
metal.pixelFormat = .bgra8Unorm
|
||||
metal.framebufferOnly = true
|
||||
metal.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
|
||||
metal.isOpaque = false
|
||||
metal.backgroundColor = NSColor.clear.cgColor
|
||||
layer = metal
|
||||
metalLayer = metal
|
||||
layer?.backgroundColor = NSColor.clear.cgColor
|
||||
|
||||
inputHandler = TerminalKeyEventHandler(view: self)
|
||||
setupTrackingArea()
|
||||
|
||||
core.isAttached = { [weak self] in self?.window != nil }
|
||||
core.scaleFactor = { [weak self] in
|
||||
Double(
|
||||
self?.window?.backingScaleFactor
|
||||
?? NSScreen.main?.backingScaleFactor ?? 2.0
|
||||
)
|
||||
}
|
||||
core.viewSize = { [weak self] in
|
||||
guard let self else { return (0, 0) }
|
||||
return (bounds.width, bounds.height)
|
||||
}
|
||||
core.platformSetup = { [weak self] config in
|
||||
guard let self else { return }
|
||||
config.platform_tag = GHOSTTY_PLATFORM_MACOS
|
||||
config.platform = ghostty_platform_u(
|
||||
macos: ghostty_platform_macos_s(
|
||||
nsview: Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
)
|
||||
}
|
||||
core.onMetricsUpdate = { [weak self] in
|
||||
self?.updateMetalLayerMetrics()
|
||||
}
|
||||
core.onPostRender = { [weak self] in
|
||||
self?.enforceMetalLayerScale()
|
||||
}
|
||||
}
|
||||
|
||||
open func selectionMenuPoint(at point: CGPoint) -> CGPoint? {
|
||||
guard surface?.hasSelection() == true else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(selectionPointDescription(point))"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if let rect = lastPointerSelectionRect {
|
||||
guard rect.insetBy(dx: -4, dy: -4).contains(point) else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(selectionPointDescription(point)) outside pointer selection"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu hit point=\(selectionPointDescription(point)) inside pointer selection"
|
||||
)
|
||||
return point
|
||||
}
|
||||
|
||||
guard surface?.selectionContainsQuicklookWord() == true else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(selectionPointDescription(point)) outside quicklook word"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu hit point=\(selectionPointDescription(point))"
|
||||
)
|
||||
return point
|
||||
}
|
||||
|
||||
open func selectionContextMenu() -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let copyItem = NSMenuItem(
|
||||
title: "Copy",
|
||||
action: #selector(copy(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
copyItem.target = self
|
||||
menu.addItem(copyItem)
|
||||
return menu
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
open func copySelectedTextToPasteboard() -> Bool {
|
||||
guard surface?.hasSelection() == true else {
|
||||
return false
|
||||
}
|
||||
guard surface?.performBindingAction("copy_to_clipboard") == true else {
|
||||
return false
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection copied to clipboard"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private func selectionPointDescription(_ point: CGPoint) -> String {
|
||||
"\(String(format: "%.2f", point.x))x\(String(format: "%.2f", point.y))"
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
22
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/KeyboardLayout.swift
vendored
Normal file
22
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/KeyboardLayout.swift
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import Carbon.HIToolbox
|
||||
|
||||
enum KeyboardLayout {
|
||||
static var id: String? {
|
||||
guard let inputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let rawProperty = TISGetInputSourceProperty(
|
||||
inputSource,
|
||||
kTISPropertyInputSourceID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let property = unsafeBitCast(rawProperty, to: CFString.self)
|
||||
return property as String
|
||||
}
|
||||
}
|
||||
#endif
|
||||
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
|
||||
130
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalTextInputHandler@AppKit.swift
vendored
Normal file
130
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalTextInputHandler@AppKit.swift
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// TerminalTextInputHandler@AppKit.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
// Reference:
|
||||
// - ghostty-org/ghostty
|
||||
// - macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
|
||||
// IME text accumulation intentionally follows Ghostty's AppKit flow:
|
||||
// marked/preedit text is passed through as-is, without extra app-specific
|
||||
// filtering, so composition behavior stays consistent with upstream.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
final class TerminalTextInputHandler: NSObject {
|
||||
private weak var view: AppTerminalView?
|
||||
private var markedTextState = TerminalMarkedTextState()
|
||||
private var accumulatedTexts: [String]?
|
||||
|
||||
var hasMarkedText: Bool {
|
||||
markedTextState.hasMarkedText
|
||||
}
|
||||
|
||||
init(view: AppTerminalView) {
|
||||
self.view = view
|
||||
super.init()
|
||||
}
|
||||
|
||||
func startCollectingText() {
|
||||
accumulatedTexts = []
|
||||
}
|
||||
|
||||
func finishCollectingText() -> [String]? {
|
||||
defer { accumulatedTexts = nil }
|
||||
guard let texts = accumulatedTexts, !texts.isEmpty else { return nil }
|
||||
return texts
|
||||
}
|
||||
|
||||
// MARK: - Text Input
|
||||
|
||||
func insertText(_ string: Any) {
|
||||
guard NSApp.currentEvent != nil else { return }
|
||||
|
||||
let text: String
|
||||
if let attrStr = string as? NSAttributedString {
|
||||
text = attrStr.string
|
||||
} else if let str = string as? String {
|
||||
text = str
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
unmarkText()
|
||||
|
||||
if accumulatedTexts != nil {
|
||||
accumulatedTexts?.append(text)
|
||||
} else {
|
||||
view?.surface?.sendText(text)
|
||||
}
|
||||
}
|
||||
|
||||
func setMarkedText(
|
||||
_ string: Any,
|
||||
selectedRange: NSRange
|
||||
) {
|
||||
let text: String
|
||||
if let attrStr = string as? NSAttributedString {
|
||||
text = attrStr.string
|
||||
} else if let str = string as? String {
|
||||
text = str
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
markedTextState.setMarkedText(text, selectedRange: selectedRange)
|
||||
|
||||
if accumulatedTexts == nil {
|
||||
syncPreedit()
|
||||
}
|
||||
}
|
||||
|
||||
func unmarkText() {
|
||||
guard markedTextState.hasMarkedText else { return }
|
||||
markedTextState.clear()
|
||||
syncPreedit()
|
||||
}
|
||||
|
||||
func currentSelectedRange() -> NSRange {
|
||||
markedTextState.currentSelectedRange
|
||||
}
|
||||
|
||||
func markedRange() -> NSRange {
|
||||
markedTextState.markedRange
|
||||
}
|
||||
|
||||
func attributedSubstring(
|
||||
forProposedRange range: NSRange,
|
||||
actualRange: NSRangePointer?
|
||||
) -> NSAttributedString? {
|
||||
guard markedTextState.hasMarkedText else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let length = markedTextState.documentLength
|
||||
let location = min(max(range.location, 0), length)
|
||||
let end = min(max(range.location + range.length, location), length)
|
||||
let clampedRange = NSRange(location: location, length: end - location)
|
||||
actualRange?.pointee = clampedRange
|
||||
|
||||
guard let text = markedTextState.text(in: clampedRange) else {
|
||||
return nil
|
||||
}
|
||||
return NSAttributedString(string: text)
|
||||
}
|
||||
|
||||
func syncPreedit(clearIfNeeded: Bool = true) {
|
||||
guard let text = markedTextState.text else {
|
||||
guard clearIfNeeded else { return }
|
||||
view?.surface?.preedit("")
|
||||
return
|
||||
}
|
||||
|
||||
view?.surface?.preedit(text)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user