初始提交: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:
kid
2026-07-24 10:20:46 +08:00
commit aa92d0e676
2761 changed files with 803505 additions and 0 deletions

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,367 @@
//
// TerminalHardwareKeyRouter.swift
// libghostty-spm
//
import Foundation
import GhosttyKit
enum TerminalHardwareKeyDelivery: Equatable {
case ghostty(ghostty_input_key_e)
case data(Data)
var isDirectInput: Bool {
if case .data = self {
return true
}
return false
}
}
enum TerminalHardwareKeyRouter {
static func routeUIKit(
usage: UInt16,
backend: TerminalSessionBackend
) -> TerminalHardwareKeyDelivery {
if case .inMemory = backend,
let data = directControlInputForUIKit(usage: usage)
{
return .data(data)
}
return .ghostty(ghosttyKeyForUIKit(usage: usage))
}
static func routeUIKit(
usage: UInt16,
backend: TerminalSessionBackend,
modifiers: TerminalInputModifiers
) -> TerminalHardwareKeyDelivery {
// Raw host-managed bytes only represent the unmodified control key.
// Modified synthetic accessory keys need a real Ghostty key event so
// the backend can emit the correct escape sequence for those modifiers.
guard modifiers.isEmpty else {
return .ghostty(ghosttyKeyForUIKit(usage: usage))
}
return routeUIKit(usage: usage, backend: backend)
}
static func routeAppKit(
keyCode: UInt16,
backend: TerminalSessionBackend
) -> TerminalHardwareKeyDelivery {
if case .inMemory = backend,
let data = directControlInputForAppKit(keyCode: keyCode)
{
return .data(data)
}
return .ghostty(ghosttyKeyForAppKit(keyCode: keyCode))
}
private static func directControlInputForUIKit(usage: UInt16) -> Data? {
switch usage {
case 0x2A:
Data([0x7F])
case 0x2B:
Data([0x09])
case 0x4C:
Data("\u{1B}[3~".utf8)
case 0x4A:
Data("\u{1B}[H".utf8)
case 0x4D:
Data("\u{1B}[F".utf8)
case 0x4B:
Data("\u{1B}[5~".utf8)
case 0x4E:
Data("\u{1B}[6~".utf8)
case 0x4F:
Data("\u{1B}[C".utf8)
case 0x50:
Data("\u{1B}[D".utf8)
case 0x51:
Data("\u{1B}[B".utf8)
case 0x52:
Data("\u{1B}[A".utf8)
default:
nil
}
}
private static func directControlInputForAppKit(keyCode: UInt16) -> Data? {
switch keyCode {
case 0x33:
Data([0x7F])
case 0x30:
Data([0x09])
case 0x75:
Data("\u{1B}[3~".utf8)
case 0x73:
Data("\u{1B}[H".utf8)
case 0x77:
Data("\u{1B}[F".utf8)
case 0x74:
Data("\u{1B}[5~".utf8)
case 0x79:
Data("\u{1B}[6~".utf8)
case 0x7B:
Data("\u{1B}[D".utf8)
case 0x7C:
Data("\u{1B}[C".utf8)
case 0x7D:
Data("\u{1B}[B".utf8)
case 0x7E:
Data("\u{1B}[A".utf8)
default:
nil
}
}
private static func ghosttyKeyForUIKit(usage: UInt16) -> ghostty_input_key_e {
uiKitMap[usage] ?? GHOSTTY_KEY_UNIDENTIFIED
}
private static func ghosttyKeyForAppKit(keyCode: UInt16) -> ghostty_input_key_e {
appKitMap[keyCode] ?? GHOSTTY_KEY_UNIDENTIFIED
}
/// Sentinel `keycode` value for keys that have no macOS AppKit
/// equivalent (e.g. CUT/COPY/PASTE, media keys, CONTEXT_MENU, INSERT on
/// PC keyboards). Any value outside the 8-bit AppKit virtual keycode
/// range falls out of libghostty's native-keycode lookup and resolves
/// to `.unidentified`. The pinned Ghostty keycode table uses 8-bit macOS
/// keycodes, so `0x1_0000` stays safely outside the native range. Using
/// plain `0` would instead collide with AppKit's keycode for the `A` key.
static let unidentifiedAppKitKeyCode: UInt32 = 0x10000
/// Translate a Ghostty key enum to the macOS AppKit virtual keycode
/// for the same physical key. This is used by synthetic UIKit key
/// events that already know the logical Ghostty key but still need to
/// satisfy libghostty's native-keycode contract.
static func appKitKeyCode(for ghosttyKey: ghostty_input_key_e) -> UInt32 {
guard let macKeyCode = ghosttyKeyToAppKitCode[ghosttyKey.rawValue]
else { return unidentifiedAppKitKeyCode }
return UInt32(macKeyCode)
}
/// Translate a UIKit (USB HID) usage code to the macOS AppKit virtual
/// keycode for the same physical key. Libghostty's keycode lookup
/// (`src/input/keycodes.zig`) uses macOS keycodes on both macOS and iOS
/// builds, so UIKit callers need to translate HID mac before handing
/// the keycode to `ghostty_surface_key`. Returns
/// `unidentifiedAppKitKeyCode` for HID usages with no AppKit counterpart
/// (keys that do not exist on Mac keyboards).
static func appKitKeyCodeForUIKit(usage: UInt16) -> UInt32 {
// Prefer explicit HID -> AppKit overrides before falling back to the
// shared Ghostty key mapping. Some physical keys do not share the same
// logical Ghostty enum on UIKit and AppKit.
if let macKeyCode = uiKitToAppKitKeyCodeOverrides[usage] {
return UInt32(macKeyCode)
}
guard let ghosttyKey = uiKitMap[usage]
else { return unidentifiedAppKitKeyCode }
return appKitKeyCode(for: ghosttyKey)
}
private static let ghosttyKeyToAppKitCode: [UInt32: UInt16] = {
var result: [UInt32: UInt16] = [:]
for (code, key) in appKitMap {
result[key.rawValue] = code
}
return result
}()
/// UIKit and AppKit do not always use the same logical Ghostty key for the
/// same physical key. Prefer the AppKit keycode directly for those cases.
private static let uiKitToAppKitKeyCodeOverrides: [UInt16: UInt16] = [
// keyboardNumLock -> kVK_ANSI_KeypadClear
0x53: 0x47,
// keyboardNonUSBackslash -> kVK_ISO_Section
0x64: 0x0A,
]
private typealias Pair = (UInt16, ghostty_input_key_e)
private static let uiKitMap = buildMap(
literalPairs: [
(0x28, GHOSTTY_KEY_ENTER),
(0x29, GHOSTTY_KEY_ESCAPE),
(0x2A, GHOSTTY_KEY_BACKSPACE),
(0x2B, GHOSTTY_KEY_TAB),
(0x2C, GHOSTTY_KEY_SPACE),
(0x2D, GHOSTTY_KEY_MINUS),
(0x2E, GHOSTTY_KEY_EQUAL),
(0x2F, GHOSTTY_KEY_BRACKET_LEFT),
(0x30, GHOSTTY_KEY_BRACKET_RIGHT),
(0x31, GHOSTTY_KEY_BACKSLASH),
(0x33, GHOSTTY_KEY_SEMICOLON),
(0x34, GHOSTTY_KEY_QUOTE),
(0x35, GHOSTTY_KEY_BACKQUOTE),
(0x36, GHOSTTY_KEY_COMMA),
(0x37, GHOSTTY_KEY_PERIOD),
(0x38, GHOSTTY_KEY_SLASH),
(0x39, GHOSTTY_KEY_CAPS_LOCK),
(0x46, GHOSTTY_KEY_PRINT_SCREEN),
(0x47, GHOSTTY_KEY_SCROLL_LOCK),
(0x48, GHOSTTY_KEY_PAUSE),
(0x49, GHOSTTY_KEY_INSERT),
(0x4A, GHOSTTY_KEY_HOME),
(0x4B, GHOSTTY_KEY_PAGE_UP),
(0x4C, GHOSTTY_KEY_DELETE),
(0x4D, GHOSTTY_KEY_END),
(0x4E, GHOSTTY_KEY_PAGE_DOWN),
(0x4F, GHOSTTY_KEY_ARROW_RIGHT),
(0x50, GHOSTTY_KEY_ARROW_LEFT),
(0x51, GHOSTTY_KEY_ARROW_DOWN),
(0x52, GHOSTTY_KEY_ARROW_UP),
(0x53, GHOSTTY_KEY_NUM_LOCK),
(0x54, GHOSTTY_KEY_NUMPAD_DIVIDE),
(0x55, GHOSTTY_KEY_NUMPAD_MULTIPLY),
(0x56, GHOSTTY_KEY_NUMPAD_SUBTRACT),
(0x57, GHOSTTY_KEY_NUMPAD_ADD),
(0x58, GHOSTTY_KEY_NUMPAD_ENTER),
(0x64, GHOSTTY_KEY_INTL_BACKSLASH),
(0x65, GHOSTTY_KEY_CONTEXT_MENU),
(0x67, GHOSTTY_KEY_NUMPAD_EQUAL),
(0x75, GHOSTTY_KEY_HELP),
(0x7B, GHOSTTY_KEY_CUT),
(0x7C, GHOSTTY_KEY_COPY),
(0x7D, GHOSTTY_KEY_PASTE),
(0x7F, GHOSTTY_KEY_AUDIO_VOLUME_MUTE),
(0x80, GHOSTTY_KEY_AUDIO_VOLUME_UP),
(0x81, GHOSTTY_KEY_AUDIO_VOLUME_DOWN),
(0xE0, GHOSTTY_KEY_CONTROL_LEFT),
(0xE1, GHOSTTY_KEY_SHIFT_LEFT),
(0xE2, GHOSTTY_KEY_ALT_LEFT),
(0xE3, GHOSTTY_KEY_META_LEFT),
(0xE4, GHOSTTY_KEY_CONTROL_RIGHT),
(0xE5, GHOSTTY_KEY_SHIFT_RIGHT),
(0xE6, GHOSTTY_KEY_ALT_RIGHT),
(0xE7, GHOSTTY_KEY_META_RIGHT),
],
groupedPairs: [
makeRun(
startingAt: 0x04,
keys: [
GHOSTTY_KEY_A, GHOSTTY_KEY_B, GHOSTTY_KEY_C, GHOSTTY_KEY_D,
GHOSTTY_KEY_E, GHOSTTY_KEY_F, GHOSTTY_KEY_G, GHOSTTY_KEY_H,
GHOSTTY_KEY_I, GHOSTTY_KEY_J, GHOSTTY_KEY_K, GHOSTTY_KEY_L,
GHOSTTY_KEY_M, GHOSTTY_KEY_N, GHOSTTY_KEY_O, GHOSTTY_KEY_P,
GHOSTTY_KEY_Q, GHOSTTY_KEY_R, GHOSTTY_KEY_S, GHOSTTY_KEY_T,
GHOSTTY_KEY_U, GHOSTTY_KEY_V, GHOSTTY_KEY_W, GHOSTTY_KEY_X,
GHOSTTY_KEY_Y, GHOSTTY_KEY_Z,
]
),
makeRun(
startingAt: 0x1E,
keys: [
GHOSTTY_KEY_DIGIT_1, GHOSTTY_KEY_DIGIT_2, GHOSTTY_KEY_DIGIT_3,
GHOSTTY_KEY_DIGIT_4, GHOSTTY_KEY_DIGIT_5, GHOSTTY_KEY_DIGIT_6,
GHOSTTY_KEY_DIGIT_7, GHOSTTY_KEY_DIGIT_8, GHOSTTY_KEY_DIGIT_9,
GHOSTTY_KEY_DIGIT_0,
]
),
makeRun(
startingAt: 0x3A,
keys: [
GHOSTTY_KEY_F1, GHOSTTY_KEY_F2, GHOSTTY_KEY_F3, GHOSTTY_KEY_F4,
GHOSTTY_KEY_F5, GHOSTTY_KEY_F6, GHOSTTY_KEY_F7, GHOSTTY_KEY_F8,
GHOSTTY_KEY_F9, GHOSTTY_KEY_F10, GHOSTTY_KEY_F11, GHOSTTY_KEY_F12,
]
),
makeRun(
startingAt: 0x59,
keys: [
GHOSTTY_KEY_NUMPAD_1, GHOSTTY_KEY_NUMPAD_2, GHOSTTY_KEY_NUMPAD_3,
GHOSTTY_KEY_NUMPAD_4, GHOSTTY_KEY_NUMPAD_5, GHOSTTY_KEY_NUMPAD_6,
GHOSTTY_KEY_NUMPAD_7, GHOSTTY_KEY_NUMPAD_8, GHOSTTY_KEY_NUMPAD_9,
GHOSTTY_KEY_NUMPAD_0, GHOSTTY_KEY_NUMPAD_DECIMAL,
]
),
makeRun(
startingAt: 0x68,
keys: [
GHOSTTY_KEY_F13, GHOSTTY_KEY_F14, GHOSTTY_KEY_F15, GHOSTTY_KEY_F16,
GHOSTTY_KEY_F17, GHOSTTY_KEY_F18, GHOSTTY_KEY_F19, GHOSTTY_KEY_F20,
GHOSTTY_KEY_F21, GHOSTTY_KEY_F22, GHOSTTY_KEY_F23, GHOSTTY_KEY_F24,
]
),
]
)
/// JIS keyboard entries are still absent from this table:
/// (0x5D, GHOSTTY_KEY_INTL_YEN) // kVK_JIS_Yen
/// (0x5E, GHOSTTY_KEY_INTL_RO) // kVK_JIS_Underscore
private static let appKitMap = buildMap(
literalPairs: [
(0x00, GHOSTTY_KEY_A), (0x01, GHOSTTY_KEY_S), (0x02, GHOSTTY_KEY_D),
(0x03, GHOSTTY_KEY_F), (0x04, GHOSTTY_KEY_H), (0x05, GHOSTTY_KEY_G),
(0x06, GHOSTTY_KEY_Z), (0x07, GHOSTTY_KEY_X), (0x08, GHOSTTY_KEY_C),
(0x09, GHOSTTY_KEY_V),
(0x0B, GHOSTTY_KEY_B), (0x0C, GHOSTTY_KEY_Q),
(0x0D, GHOSTTY_KEY_W), (0x0E, GHOSTTY_KEY_E), (0x0F, GHOSTTY_KEY_R),
(0x10, GHOSTTY_KEY_Y), (0x11, GHOSTTY_KEY_T), (0x12, GHOSTTY_KEY_DIGIT_1),
(0x13, GHOSTTY_KEY_DIGIT_2), (0x14, GHOSTTY_KEY_DIGIT_3), (0x15, GHOSTTY_KEY_DIGIT_4),
(0x16, GHOSTTY_KEY_DIGIT_6), (0x17, GHOSTTY_KEY_DIGIT_5), (0x18, GHOSTTY_KEY_EQUAL),
(0x19, GHOSTTY_KEY_DIGIT_9), (0x1A, GHOSTTY_KEY_DIGIT_7), (0x1B, GHOSTTY_KEY_MINUS),
(0x1C, GHOSTTY_KEY_DIGIT_8), (0x1D, GHOSTTY_KEY_DIGIT_0), (0x1E, GHOSTTY_KEY_BRACKET_RIGHT),
(0x1F, GHOSTTY_KEY_O), (0x20, GHOSTTY_KEY_U), (0x21, GHOSTTY_KEY_BRACKET_LEFT),
(0x22, GHOSTTY_KEY_I), (0x23, GHOSTTY_KEY_P), (0x24, GHOSTTY_KEY_ENTER),
(0x25, GHOSTTY_KEY_L), (0x26, GHOSTTY_KEY_J), (0x27, GHOSTTY_KEY_QUOTE),
(0x28, GHOSTTY_KEY_K), (0x29, GHOSTTY_KEY_SEMICOLON), (0x2A, GHOSTTY_KEY_BACKSLASH),
(0x2B, GHOSTTY_KEY_COMMA), (0x2C, GHOSTTY_KEY_SLASH), (0x2D, GHOSTTY_KEY_N),
(0x2E, GHOSTTY_KEY_M), (0x2F, GHOSTTY_KEY_PERIOD), (0x30, GHOSTTY_KEY_TAB),
(0x31, GHOSTTY_KEY_SPACE), (0x32, GHOSTTY_KEY_BACKQUOTE), (0x33, GHOSTTY_KEY_BACKSPACE),
(0x35, GHOSTTY_KEY_ESCAPE), (0x36, GHOSTTY_KEY_META_RIGHT), (0x37, GHOSTTY_KEY_META_LEFT),
(0x38, GHOSTTY_KEY_SHIFT_LEFT), (0x39, GHOSTTY_KEY_CAPS_LOCK), (0x3A, GHOSTTY_KEY_ALT_LEFT),
(0x3B, GHOSTTY_KEY_CONTROL_LEFT), (0x3C, GHOSTTY_KEY_SHIFT_RIGHT), (0x3D, GHOSTTY_KEY_ALT_RIGHT),
(0x3E, GHOSTTY_KEY_CONTROL_RIGHT), (0x3F, GHOSTTY_KEY_FN), (0x40, GHOSTTY_KEY_F17),
(0x41, GHOSTTY_KEY_NUMPAD_DECIMAL),
(0x43, GHOSTTY_KEY_NUMPAD_MULTIPLY), (0x45, GHOSTTY_KEY_NUMPAD_ADD), (0x47, GHOSTTY_KEY_NUMPAD_CLEAR),
(0x48, GHOSTTY_KEY_AUDIO_VOLUME_UP), (0x49, GHOSTTY_KEY_AUDIO_VOLUME_DOWN),
(0x4A, GHOSTTY_KEY_AUDIO_VOLUME_MUTE), (0x4B, GHOSTTY_KEY_NUMPAD_DIVIDE),
(0x4C, GHOSTTY_KEY_NUMPAD_ENTER), (0x4E, GHOSTTY_KEY_NUMPAD_SUBTRACT),
(0x4F, GHOSTTY_KEY_F18), (0x50, GHOSTTY_KEY_F19), (0x51, GHOSTTY_KEY_NUMPAD_EQUAL),
(0x52, GHOSTTY_KEY_NUMPAD_0), (0x53, GHOSTTY_KEY_NUMPAD_1),
(0x54, GHOSTTY_KEY_NUMPAD_2), (0x55, GHOSTTY_KEY_NUMPAD_3), (0x56, GHOSTTY_KEY_NUMPAD_4),
(0x57, GHOSTTY_KEY_NUMPAD_5), (0x58, GHOSTTY_KEY_NUMPAD_6), (0x59, GHOSTTY_KEY_NUMPAD_7),
(0x5A, GHOSTTY_KEY_F20),
(0x5B, GHOSTTY_KEY_NUMPAD_8), (0x5C, GHOSTTY_KEY_NUMPAD_9), (0x60, GHOSTTY_KEY_F5),
(0x61, GHOSTTY_KEY_F6), (0x62, GHOSTTY_KEY_F7), (0x63, GHOSTTY_KEY_F3),
(0x64, GHOSTTY_KEY_F8), (0x65, GHOSTTY_KEY_F9), (0x67, GHOSTTY_KEY_F11),
(0x69, GHOSTTY_KEY_F13), (0x6A, GHOSTTY_KEY_F16), (0x6B, GHOSTTY_KEY_F14),
(0x6D, GHOSTTY_KEY_F10), (0x6F, GHOSTTY_KEY_F12), (0x71, GHOSTTY_KEY_F15),
(0x72, GHOSTTY_KEY_HELP), (0x73, GHOSTTY_KEY_HOME), (0x74, GHOSTTY_KEY_PAGE_UP),
(0x75, GHOSTTY_KEY_DELETE), (0x76, GHOSTTY_KEY_F4), (0x77, GHOSTTY_KEY_END),
(0x78, GHOSTTY_KEY_F2), (0x79, GHOSTTY_KEY_PAGE_DOWN), (0x7A, GHOSTTY_KEY_F1),
(0x7B, GHOSTTY_KEY_ARROW_LEFT), (0x7C, GHOSTTY_KEY_ARROW_RIGHT),
(0x7D, GHOSTTY_KEY_ARROW_DOWN), (0x7E, GHOSTTY_KEY_ARROW_UP),
],
groupedPairs: []
)
private static func buildMap(
literalPairs: [Pair],
groupedPairs: [[Pair]]
) -> [UInt16: ghostty_input_key_e] {
var map: [UInt16: ghostty_input_key_e] = [:]
for (code, key) in literalPairs {
map[code] = key
}
for group in groupedPairs {
for (code, key) in group {
map[code] = key
}
}
return map
}
private static func makeRun(
startingAt code: UInt16,
keys: [ghostty_input_key_e]
) -> [Pair] {
keys.enumerated().map { offset, key in
(code + UInt16(offset), key)
}
}
}

View File

@@ -0,0 +1,45 @@
//
// TerminalInputText.swift
// libghostty-spm
//
// Reference:
// - ghostty-org/ghostty
// - macos/Sources/Ghostty/NSEvent+Extension.swift
// Keep the AppKit text filtering here aligned with Ghostty's native
// `ghosttyCharacters` behavior so future upstream syncs stay mechanical.
import Foundation
enum TerminalInputText {
static func filteredFunctionKeyText(_ text: String?) -> String? {
guard let text else { return nil }
if isUIKitNamedFunctionKey(text) {
return nil
}
guard text.count == 1, let scalar = text.unicodeScalars.first else {
return text
}
if isPrivateUseFunctionKey(scalar) {
return nil
}
return text
}
static func lineCount(in text: String) -> Int {
text.reduce(into: 0) { count, character in
if character == "\n" {
count += 1
}
}
}
static func isPrivateUseFunctionKey(_ scalar: UnicodeScalar) -> Bool {
scalar.value >= 0xF700 && scalar.value <= 0xF8FF
}
static func isUIKitNamedFunctionKey(_ text: String) -> Bool {
text.hasPrefix("UIKeyInput")
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
@inline(__always)
func terminalRunOnMain(
_ operation: @escaping @MainActor () -> Void
) {
if Thread.isMainThread {
MainActor.assumeIsolated {
operation()
}
return
}
DispatchQueue.main.async {
MainActor.assumeIsolated {
operation()
}
}
}

View File

@@ -0,0 +1,86 @@
import Foundation
/// Owns the platform-agnostic state for IME marked text so AppKit and UIKit
/// can share one editing model.
struct TerminalMarkedTextState {
private(set) var text: String?
private(set) var selectedRange = NSRange(location: 0, length: 0)
var hasMarkedText: Bool {
guard let text else { return false }
return !text.isEmpty
}
var documentLength: Int {
text?.utf16.count ?? 0
}
var markedRange: NSRange {
guard hasMarkedText else {
return NSRange(location: NSNotFound, length: 0)
}
return NSRange(location: 0, length: documentLength)
}
var currentSelectedRange: NSRange {
guard hasMarkedText else {
return NSRange(location: NSNotFound, length: 0)
}
return selectedRange
}
mutating func setMarkedText(_ text: String?, selectedRange: NSRange) {
let normalizedText = text.flatMap { $0.isEmpty ? nil : $0 }
self.text = normalizedText
self.selectedRange = clampedSelectedRange(selectedRange, in: normalizedText)
}
mutating func clear() {
text = nil
selectedRange = NSRange(location: 0, length: 0)
}
mutating func deleteBackward() -> Bool {
guard let text, !text.isEmpty else { return false }
let mutableText = NSMutableString(string: text)
if selectedRange.length > 0 {
mutableText.deleteCharacters(in: selectedRange)
selectedRange = NSRange(location: selectedRange.location, length: 0)
} else if selectedRange.location > 0 {
let deletionRange = NSRange(location: selectedRange.location - 1, length: 1)
mutableText.deleteCharacters(in: deletionRange)
selectedRange = NSRange(location: deletionRange.location, length: 0)
} else {
return true
}
let updatedText = mutableText as String
self.text = updatedText.isEmpty ? nil : updatedText
if self.text == nil {
selectedRange = NSRange(location: 0, length: 0)
}
return true
}
func text(in range: NSRange) -> String? {
guard let text else {
return range.length == 0 ? "" : nil
}
let nsText = text as NSString
guard range.location >= 0, range.length >= 0 else { return nil }
guard range.location + range.length <= nsText.length else { return nil }
return nsText.substring(with: range)
}
private func clampedSelectedRange(
_ range: NSRange,
in text: String?
) -> NSRange {
let length = text?.utf16.count ?? 0
let location = min(max(range.location, 0), length)
let end = min(max(range.location + range.length, location), length)
return NSRange(location: location, length: end - location)
}
}

View File

@@ -0,0 +1,27 @@
//
// TerminalView+Process.swift
// libghostty-spm
//
// Public read access to the pty's foreground process, available on both
// AppKit (`AppTerminalView`) and UIKit (`UITerminalView`) hosts via the
// `TerminalView` typealias. Both concrete views expose the same internal
// `surface` accessor, so a single extension covers every platform.
//
import Foundation
public extension TerminalView {
/// PID of the pty's foreground process group (`tcgetpgrp(pty)`). When the
/// user runs a program in the pty this is that program's pid, so hosts can
/// correlate the surface with an external process list. Nil until the
/// surface has a process.
var foregroundPid: pid_t? {
surface?.foregroundPid
}
/// Name of the pty's controlling tty (e.g. `/dev/ttys004`), or nil until
/// the surface has a process.
var ttyName: String? {
surface?.ttyName
}
}

View File

@@ -0,0 +1,29 @@
//
// TerminalInputAccessoryStyle.swift
// libghostty-spm
//
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
import UIKit
public struct TerminalInputAccessoryStyle: Sendable {
public var regularBackground: UIColor
public var regularForeground: UIColor
public var activeBackground: UIColor
public var activeForeground: UIColor
public init(
regularBackground: UIColor = UIColor.systemGray5.withAlphaComponent(0.92),
regularForeground: UIColor = .label,
activeBackground: UIColor = .systemBlue,
activeForeground: UIColor = .white
) {
self.regularBackground = regularBackground
self.regularForeground = regularForeground
self.activeBackground = activeBackground
self.activeForeground = activeForeground
}
public static let `default` = TerminalInputAccessoryStyle()
}
#endif

View File

@@ -0,0 +1,400 @@
//
// TerminalInputAccessoryView.swift
// libghostty-spm
//
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
import UIKit
@MainActor
final class TerminalInputAccessoryView: UIView {
weak var terminalView: UITerminalView?
var style: TerminalInputAccessoryStyle = .default {
didSet { refreshContent() }
}
private let barHeight: CGFloat = 52
private let buttonSize: CGFloat = 36
private lazy var blurView = UIVisualEffectView(
effect: makeBarEffect()
)
private let scrollView = UIScrollView()
private let stackView = UIStackView()
private var blurLeadingConstraint: NSLayoutConstraint?
private var blurTrailingConstraint: NSLayoutConstraint?
private var blurTopConstraint: NSLayoutConstraint?
private var blurBottomConstraint: NSLayoutConstraint?
private var keyButtons: [AccessoryButton] = []
private var modifierButtons: [(TerminalStickyModifierState.Modifier, AccessoryButton)] = []
init(terminalView: UITerminalView) {
self.terminalView = terminalView
super.init(
frame: CGRect(
x: 0,
y: 0,
width: 0,
height: Self.preferredHeight(for: barHeight)
)
)
autoresizingMask = .flexibleWidth
setupViews()
applyBarChrome()
refreshContent()
terminalView.stickyModifiers.onChange = { [weak self] in
self?.refreshContent()
}
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: preferredHeight)
}
func refreshContent() {
let hasMarkedText = terminalView?.inputHandler.hasMarkedText ?? false
let ctrlActivation = terminalView?.stickyModifiers.ctrl ?? .inactive
let altActivation = terminalView?.stickyModifiers.alt ?? .inactive
let commandActivation = terminalView?.stickyModifiers.command ?? .inactive
keyButtons.forEach { $0.applyRegularStyle(style) }
for (modifier, button) in modifierButtons {
let activation = switch modifier {
case .ctrl: ctrlActivation
case .alt: altActivation
case .command: commandActivation
}
button.applyModifierStyle(activation, isDisabled: hasMarkedText, style: style)
}
}
func rebuildContent() {
stackView.arrangedSubviews.forEach { view in
stackView.removeArrangedSubview(view)
view.removeFromSuperview()
}
keyButtons.removeAll()
modifierButtons.removeAll()
let items = terminalView?.inputAccessoryItems ?? TerminalInputAccessoryItem.defaultItems
addArrangedViews(items.map(makeView(for:)))
refreshContent()
}
private func setupViews() {
backgroundColor = .clear
blurView.translatesAutoresizingMaskIntoConstraints = false
blurView.clipsToBounds = true
addSubview(blurView)
let leading = blurView.leadingAnchor.constraint(equalTo: leadingAnchor)
let trailing = blurView.trailingAnchor.constraint(equalTo: trailingAnchor)
let top = blurView.topAnchor.constraint(equalTo: topAnchor)
let bottom = blurView.bottomAnchor.constraint(equalTo: bottomAnchor)
blurLeadingConstraint = leading
blurTrailingConstraint = trailing
blurTopConstraint = top
blurBottomConstraint = bottom
NSLayoutConstraint.activate([
leading,
trailing,
top,
bottom,
blurView.heightAnchor.constraint(equalToConstant: barHeight),
])
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.alwaysBounceHorizontal = true
scrollView.clipsToBounds = true
blurView.contentView.addSubview(scrollView)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor),
])
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = 8
scrollView.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 10),
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -10),
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor),
])
rebuildContent()
}
private func addArrangedViews(_ views: [UIView]) {
views.forEach { stackView.addArrangedSubview($0) }
}
private func makeDivider() -> UIView {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .secondaryLabel.withAlphaComponent(0.28)
view.layer.cornerRadius = 3
NSLayoutConstraint.activate([
view.widthAnchor.constraint(equalToConstant: 6),
view.heightAnchor.constraint(equalToConstant: 6),
])
return view
}
private func makeView(for item: TerminalInputAccessoryItem) -> UIView {
switch item {
case .esc:
makeTrackedKeyButton(title: "Escape", systemImage: "escape", key: .esc)
case .ctrl:
makeTrackedModifierButton(title: "Control", systemImage: "control", modifier: .ctrl)
case .alt:
makeTrackedModifierButton(title: "Option", systemImage: "option", modifier: .alt)
case .command:
makeTrackedModifierButton(title: "Command", systemImage: "command", modifier: .command)
case .tab:
makeTrackedKeyButton(title: "Tab", systemImage: "arrow.right.to.line", key: .tab)
case .arrowLeft:
makeTrackedKeyButton(title: "Left", systemImage: "arrowtriangle.left.fill", key: .arrowLeft)
case .arrowUp:
makeTrackedKeyButton(title: "Up", systemImage: "arrowtriangle.up.fill", key: .arrowUp)
case .arrowDown:
makeTrackedKeyButton(title: "Down", systemImage: "arrowtriangle.down.fill", key: .arrowDown)
case .arrowRight:
makeTrackedKeyButton(title: "Right", systemImage: "arrowtriangle.right.fill", key: .arrowRight)
case let .symbol(symbol):
makeTrackedKeyButton(title: symbol, key: .symbol(symbol))
case .paste:
makeTrackedKeyButton(title: "Paste", systemImage: "doc.on.clipboard", key: .paste)
case .divider:
makeDivider()
}
}
private func makeTrackedModifierButton(
title: String,
systemImage: String,
modifier: TerminalStickyModifierState.Modifier
) -> AccessoryButton {
let button = makeModifierButton(
title: title,
systemImage: systemImage,
modifier: modifier
)
modifierButtons.append((modifier, button))
return button
}
private func makeTrackedKeyButton(
title: String,
systemImage: String? = nil,
key: TerminalInputBarKey
) -> AccessoryButton {
let button = makeKeyButton(title: title, systemImage: systemImage, key: key)
keyButtons.append(button)
return button
}
private func makeModifierButton(
title: String,
systemImage: String,
modifier: TerminalStickyModifierState.Modifier
) -> AccessoryButton {
let button = AccessoryButton(size: buttonSize) { [weak terminalView] in
terminalView?.stickyModifiers.toggle(modifier)
}
button.accessibilityLabel = title
button.setImage(UIImage(systemName: systemImage), for: .normal)
return button
}
private func makeKeyButton(
title: String,
systemImage: String? = nil,
key: TerminalInputBarKey
) -> AccessoryButton {
let button = AccessoryButton(size: buttonSize) { [weak terminalView] in
terminalView?.handleInputBarKey(key)
}
button.accessibilityLabel = title
if let systemImage {
button.setImage(UIImage(systemName: systemImage), for: .normal)
} else {
var configuration = UIButton.Configuration.plain()
configuration.baseForegroundColor = .label
configuration.title = title
configuration.contentInsets = .zero
configuration.attributedTitle = AttributedString(
title,
attributes: AttributeContainer([
.font: UIFont.monospacedSystemFont(ofSize: 13, weight: .semibold),
])
)
button.configuration = configuration
}
return button
}
private var preferredHeight: CGFloat {
Self.preferredHeight(for: barHeight)
}
private var currentOuterPadding: UIEdgeInsets {
if #available(iOS 26, *) {
UIEdgeInsets(top: 0, left: 8, bottom: 8, right: 8)
} else {
.zero
}
}
private func makeBarEffect() -> UIVisualEffect {
if #available(iOS 26, *) {
let effect = UIGlassEffect(style: .regular)
effect.isInteractive = true
return effect
} else {
return UIBlurEffect(style: .systemUltraThinMaterial)
}
}
private func applyBarChrome() {
let padding = currentOuterPadding
blurLeadingConstraint?.constant = padding.left
blurTrailingConstraint?.constant = -padding.right
blurTopConstraint?.constant = padding.top
blurBottomConstraint?.isActive = !isFloatingBarLayout
blurView.effect = makeBarEffect()
blurView.layer.cornerCurve = .continuous
blurView.layer.cornerRadius = if #available(iOS 26, *) {
barHeight / 2
} else {
0
}
invalidateIntrinsicContentSize()
}
private var isFloatingBarLayout: Bool {
if #available(iOS 26, *) {
true
} else {
false
}
}
private static func preferredHeight(for barHeight: CGFloat) -> CGFloat {
if #available(iOS 26, *) {
barHeight + 8
} else {
barHeight
}
}
}
private final class AccessoryButton: UIButton {
private let size: CGFloat
private let handler: () -> Void
private let lockIndicator = UIView()
init(size: CGFloat, handler: @escaping () -> Void) {
self.size = size
self.handler = handler
super.init(frame: .zero)
translatesAutoresizingMaskIntoConstraints = false
layer.cornerRadius = size / 2
layer.cornerCurve = .continuous
clipsToBounds = true
tintColor = .label
backgroundColor = UIColor.systemGray5.withAlphaComponent(0.92)
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
imageView?.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([
widthAnchor.constraint(equalToConstant: size),
heightAnchor.constraint(equalToConstant: size),
])
lockIndicator.translatesAutoresizingMaskIntoConstraints = false
lockIndicator.backgroundColor = tintColor
lockIndicator.layer.cornerRadius = 1.5
lockIndicator.isHidden = true
addSubview(lockIndicator)
NSLayoutConstraint.activate([
lockIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
lockIndicator.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3),
lockIndicator.widthAnchor.constraint(equalToConstant: 14),
lockIndicator.heightAnchor.constraint(equalToConstant: 3),
])
addAction(UIAction { [weak self] _ in
self?.handler()
}, for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func applyRegularStyle(_ style: TerminalInputAccessoryStyle) {
isEnabled = true
alpha = 1
tintColor = style.regularForeground
backgroundColor = style.regularBackground
lockIndicator.isHidden = true
lockIndicator.backgroundColor = tintColor
configuration?.baseForegroundColor = tintColor
}
func applyModifierStyle(
_ activation: TerminalStickyModifierState.Activation,
isDisabled: Bool,
style: TerminalInputAccessoryStyle
) {
isEnabled = !isDisabled
alpha = isDisabled && activation == .inactive ? 0.45 : 1
let isActive = activation != .inactive
tintColor = isActive ? style.activeForeground : style.regularForeground
backgroundColor = isActive ? style.activeBackground : style.regularBackground
lockIndicator.isHidden = activation != .locked
lockIndicator.backgroundColor = tintColor
configuration?.baseForegroundColor = tintColor
}
}
#endif

View File

@@ -0,0 +1,55 @@
//
// TerminalInputBarKey.swift
// libghostty-spm
//
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
public enum TerminalInputAccessoryItem: Equatable, Sendable {
case esc
case ctrl
case alt
case command
case tab
case arrowLeft
case arrowUp
case arrowDown
case arrowRight
case symbol(String)
case paste
case divider
public static let defaultItems: [TerminalInputAccessoryItem] = [
.esc,
.tab,
.ctrl,
.alt,
.command,
.divider,
.arrowLeft,
.arrowUp,
.arrowDown,
.arrowRight,
.divider,
.symbol("|"),
.symbol("/"),
.symbol("~"),
.symbol("-"),
.symbol("_"),
.symbol("`"),
.symbol("'"),
.symbol("\""),
.paste,
]
}
enum TerminalInputBarKey {
case esc
case tab
case arrowLeft
case arrowUp
case arrowDown
case arrowRight
case symbol(String)
case paste
}
#endif

View File

@@ -0,0 +1,81 @@
//
// TerminalStickyModifierState.swift
// libghostty-spm
//
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
import Foundation
@MainActor
final class TerminalStickyModifierState {
enum Activation { case inactive, armed, locked }
enum Modifier { case ctrl, alt, command }
private(set) var ctrl: Activation = .inactive
private(set) var alt: Activation = .inactive
private(set) var command: Activation = .inactive
var onChange: (() -> Void)?
private var lastCtrlTap: Date = .distantPast
private var lastAltTap: Date = .distantPast
private var lastCommandTap: Date = .distantPast
private let doubleTapInterval: TimeInterval = 0.3
func toggle(_ modifier: Modifier) {
switch modifier {
case .ctrl:
ctrl = nextActivation(ctrl, lastTap: lastCtrlTap)
lastCtrlTap = Date()
case .alt:
alt = nextActivation(alt, lastTap: lastAltTap)
lastAltTap = Date()
case .command:
command = nextActivation(command, lastTap: lastCommandTap)
lastCommandTap = Date()
}
onChange?()
}
func consumeForNextKey() -> TerminalInputModifiers {
var mods = TerminalInputModifiers()
if ctrl != .inactive { mods.insert(.ctrl) }
if alt != .inactive { mods.insert(.alt) }
if command != .inactive { mods.insert(.super_) }
if ctrl == .armed { ctrl = .inactive }
if alt == .armed { alt = .inactive }
if command == .armed { command = .inactive }
onChange?()
return mods
}
var hasActiveModifiers: Bool {
ctrl != .inactive || alt != .inactive || command != .inactive
}
func reset() {
guard hasActiveModifiers else { return }
ctrl = .inactive
alt = .inactive
command = .inactive
onChange?()
}
private func nextActivation(
_ current: Activation,
lastTap: Date
) -> Activation {
switch current {
case .inactive:
return .armed
case .armed:
if Date().timeIntervalSince(lastTap) < doubleTapInterval {
return .locked
}
return .inactive
case .locked:
return .inactive
}
}
}
#endif

View File

@@ -0,0 +1,254 @@
//
// TerminalTextInputHandler@UIKit.swift
// libghostty-spm
//
#if canImport(UIKit)
import GhosttyKit
import UIKit
@MainActor
final class TerminalTextInputHandler {
private weak var view: UITerminalView?
private var markedTextState = TerminalMarkedTextState()
var hasMarkedText: Bool {
markedTextState.hasMarkedText
}
var documentLength: Int {
markedTextState.documentLength
}
init(view: UITerminalView) {
self.view = view
}
// MARK: - Text Input
func insertText(
_ text: String,
applyingStickyModifiers: Bool = false
) {
guard let view else { return }
let shouldNotifySelectionChange = shouldNotifySelectionChange
TerminalDebugLog.log(
.input,
"insertText text=\(TerminalDebugLog.describe(text)) marked=\(hasMarkedText)"
)
view.inputDelegate?.textWillChange(view)
if shouldNotifySelectionChange {
view.inputDelegate?.selectionWillChange(view)
}
markedTextState.clear()
view.surface?.preedit("")
#if !targetEnvironment(macCatalyst)
if applyingStickyModifiers {
_ = view.handleStickyCommittedText(text)
} else {
view.surface?.sendText(text)
}
#else
view.surface?.sendText(text)
#endif
view.refreshInputAccessoryContent()
if shouldNotifySelectionChange {
view.inputDelegate?.selectionDidChange(view)
}
view.inputDelegate?.textDidChange(view)
}
func setMarkedText(_ text: String?, selectedRange: NSRange) {
guard let view else { return }
let shouldNotifySelectionChange = shouldNotifySelectionChange
TerminalDebugLog.log(
.ime,
"setMarkedText text=\(TerminalDebugLog.describe(text)) selected=\(TerminalDebugLog.describe(selectedRange))"
)
#if !targetEnvironment(macCatalyst)
if let text, !text.isEmpty {
if view.stickyModifiers.hasActiveModifiers {
view.inputDelegate?.textWillChange(view)
if shouldNotifySelectionChange {
view.inputDelegate?.selectionWillChange(view)
}
markedTextState.clear()
view.surface?.preedit("")
_ = view.handleStickyMarkedText(text)
view.refreshInputAccessoryContent()
if shouldNotifySelectionChange {
view.inputDelegate?.selectionDidChange(view)
}
view.inputDelegate?.textDidChange(view)
return
}
}
#endif
view.inputDelegate?.textWillChange(view)
view.inputDelegate?.selectionWillChange(view)
markedTextState.setMarkedText(text, selectedRange: selectedRange)
if let text = markedTextState.text, !text.isEmpty {
view.surface?.preedit(text)
} else {
view.surface?.preedit("")
}
view.refreshInputAccessoryContent()
view.inputDelegate?.selectionDidChange(view)
view.inputDelegate?.textDidChange(view)
}
func unmarkText(
applyingStickyModifiers: Bool = false
) {
guard let view else { return }
let shouldNotifySelectionChange = shouldNotifySelectionChange
let committedText = markedTextState.text
TerminalDebugLog.log(
.ime,
"unmarkText committed=\(TerminalDebugLog.describe(committedText))"
)
view.inputDelegate?.textWillChange(view)
if shouldNotifySelectionChange {
view.inputDelegate?.selectionWillChange(view)
}
markedTextState.clear()
view.surface?.preedit("")
if let committedText, !committedText.isEmpty {
#if !targetEnvironment(macCatalyst)
if applyingStickyModifiers {
_ = view.handleStickyCommittedText(committedText)
} else {
view.surface?.sendText(committedText)
}
#else
view.surface?.sendText(committedText)
#endif
}
view.refreshInputAccessoryContent()
if shouldNotifySelectionChange {
view.inputDelegate?.selectionDidChange(view)
}
view.inputDelegate?.textDidChange(view)
}
func markedTextRange() -> TerminalTextRange? {
guard markedTextState.hasMarkedText else { return nil }
return TerminalTextRange(
location: markedTextState.markedRange.location,
length: markedTextState.markedRange.length
)
}
func selectedTextRange() -> TerminalTextRange {
TerminalTextRange(
location: markedTextState.selectedRange.location,
length: markedTextState.selectedRange.length
)
}
func setSelectedTextRange(_ range: UITextRange?) {
let updatedRange = if let range = range as? TerminalTextRange {
NSRange(
location: range.location,
length: range.length
)
} else {
NSRange(location: 0, length: 0)
}
let clampedRange = clampedSelectedRange(updatedRange)
guard markedTextState.selectedRange != clampedRange else { return }
TerminalDebugLog.log(
.ime,
"setSelectedTextRange range=\(TerminalDebugLog.describe(clampedRange))"
)
notifySelectionWillChange()
markedTextState.setMarkedText(markedTextState.text, selectedRange: clampedRange)
notifySelectionDidChange()
}
func text(in range: TerminalTextRange) -> String? {
markedTextState.text(in: NSRange(
location: range.location,
length: range.length
))
}
func deleteBackwardInMarkedText() -> Bool {
guard let view else { return false }
guard markedTextState.hasMarkedText else { return false }
let shouldNotifySelectionChange = shouldNotifySelectionChange
TerminalDebugLog.log(
.ime,
"deleteBackwardInMarkedText selected=\(TerminalDebugLog.describe(markedTextState.selectedRange))"
)
view.inputDelegate?.textWillChange(view)
if shouldNotifySelectionChange {
view.inputDelegate?.selectionWillChange(view)
}
_ = markedTextState.deleteBackward()
view.surface?.preedit(markedTextState.text ?? "")
view.refreshInputAccessoryContent()
if shouldNotifySelectionChange {
view.inputDelegate?.selectionDidChange(view)
}
view.inputDelegate?.textDidChange(view)
return true
}
func notifyGeometryDidChange(reason: String) {
guard let view else { return }
TerminalDebugLog.log(
.ime,
"notifyGeometryDidChange reason=\(reason) selected=\(TerminalDebugLog.describe(markedTextState.selectedRange)) documentLength=\(markedTextState.documentLength) marked=\(hasMarkedText)"
)
view.inputDelegate?.selectionWillChange(view)
view.inputDelegate?.selectionDidChange(view)
if view.isFirstResponder {
view.reloadInputViews()
}
}
private var shouldNotifySelectionChange: Bool {
hasMarkedText
|| markedTextState.selectedRange.location != 0
|| markedTextState.selectedRange.length != 0
}
private func clampedSelectedRange(_ range: NSRange) -> NSRange {
let length = markedTextState.documentLength
let location = min(max(range.location, 0), length)
let end = min(max(range.location + range.length, location), length)
return NSRange(location: location, length: end - location)
}
private func notifySelectionWillChange() {
if let view {
view.inputDelegate?.selectionWillChange(view)
}
}
private func notifySelectionDidChange() {
if let view {
view.inputDelegate?.selectionDidChange(view)
}
}
}
#endif

View File

@@ -0,0 +1,63 @@
//
// TerminalTextPosition.swift
// libghostty-spm
//
#if canImport(UIKit)
import UIKit
final class TerminalTextPosition: UITextPosition {
let index: Int
init(_ index: Int) {
self.index = index
super.init()
}
}
final class TerminalTextRange: UITextRange {
private let _start: TerminalTextPosition
private let _end: TerminalTextPosition
override var start: UITextPosition {
_start
}
override var end: UITextPosition {
_end
}
override var isEmpty: Bool {
_start.index >= _end.index
}
var startPosition: TerminalTextPosition {
_start
}
var endPosition: TerminalTextPosition {
_end
}
var location: Int {
_start.index
}
var length: Int {
_end.index - _start.index
}
init(start: TerminalTextPosition, end: TerminalTextPosition) {
_start = start
_end = end
super.init()
}
convenience init(location: Int, length: Int) {
self.init(
start: TerminalTextPosition(location),
end: TerminalTextPosition(location + length)
)
}
}
#endif

View File

@@ -0,0 +1,313 @@
//
// UITerminalView+InputAccessory.swift
// libghostty-spm
//
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
import GhosttyKit
import UIKit
extension UITerminalView {
override open var inputAccessoryView: UIView? {
inputAccessoryItems.isEmpty ? nil : terminalInputAccessory
}
func handleInputBarKey(_ key: TerminalInputBarKey) {
commitMarkedTextIfStickyModifiersAreActive()
switch key {
case let .symbol(text):
_ = handleStickyTextInput(text)
case .paste:
_ = stickyModifiers.consumeForNextKey()
if let text = UIPasteboard.general.string, !text.isEmpty {
inputHandler.insertText(text)
}
case .esc:
let mods = stickyModifiers.consumeForNextKey()
sendSyntheticKey(usage: 0x29, additionalMods: mods)
case .tab:
let mods = stickyModifiers.consumeForNextKey()
sendSyntheticKey(usage: 0x2B, additionalMods: mods)
case .arrowLeft:
let mods = stickyModifiers.consumeForNextKey()
sendSyntheticKey(usage: 0x50, additionalMods: mods)
case .arrowRight:
let mods = stickyModifiers.consumeForNextKey()
sendSyntheticKey(usage: 0x4F, additionalMods: mods)
case .arrowUp:
let mods = stickyModifiers.consumeForNextKey()
sendSyntheticKey(usage: 0x52, additionalMods: mods)
case .arrowDown:
let mods = stickyModifiers.consumeForNextKey()
sendSyntheticKey(usage: 0x51, additionalMods: mods)
}
}
private func commitMarkedTextIfStickyModifiersAreActive() {
guard stickyModifiers.hasActiveModifiers, inputHandler.hasMarkedText else { return }
inputHandler.unmarkText(applyingStickyModifiers: false)
}
func sendSyntheticKey(
usage: UInt16,
additionalMods: TerminalInputModifiers = []
) {
guard let surface else { return }
if inputHandler.hasMarkedText {
inputHandler.unmarkText()
}
// Unmodified accessory arrows/Esc/Tab can still use the direct
// in-memory byte path, but sticky modifiers must round-trip
// through Ghostty so modifier-aware escape sequences are preserved.
let delivery = TerminalHardwareKeyRouter.routeUIKit(
usage: usage,
backend: configuration.backend,
modifiers: additionalMods
)
if !additionalMods.isEmpty, let ghosttyKey = ghosttyKey(from: delivery) {
var event = ghostty_input_key_s()
event.action = GHOSTTY_ACTION_PRESS
event.keycode = TerminalHardwareKeyRouter.appKitKeyCode(
for: ghosttyKey
)
event.mods = additionalMods.ghosttyMods
_ = surface.sendKeyEvent(event)
return
}
switch delivery {
case let .data(data):
guard case let .inMemory(session) = configuration.backend else { return }
session.sendInput(data)
case let .ghostty(ghosttyKey):
var event = ghostty_input_key_s()
event.action = GHOSTTY_ACTION_PRESS
event.keycode = TerminalHardwareKeyRouter.appKitKeyCode(
for: ghosttyKey
)
event.mods = additionalMods.ghosttyMods
_ = surface.sendKeyEvent(event)
}
}
@discardableResult
func handleStickyTextInput(_ text: String) -> Bool {
handleStickyTextInput(text) { [weak self] text in
self?.inputHandler.insertText(text)
}
}
@discardableResult
func handleStickyCommittedText(_ text: String) -> Bool {
handleStickyTextInput(text) { [weak self] text in
self?.surface?.sendText(text)
}
}
@discardableResult
func handleStickyMarkedText(_ text: String) -> Bool {
guard stickyModifiers.hasActiveModifiers else { return false }
let keyText = String(text.prefix(1))
guard !keyText.isEmpty else {
stickyModifiers.reset()
return false
}
let mods = stickyModifiers.consumeForNextKey()
let handled: Bool
if mods == .ctrl, let controlByte = controlByte(for: keyText) {
sendControlByte(controlByte, modifiers: mods)
handled = true
} else {
handled = sendModifiedTextKey(keyText, modifiers: mods)
}
stickyModifiers.reset()
return handled
}
@discardableResult
private func handleStickyTextInput(
_ text: String,
fallback: (String) -> Void
) -> Bool {
commitMarkedTextIfStickyModifiersAreActive()
guard stickyModifiers.hasActiveModifiers else {
fallback(text)
return false
}
let mods = stickyModifiers.consumeForNextKey()
if mods == .ctrl, let controlByte = controlByte(for: text) {
sendControlByte(controlByte, modifiers: mods)
return true
}
if sendModifiedTextKey(text, modifiers: mods) {
return true
}
fallback(text)
return false
}
func sendControlByte(
_ byte: UInt8,
modifiers: TerminalInputModifiers = .ctrl
) {
if inputHandler.hasMarkedText {
inputHandler.unmarkText()
}
if case let .inMemory(session) = configuration.backend {
session.sendInput(Data([byte]))
} else if let surface {
var event = ghostty_input_key_s()
event.action = GHOSTTY_ACTION_PRESS
event.mods = modifiers.ghosttyMods
let char = Character(UnicodeScalar(byte | 0x60))
let ghosttyKey = ghosttyKeyForCharacter(char)
event.keycode = TerminalHardwareKeyRouter.appKitKeyCode(
for: ghosttyKey
)
_ = surface.sendKeyEvent(event)
}
}
private func controlByte(for text: String) -> UInt8? {
guard text.count == 1 else { return nil }
guard let ascii = text.lowercased().utf8.first else { return nil }
guard ascii >= 0x61, ascii <= 0x7A else { return nil }
return ascii & 0x1F
}
private func sendModifiedTextKey(
_ text: String,
modifiers: TerminalInputModifiers
) -> Bool {
guard let surface else { return false }
if inputHandler.hasMarkedText {
inputHandler.unmarkText()
}
guard let mapping = keyMapping(for: text) else { return false }
var event = ghostty_input_key_s()
event.action = GHOSTTY_ACTION_PRESS
event.keycode = TerminalHardwareKeyRouter.appKitKeyCode(
for: mapping.key
)
event.mods = modifiers.union(mapping.extraModifiers).ghosttyMods
if !modifiers.contains(.super_) {
text.withCString { ptr in
event.text = ptr
_ = surface.sendKeyEvent(event)
}
} else {
_ = surface.sendKeyEvent(event)
}
return true
}
private func ghosttyKey(from delivery: TerminalHardwareKeyDelivery) -> ghostty_input_key_e? {
guard case let .ghostty(ghosttyKey) = delivery else { return nil }
return ghosttyKey
}
private func keyMapping(
for text: String
) -> (key: ghostty_input_key_e, extraModifiers: TerminalInputModifiers)? {
guard text.count == 1, let char = text.first else { return nil }
switch char {
case "a" ... "z":
return (ghosttyKeyForCharacter(char), [])
case "A" ... "Z":
return (ghosttyKeyForCharacter(Character(char.lowercased())), [.shift])
case "0": return (GHOSTTY_KEY_DIGIT_0, [])
case "1": return (GHOSTTY_KEY_DIGIT_1, [])
case "2": return (GHOSTTY_KEY_DIGIT_2, [])
case "3": return (GHOSTTY_KEY_DIGIT_3, [])
case "4": return (GHOSTTY_KEY_DIGIT_4, [])
case "5": return (GHOSTTY_KEY_DIGIT_5, [])
case "6": return (GHOSTTY_KEY_DIGIT_6, [])
case "7": return (GHOSTTY_KEY_DIGIT_7, [])
case "8": return (GHOSTTY_KEY_DIGIT_8, [])
case "9": return (GHOSTTY_KEY_DIGIT_9, [])
case "`": return (GHOSTTY_KEY_BACKQUOTE, [])
case "~": return (GHOSTTY_KEY_BACKQUOTE, [.shift])
case "-": return (GHOSTTY_KEY_MINUS, [])
case "_": return (GHOSTTY_KEY_MINUS, [.shift])
case "=": return (GHOSTTY_KEY_EQUAL, [])
case "+": return (GHOSTTY_KEY_EQUAL, [.shift])
case "[": return (GHOSTTY_KEY_BRACKET_LEFT, [])
case "{": return (GHOSTTY_KEY_BRACKET_LEFT, [.shift])
case "]": return (GHOSTTY_KEY_BRACKET_RIGHT, [])
case "}": return (GHOSTTY_KEY_BRACKET_RIGHT, [.shift])
case "\\": return (GHOSTTY_KEY_BACKSLASH, [])
case "|": return (GHOSTTY_KEY_BACKSLASH, [.shift])
case ";": return (GHOSTTY_KEY_SEMICOLON, [])
case ":": return (GHOSTTY_KEY_SEMICOLON, [.shift])
case "'": return (GHOSTTY_KEY_QUOTE, [])
case "\"": return (GHOSTTY_KEY_QUOTE, [.shift])
case ",": return (GHOSTTY_KEY_COMMA, [])
case "<": return (GHOSTTY_KEY_COMMA, [.shift])
case ".": return (GHOSTTY_KEY_PERIOD, [])
case ">": return (GHOSTTY_KEY_PERIOD, [.shift])
case "/": return (GHOSTTY_KEY_SLASH, [])
case "?": return (GHOSTTY_KEY_SLASH, [.shift])
case " ": return (GHOSTTY_KEY_SPACE, [])
default:
return nil
}
}
private func ghosttyKeyForCharacter(_ char: Character) -> ghostty_input_key_e {
switch char {
case "a": GHOSTTY_KEY_A
case "b": GHOSTTY_KEY_B
case "c": GHOSTTY_KEY_C
case "d": GHOSTTY_KEY_D
case "e": GHOSTTY_KEY_E
case "f": GHOSTTY_KEY_F
case "g": GHOSTTY_KEY_G
case "h": GHOSTTY_KEY_H
case "i": GHOSTTY_KEY_I
case "j": GHOSTTY_KEY_J
case "k": GHOSTTY_KEY_K
case "l": GHOSTTY_KEY_L
case "m": GHOSTTY_KEY_M
case "n": GHOSTTY_KEY_N
case "o": GHOSTTY_KEY_O
case "p": GHOSTTY_KEY_P
case "q": GHOSTTY_KEY_Q
case "r": GHOSTTY_KEY_R
case "s": GHOSTTY_KEY_S
case "t": GHOSTTY_KEY_T
case "u": GHOSTTY_KEY_U
case "v": GHOSTTY_KEY_V
case "w": GHOSTTY_KEY_W
case "x": GHOSTTY_KEY_X
case "y": GHOSTTY_KEY_Y
case "z": GHOSTTY_KEY_Z
default: GHOSTTY_KEY_UNIDENTIFIED
}
}
}
#endif

View File

@@ -0,0 +1,652 @@
//
// UITerminalView+Interaction.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
#if canImport(UIKit)
import GhosttyKit
import UIKit
extension UITerminalView {
override open func touchesBegan(
_ touches: Set<UITouch>,
with event: UIEvent?
) {
if handleIndirectPointerTouches(touches, phase: .began, event: event) {
return
}
super.touchesBegan(touches, with: event)
#if targetEnvironment(macCatalyst)
becomeFirstResponder()
#else
pendingKeyboardDismissOnTouchEnd = false
touchDidScrollDuringCurrentTouch = false
if softwareKeyboardVisible {
pendingKeyboardDismissOnTouchEnd = true
} else {
becomeFirstResponder()
}
#endif
}
override open func touchesMoved(
_ touches: Set<UITouch>,
with event: UIEvent?
) {
if handleIndirectPointerTouches(touches, phase: .moved, event: event) {
return
}
super.touchesMoved(touches, with: event)
}
override open func touchesEnded(
_ touches: Set<UITouch>,
with event: UIEvent?
) {
if handleIndirectPointerTouches(touches, phase: .ended, event: event) {
return
}
#if !targetEnvironment(macCatalyst)
if pendingKeyboardDismissOnTouchEnd, !touchDidScrollDuringCurrentTouch {
resignFirstResponder()
}
pendingKeyboardDismissOnTouchEnd = false
touchDidScrollDuringCurrentTouch = false
#endif
super.touchesEnded(touches, with: event)
}
override open func touchesCancelled(
_ touches: Set<UITouch>,
with event: UIEvent?
) {
if handleIndirectPointerTouches(touches, phase: .cancelled, event: event) {
return
}
#if !targetEnvironment(macCatalyst)
pendingKeyboardDismissOnTouchEnd = false
touchDidScrollDuringCurrentTouch = false
#endif
super.touchesCancelled(touches, with: event)
}
func setupPlatformInput() {
addInteraction(selectionContextMenuInteraction)
#if targetEnvironment(macCatalyst)
setupCatalystScrollWheelInput()
#else
setupTouchScrollInput()
#endif
}
enum IndirectPointerPhase {
case began
case moved
case ended
case cancelled
}
func handleIndirectPointerTouches(
_ touches: Set<UITouch>,
phase: IndirectPointerPhase,
event: UIEvent?
) -> Bool {
let hasIndirectPointerTouch = touches.contains { $0.type == .indirectPointer }
#if !targetEnvironment(macCatalyst)
if suppressNextIndirectPointerTouchEnd, hasIndirectPointerTouch {
if phase == .ended || phase == .cancelled {
suppressNextIndirectPointerTouchEnd = false
return true
}
suppressNextIndirectPointerTouchEnd = false
}
if indirectPointerPanOwnsTouchSequence, hasIndirectPointerTouch {
if phase == .began {
indirectPointerPanOwnsTouchSequence = false
} else {
return true
}
}
#endif
guard hasIndirectPointerTouch,
let touch = touches.first(where: { $0.type == .indirectPointer })
else {
return false
}
core.setFocus(true)
#if targetEnvironment(macCatalyst)
if phase == .began {
becomeFirstResponder()
}
#endif
stopMomentumScrolling()
let button = pointerButton(from: event)
let mods = ghostty_input_mods_e(rawValue: 0)
let location = touch.location(in: self)
let suppressSurfacePositionForSelectionMenu =
button == GHOSTTY_MOUSE_RIGHT &&
(pendingSelectionMenuPoint != nil || pointIsInsidePointerSelection(location))
TerminalDebugLog.log(
.input,
"pointer touch phase=\(phase) type=\(touch.type.rawValue) button=\(button.rawValue) location=\(NSCoder.string(for: location)) mask=\(event?.buttonMask.rawValue ?? 0)"
)
if !suppressSurfacePositionForSelectionMenu {
surface?.sendMousePos(
x: location.x,
y: location.y,
mods: mods
)
}
switch phase {
case .began:
activePointerButton = button
switch button {
case GHOSTTY_MOUSE_LEFT:
pointerSelectionStartPoint = location
pendingSelectionMenuPoint = nil
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_PRESS,
button: button,
mods: mods
)
case GHOSTTY_MOUSE_RIGHT:
if pointIsInsidePointerSelection(location) {
pendingSelectionMenuPoint = location
} else {
pendingSelectionMenuPoint = selectionMenuPoint(at: location)
}
default:
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_PRESS,
button: button,
mods: mods
)
}
case .moved:
updatePointerSelectionRect(to: location)
case .ended:
let releasedButton = activePointerButton ?? button
activePointerButton = nil
if releasedButton == GHOSTTY_MOUSE_RIGHT,
pendingSelectionMenuPoint != nil
{
if selectionMenuPoint(at: location) != nil {
showSelectionCopyMenu(at: location)
}
pendingSelectionMenuPoint = nil
return true
}
if releasedButton == GHOSTTY_MOUSE_RIGHT {
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_PRESS,
button: releasedButton,
mods: mods
)
}
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_RELEASE,
button: releasedButton,
mods: mods
)
if releasedButton == GHOSTTY_MOUSE_LEFT {
finishPointerSelection(at: location)
}
pendingSelectionMenuPoint = nil
case .cancelled:
let releasedButton = activePointerButton ?? button
activePointerButton = nil
pendingSelectionMenuPoint = nil
pointerSelectionStartPoint = nil
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_RELEASE,
button: releasedButton,
mods: mods
)
}
return true
}
func pointerButton(from event: UIEvent?) -> ghostty_input_mouse_button_e {
guard let event else { return GHOSTTY_MOUSE_LEFT }
if event.buttonMask.contains(.secondary) {
return GHOSTTY_MOUSE_RIGHT
}
if event.buttonMask.contains(.primary) {
return GHOSTTY_MOUSE_LEFT
}
return GHOSTTY_MOUSE_LEFT
}
func updatePointerSelectionRect(to point: CGPoint) {
guard activePointerButton == GHOSTTY_MOUSE_LEFT,
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)
logPointerSelectionDiagnostics(
context: "updatePointerSelectionRect",
point: point
)
}
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)
}
logPointerSelectionDiagnostics(
context: "finishPointerSelection",
point: point
)
}
func logPointerSelectionDiagnostics(context: String, point: CGPoint) {
guard TerminalDebugLog.isEnabled,
TerminalDebugLog.categories.contains(.input)
else { return }
let rectDescription = lastPointerSelectionRect.map {
NSCoder.string(for: $0)
} ?? "nil"
let metricsDescription = surface?.size().map(\.debugSummary) ?? "nil"
let selection = surface?.readSelectionResult()
let selectionDescription = selection.map {
"text=\(TerminalDebugLog.describe($0.text)) offset=\($0.offsetStart)+\($0.offsetLength)"
} ?? "nil"
let word = surface?.quicklookWord()
let wordDescription = word.map {
"word=\(TerminalDebugLog.describe($0.word)) offset=\($0.offsetStart)+\($0.offsetLength) point=\(String(format: "%.2f", $0.pointX))x\(String(format: "%.2f", $0.pointY))"
} ?? "nil"
TerminalDebugLog.log(
.input,
"pointer selection \(context) viewBounds=\(NSCoder.string(for: bounds)) point=\(NSCoder.string(for: point)) rect=\(rectDescription) metrics=\(metricsDescription) selection=\(selectionDescription) quicklook=\(wordDescription)"
)
}
@IBAction override open func copy(_: Any?) {
guard copySelectedTextToPasteboard() else { return }
}
override open func canPerformAction(
_ action: Selector,
withSender sender: Any?
) -> Bool {
if action == #selector(copy(_:)) {
return surface?.hasSelection() == true
}
return super.canPerformAction(action, withSender: sender)
}
func pointIsInsidePointerSelection(_ point: CGPoint) -> Bool {
lastPointerSelectionRect.map {
$0.insetBy(dx: -4, dy: -4).contains(point)
} ?? false
}
#if targetEnvironment(macCatalyst)
func setupCatalystScrollWheelInput() {
let gesture = UIPanGestureRecognizer(
target: self,
action: #selector(handleCatalystScrollWheelGesture(_:))
)
gesture.allowedScrollTypesMask = [.continuous, .discrete]
gesture.cancelsTouchesInView = false
gesture.delaysTouchesBegan = false
gesture.delaysTouchesEnded = false
addGestureRecognizer(gesture)
}
@objc func handleCatalystScrollWheelGesture(
_ gesture: UIPanGestureRecognizer
) {
guard activePointerButton == nil else { return }
let translation = gesture.translation(in: self)
gesture.setTranslation(.zero, in: self)
TerminalDebugLog.log(
.input,
"catalyst scroll translation=\(String(format: "%.2f", translation.x))x\(String(format: "%.2f", translation.y))"
)
let scrollMods = TerminalScrollModifiers(precision: true)
surface?.sendMouseScroll(
x: Double(translation.x),
y: Double(translation.y),
mods: scrollMods.rawValue
)
}
#else
func setupTouchScrollInput() {
let gesture = UIPanGestureRecognizer(
target: self,
action: #selector(handleTouchScrollGesture(_:))
)
gesture.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)]
gesture.maximumNumberOfTouches = 1
addGestureRecognizer(gesture)
let longPress = UILongPressGestureRecognizer(
target: self,
action: #selector(handleLongPressForSelection(_:))
)
longPress.minimumPressDuration = 0.5
longPress.allowableMovement = 10
longPress.numberOfTouchesRequired = 1
longPress.numberOfTapsRequired = 0
longPress.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)]
longPress.cancelsTouchesInView = false
longPress.delegate = self
addGestureRecognizer(longPress)
setupIndirectPointerSelectionGesture()
currentFontSize = configuration.fontSize ?? 14
setupPinchZoomGesture()
}
func setupIndirectPointerSelectionGesture() {
let gesture = UIPanGestureRecognizer(
target: self,
action: #selector(handleIndirectPointerSelectionGesture(_:))
)
gesture.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)]
gesture.minimumNumberOfTouches = 1
gesture.maximumNumberOfTouches = 1
gesture.cancelsTouchesInView = false
gesture.delaysTouchesBegan = false
gesture.delaysTouchesEnded = false
addGestureRecognizer(gesture)
}
@objc func handleIndirectPointerSelectionGesture(
_ gesture: UIPanGestureRecognizer
) {
let location = gesture.location(in: self)
let mods = ghostty_input_mods_e(rawValue: 0)
TerminalDebugLog.log(
.input,
"indirect pointer gesture state=\(gesture.state.rawValue) location=\(NSCoder.string(for: location)) translation=\(NSCoder.string(for: gesture.translation(in: self)))"
)
switch gesture.state {
case .began:
core.setFocus(true)
stopMomentumScrolling()
indirectPointerPanOwnsTouchSequence = true
if activePointerButton != GHOSTTY_MOUSE_LEFT {
activePointerButton = GHOSTTY_MOUSE_LEFT
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_PRESS,
button: GHOSTTY_MOUSE_LEFT,
mods: mods
)
}
if pointerSelectionStartPoint == nil {
pointerSelectionStartPoint = location
}
pendingSelectionMenuPoint = nil
surface?.sendMousePos(x: location.x, y: location.y, mods: mods)
case .changed:
updatePointerSelectionRect(to: location)
surface?.sendMousePos(x: location.x, y: location.y, mods: mods)
case .ended:
activePointerButton = nil
updatePointerSelectionRect(to: location)
surface?.sendMousePos(x: location.x, y: location.y, mods: mods)
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_RELEASE,
button: GHOSTTY_MOUSE_LEFT,
mods: mods
)
finishPointerSelection(at: location)
indirectPointerPanOwnsTouchSequence = false
suppressNextIndirectPointerTouchEnd = true
case .cancelled, .failed:
activePointerButton = nil
indirectPointerPanOwnsTouchSequence = false
suppressNextIndirectPointerTouchEnd = true
pointerSelectionStartPoint = nil
pendingSelectionMenuPoint = nil
lastPointerSelectionRect = nil
surface?.sendMouseButton(
state: GHOSTTY_MOUSE_RELEASE,
button: GHOSTTY_MOUSE_LEFT,
mods: mods
)
default:
break
}
}
@objc func handleLongPressForSelection(
_ gesture: UILongPressGestureRecognizer
) {
guard gesture.state == .began else { return }
guard let delegate = delegate as? any TerminalSurfaceTextSelectionRequestDelegate else { return }
guard let surface else { return }
guard case let .inMemory(session) = configuration.backend else {
TerminalDebugLog.log(.input, "long-press selection ignored: backend not inMemory")
return
}
stopMomentumScrolling()
let viewPoint = gesture.location(in: self)
surface.sendMousePos(
x: Double(viewPoint.x),
y: Double(viewPoint.y),
mods: ghostty_input_mods_e(rawValue: 0)
)
let wordResult = surface.quicklookWord()
guard let text = session.readViewportText() else {
TerminalDebugLog.log(
.input,
"long-press selection aborted: readViewportText returned nil"
)
return
}
var anchorRange: NSRange?
if let w = wordResult, !text.isEmpty, let size = surface.size() {
let scale = Double(resolvedDisplayScale())
// cellWidth/HeightPixels are surface pixels; ghostty's
// tl_px_x/y are host points. Convert to points before
// dividing so units match inside resolveRange.
let cellWidthPoints = scale > 0 ? Double(size.cellWidthPixels) / scale : 0
let cellHeightPoints = scale > 0 ? Double(size.cellHeightPixels) / scale : 0
anchorRange = TerminalSelectionAnchor.resolveRange(
in: text,
word: w.word,
pointX: w.pointX,
pointY: w.pointY,
cellWidthPoints: cellWidthPoints,
cellHeightPoints: cellHeightPoints
)
}
TerminalDebugLog.log(
.input,
"long-press selection dispatch viewPoint=\(NSCoder.string(for: viewPoint)) word=\(TerminalDebugLog.describe(wordResult?.word ?? "nil")) anchor=\(anchorRange.map { NSStringFromRange($0) } ?? "nil")"
)
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
delegate.terminalDidRequestTextSelection(.init(
text: text,
anchorRange: anchorRange,
sourcePoint: viewPoint
))
}
#endif
@objc func handleTouchScrollGesture(
_ gesture: UIPanGestureRecognizer
) {
switch gesture.state {
case .began:
guard activePointerButton == nil else { return }
#if !targetEnvironment(macCatalyst)
touchDidScrollDuringCurrentTouch = true
#endif
TerminalDebugLog.log(.input, "touch scroll began")
stopMomentumScrolling()
case .changed:
guard activePointerButton == nil else { return }
let translation = gesture.translation(in: self)
gesture.setTranslation(.zero, in: self)
TerminalDebugLog.log(
.input,
"touch scroll changed translation=\(String(format: "%.2f", translation.x))x\(String(format: "%.2f", translation.y))"
)
let scrollMods = TerminalScrollModifiers(precision: true)
surface?.sendMouseScroll(
x: Double(translation.x * touchScrollMultiplier),
y: Double(translation.y * touchScrollMultiplier),
mods: scrollMods.rawValue
)
case .ended:
guard activePointerButton == nil else { return }
let velocity = gesture.velocity(in: self)
TerminalDebugLog.log(
.input,
"touch scroll ended velocity=\(String(format: "%.2f", velocity.x))x\(String(format: "%.2f", velocity.y))"
)
startMomentumScrolling(velocity: velocity)
case .cancelled, .failed:
TerminalDebugLog.log(.input, "touch scroll cancelled")
stopMomentumScrolling()
default:
break
}
}
func startMomentumScrolling(velocity: CGPoint) {
guard abs(velocity.x) > 50 || abs(velocity.y) > 50 else { return }
momentumVelocity = velocity
TerminalDebugLog.log(
.input,
"momentum start velocity=\(String(format: "%.2f", velocity.x))x\(String(format: "%.2f", velocity.y))"
)
let mods = TerminalScrollModifiers(precision: true, momentum: .began)
surface?.sendMouseScroll(x: 0, y: 0, mods: mods.rawValue)
let link = CADisplayLink(
target: self,
selector: #selector(momentumScrollFrame(_:))
)
link.add(to: .main, forMode: .common)
momentumDisplayLink = link
}
@objc func momentumScrollFrame(_ link: CADisplayLink) {
let dt = link.targetTimestamp - link.timestamp
let deceleration: CGFloat = 0.92
momentumVelocity.x *= deceleration
momentumVelocity.y *= deceleration
let deltaX = momentumVelocity.x * dt * touchScrollMultiplier
let deltaY = momentumVelocity.y * dt * touchScrollMultiplier
if abs(momentumVelocity.x) < 50, abs(momentumVelocity.y) < 50 {
stopMomentumScrolling()
return
}
TerminalDebugLog.log(
.input,
"momentum frame velocity=\(String(format: "%.2f", momentumVelocity.x))x\(String(format: "%.2f", momentumVelocity.y)) delta=\(String(format: "%.2f", deltaX))x\(String(format: "%.2f", deltaY))"
)
let mods = TerminalScrollModifiers(precision: true, momentum: .changed)
surface?.sendMouseScroll(
x: Double(deltaX),
y: Double(deltaY),
mods: mods.rawValue
)
}
func stopMomentumScrolling(sendTerminalEndEvent: Bool = true) {
guard momentumDisplayLink != nil else { return }
TerminalDebugLog.log(.input, "momentum stop")
if sendTerminalEndEvent {
let mods = TerminalScrollModifiers(precision: true, momentum: .none)
surface?.sendMouseScroll(x: 0, y: 0, mods: mods.rawValue)
}
momentumDisplayLink?.invalidate()
momentumDisplayLink = nil
momentumVelocity = .zero
}
}
extension UITerminalView: UIGestureRecognizerDelegate, UIContextMenuInteractionDelegate {
/// Gate the long-press recognizer at the gesture layer when no host
/// has opted into selection delegate. Without this, the recognizer
/// still enters the touch arena for 0.5s and can subtly delay pan
/// recognition for hosts that don't want the feature at all.
override open func gestureRecognizerShouldBegin(
_ gestureRecognizer: UIGestureRecognizer
) -> Bool {
if gestureRecognizer is UILongPressGestureRecognizer {
return (delegate as? any TerminalSurfaceTextSelectionRequestDelegate) != nil
}
return true
}
open func contextMenuInteraction(
_: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
surface?.sendMousePos(
x: location.x,
y: location.y,
mods: ghostty_input_mods_e(rawValue: 0)
)
guard selectionMenuPoint(at: location) != nil else { return nil }
return selectionContextMenuConfiguration(at: location)
}
}
#endif

View File

@@ -0,0 +1,246 @@
//
// UITerminalView+Keyboard.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
#if canImport(UIKit)
import GhosttyKit
import UIKit
extension UITerminalView {
override open func pressesBegan(
_ presses: Set<UIPress>,
with _: UIPressesEvent?
) {
for press in presses {
guard let key = press.key else { continue }
handleKeyPress(key, action: GHOSTTY_ACTION_PRESS)
}
}
override open func pressesEnded(
_ presses: Set<UIPress>,
with _: UIPressesEvent?
) {
for press in presses {
guard let key = press.key else { continue }
handleKeyPress(key, action: GHOSTTY_ACTION_RELEASE)
}
hardwareKeyHandled = false
}
override open func pressesCancelled(
_ presses: Set<UIPress>,
with event: UIPressesEvent?
) {
hardwareKeyHandled = false
super.pressesCancelled(presses, with: event)
}
func handleKeyPress(
_ key: UIKey,
action: ghostty_input_action_e
) {
guard let surface else {
TerminalDebugLog.log(.input, "uikit key ignored: missing surface")
return
}
let filteredModifierFlags = filteredModifierFlags(for: key)
let isCommandModified = filteredModifierFlags.contains(.command)
let mods = TerminalInputModifiers(from: filteredModifierFlags)
let keyboardZoomDirection = commandZoomDirection(
for: key,
action: action,
filteredModifierFlags: filteredModifierFlags
)
if action == GHOSTTY_ACTION_PRESS,
shouldSuppressUIKeyInput(for: key, isCommandModified: isCommandModified)
{
hardwareKeyHandled = true
}
let delivery = TerminalHardwareKeyRouter.routeUIKit(
usage: UInt16(key.keyCode.rawValue),
backend: configuration.backend,
modifiers: mods
)
TerminalDebugLog.log(
.input,
"uikit key action=\(TerminalDebugLog.describe(action)) code=\(key.keyCode.rawValue) chars=\(TerminalDebugLog.describe(key.characters)) ignoring=\(TerminalDebugLog.describe(key.charactersIgnoringModifiers)) mods=0x\(String(filteredModifierFlags.rawValue, radix: 16)) delivery=\(delivery.debugSummary) marked=\(inputHandler.hasMarkedText)"
)
if action == GHOSTTY_ACTION_RELEASE, delivery.isDirectInput {
return
}
if handleDirectInputIfNeeded(
delivery,
action: action,
isCommandModified: isCommandModified,
filteredModifierFlags: filteredModifierFlags
) {
if let keyboardZoomDirection {
scheduleViewportRefreshAfterKeyboardZoom(keyboardZoomDirection)
}
return
}
var keyEvent = ghostty_input_key_s()
keyEvent.action = action
keyEvent.mods = mods.ghosttyMods
// Ghostty expects a platform-native keycode, which it resolves
// to its internal Key enum via src/input/keycodes.zig. On iOS
// that table uses macOS virtual keycodes (native_idx = 4), so
// translate the documented HID usage value from UIKey into the
// corresponding AppKit keycode here.
keyEvent.keycode = TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(
usage: UInt16(key.keyCode.rawValue)
)
keyEvent.composing = inputHandler.hasMarkedText
var consumedFlags = filteredModifierFlags
consumedFlags.remove(.control)
consumedFlags.remove(.command)
keyEvent.consumed_mods = TerminalInputModifiers(from: consumedFlags).ghosttyMods
guard action == GHOSTTY_ACTION_PRESS || action == GHOSTTY_ACTION_REPEAT else {
_ = surface.sendKeyEvent(keyEvent)
return
}
let filteredIgnoringModifiers = TerminalInputText.filteredFunctionKeyText(
key.charactersIgnoringModifiers
)
if let codepoint = filteredIgnoringModifiers?.unicodeScalars.first {
keyEvent.unshifted_codepoint = codepoint.value
}
guard !isCommandModified else {
_ = surface.sendKeyEvent(keyEvent)
if let keyboardZoomDirection {
scheduleViewportRefreshAfterKeyboardZoom(keyboardZoomDirection)
}
return
}
guard let text = TerminalInputText.filteredFunctionKeyText(key.characters),
!text.isEmpty
else {
_ = surface.sendKeyEvent(keyEvent)
return
}
text.withCString { ptr in
keyEvent.text = ptr
_ = surface.sendKeyEvent(keyEvent)
}
}
func shouldSuppressUIKeyInput(
for key: UIKey,
isCommandModified: Bool
) -> Bool {
guard !isCommandModified else { return false }
guard key.modifierFlags.intersection([.alternate, .control]).isEmpty else {
return false
}
guard !key.characters.isEmpty else {
return key.keyCode == .keyboardDeleteOrBackspace
}
return true
}
private func handleDirectInputIfNeeded(
_ delivery: TerminalHardwareKeyDelivery,
action: ghostty_input_action_e,
isCommandModified: Bool,
filteredModifierFlags: UIKeyModifierFlags
) -> Bool {
// When IME composition is active, UIKit must own editing keys such as
// backspace and arrows so candidate text stays in sync.
guard !inputHandler.hasMarkedText else { return false }
guard !isCommandModified else { return false }
guard filteredModifierFlags.intersection([.alternate, .control]).isEmpty else {
return false
}
guard action == GHOSTTY_ACTION_PRESS || action == GHOSTTY_ACTION_REPEAT else {
return false
}
guard case let .data(sequence) = delivery else { return false }
guard case let .inMemory(session) = configuration.backend else { return false }
session.sendInput(sequence)
return true
}
private func filteredModifierFlags(for key: UIKey) -> UIKeyModifierFlags {
var flags = key.modifierFlags
let isFunctionKey =
TerminalInputText.filteredFunctionKeyText(key.characters) == nil ||
TerminalInputText.filteredFunctionKeyText(key.charactersIgnoringModifiers) == nil
if isFunctionKey {
flags.remove(.numericPad)
}
return flags
}
private func commandZoomDirection(
for key: UIKey,
action: ghostty_input_action_e,
filteredModifierFlags: UIKeyModifierFlags
) -> KeyboardZoomDirection? {
guard action == GHOSTTY_ACTION_PRESS || action == GHOSTTY_ACTION_REPEAT else {
return nil
}
guard filteredModifierFlags.contains(.command) else { return nil }
let candidates = [
key.characters,
key.charactersIgnoringModifiers,
]
if candidates.contains(where: { $0 == "+" || $0 == "=" }) {
return .increase
}
if candidates.contains(where: { $0 == "-" || $0 == "_" }) {
return .decrease
}
return nil
}
private func scheduleViewportRefreshAfterKeyboardZoom(
_ direction: KeyboardZoomDirection
) {
TerminalDebugLog.log(
.actions,
"keyboard zoom shortcut direction=\(direction.rawValue)"
)
#if !targetEnvironment(macCatalyst)
switch direction {
case .increase:
currentFontSize = min(currentFontSize + 1, Self.maxFontSize)
case .decrease:
currentFontSize = max(currentFontSize - 1, Self.minFontSize)
}
#endif
DispatchQueue.main.async { [weak self] in
guard let self else { return }
core.synchronizeMetrics()
refreshTextInputGeometry(
reason: "keyboard-zoom-\(direction.rawValue)"
)
}
}
private enum KeyboardZoomDirection: String {
case increase
case decrease
}
}
#endif

View File

@@ -0,0 +1,172 @@
//
// UITerminalView+Lifecycle.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/17.
//
#if canImport(UIKit)
import UIKit
extension UITerminalView {
func setupApplicationLifecycleObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
}
func syncApplicationActiveState() {
core.setApplicationActive(
UIApplication.shared.applicationState == .active
)
}
@objc func applicationDidEnterBackground(_: Notification) {
TerminalDebugLog.log(.lifecycle, "application did enter background")
stopMomentumScrolling(sendTerminalEndEvent: false)
core.setApplicationActive(false)
}
@objc func applicationDidBecomeActive(_: Notification) {
TerminalDebugLog.log(.lifecycle, "application did become active")
updateDisplayScale()
updateColorScheme()
core.setApplicationActive(true)
}
override open func didMoveToWindow() {
super.didMoveToWindow()
TerminalDebugLog.log(
.lifecycle,
"didMoveToWindow attached=\(window != nil)"
)
updateDisplayScale()
if window != nil {
core.rebuildIfReady()
updateColorScheme()
core.startDisplayLink()
// Defer sublayer frame and metrics sync to the next runloop
// so that AutoLayout has resolved final bounds.
DispatchQueue.main.async { [weak self] in
guard let self, window != nil else { return }
updateSublayerFrames()
core.fitToSize()
}
} else {
core.stopDisplayLink()
core.freeSurface()
}
}
override open func layoutSubviews() {
super.layoutSubviews()
TerminalDebugLog.log(
.metrics,
"layoutSubviews bounds=\(NSCoder.string(for: bounds))"
)
updateSublayerFrames()
core.fitToSize()
}
func resolvedDisplayScale() -> CGFloat {
if let screen = window?.screen {
return screen.nativeScale
}
if traitCollection.displayScale > 0 {
return traitCollection.displayScale
}
return UIScreen.main.nativeScale
}
func updateDisplayScale() {
let scale = resolvedDisplayScale()
TerminalDebugLog.log(
.metrics,
"updateDisplayScale scale=\(String(format: "%.2f", scale))"
)
contentScaleFactor = scale
layer.contentsScale = scale
updateSublayerFrames()
}
func updateSublayerFrames() {
let scale = resolvedDisplayScale()
contentScaleFactor = scale
layer.contentsScale = scale
guard let sublayers = layer.sublayers else { return }
for sublayer in sublayers {
sublayer.frame = bounds
sublayer.contentsScale = scale
}
}
func enforceSublayerScale() {
let scale = resolvedDisplayScale()
guard let sublayers = layer.sublayers else { return }
for sublayer in sublayers {
if sublayer.contentsScale != scale {
sublayer.contentsScale = scale
}
if sublayer.frame != bounds {
sublayer.frame = bounds
}
}
}
public func fitToSize() {
core.fitToSize()
}
override open func traitCollectionDidChange(
_ previousTraitCollection: UITraitCollection?
) {
super.traitCollectionDidChange(previousTraitCollection)
updateDisplayScale()
if traitCollection.hasDifferentColorAppearance(
comparedTo: previousTraitCollection
) {
updateColorScheme()
}
}
func updateColorScheme() {
let style = traitCollection.userInterfaceStyle
let scheme: TerminalColorScheme = style == .dark ? .dark : .light
TerminalDebugLog.log(.lifecycle, "updateColorScheme scheme=\(scheme)")
surface?.setColorScheme(scheme.ghosttyValue)
if let controller,
let viewState = delegate as? TerminalViewState,
viewState.controller === controller
{
viewState.adopt(terminalColorScheme: scheme)
} else {
controller?.setColorScheme(scheme)
}
}
@discardableResult
override open func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
core.setFocus(true)
onFocusChange?(true)
return result
}
@discardableResult
override open func resignFirstResponder() -> Bool {
let result = super.resignFirstResponder()
core.setFocus(false)
onFocusChange?(false)
return result
}
}
#endif

View File

@@ -0,0 +1,79 @@
//
// UITerminalView+PinchZoom.swift
// libghostty-spm
//
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
import UIKit
extension UITerminalView {
private static let scaleStepThreshold: CGFloat = 0.1
func setupPinchZoomGesture() {
let pinch = UIPinchGestureRecognizer(
target: self,
action: #selector(handlePinchGesture(_:))
)
addGestureRecognizer(pinch)
}
@objc func handlePinchGesture(_ gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
lastPinchScale = gesture.scale
TerminalDebugLog.log(
.actions,
"pinch began scale=\(String(format: "%.3f", gesture.scale)) fontSize=\(currentFontSize)"
)
case .changed:
let delta = gesture.scale - lastPinchScale
let steps = Int(delta / Self.scaleStepThreshold)
guard steps != 0 else { return }
lastPinchScale += CGFloat(steps) * Self.scaleStepThreshold
TerminalDebugLog.log(
.actions,
"pinch changed scale=\(String(format: "%.3f", gesture.scale)) delta=\(String(format: "%.3f", delta)) steps=\(steps)"
)
var changed = false
if steps > 0 {
for _ in 0 ..< steps {
guard currentFontSize < Self.maxFontSize else { break }
surface?.performBindingAction("increase_font_size:1")
currentFontSize += 1
changed = true
}
} else {
for _ in 0 ..< abs(steps) {
guard currentFontSize > Self.minFontSize else { break }
surface?.performBindingAction("decrease_font_size:1")
currentFontSize -= 1
changed = true
}
}
if changed {
core.synchronizeMetrics()
refreshTextInputGeometry(reason: "pinch-zoom")
TerminalDebugLog.log(
.actions,
"pinch applied fontSize=\(currentFontSize)"
)
}
case .ended, .cancelled, .failed:
lastPinchScale = 1.0
TerminalDebugLog.log(
.actions,
"pinch ended state=\(gesture.state.rawValue) fontSize=\(currentFontSize)"
)
default:
break
}
}
}
#endif

View File

@@ -0,0 +1,34 @@
//
// UITerminalView+PublicInput.swift
// libghostty-spm
//
// Public wrappers around TerminalSurface input and navigation actions.
//
#if canImport(UIKit)
import UIKit
extension UITerminalView {
/// 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

View File

@@ -0,0 +1,107 @@
//
// UITerminalView+PublicSticky.swift
// libghostty-spm
//
// Public wrappers around the iOS sticky-modifier state machine so
// hosts that suppress `inputAccessoryView` (and supply their own chip
// pill UI) can still drive the same Ctrl/Alt/Cmd sticky path that the
// bundled `TerminalInputAccessoryView` uses.
//
// Without this surface a host with a custom keyboard accessory has to
// either:
// * reimplement the full sticky state machine + IME compose handshake
// in app code (fragile, has to mirror libghostty internals), OR
// * intercept the outbound surface byte stream and try to transform
// bytes after-the-fact (breaks because libghostty wraps every
// `surface.sendText` call in bracketed-paste markers when the
// remote shell enabled mode 2004).
//
// Forwarding to the existing internal `stickyModifiers` keeps a
// single source of truth the bundled accessory and the host's
// custom chip UI both end up calling the same `toggle(_:)` /
// `consumeForNextKey()` codepath that `insertText` already respects.
//
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
import Foundation
import UIKit
/// Public mirror of the internal `TerminalStickyModifierState.Modifier`
/// enum. Decoupled so the internal type stays free to evolve.
public enum TerminalPublicStickyModifier: String, Sendable {
case ctrl
case alt
case command
}
/// Public mirror of `TerminalStickyModifierState.Activation`.
public enum TerminalPublicStickyActivation: String, Sendable {
case inactive
case armed
case locked
}
@MainActor
extension UITerminalView {
/// Toggle the sticky activation for `modifier`. Tap-to-arm,
/// double-tap-to-lock semantics match the bundled accessory.
/// Safe to call regardless of whether `inputAccessoryView` is
/// currently shown only mutates the internal state machine,
/// which `insertText` consults on every keystroke.
public func toggleStickyModifier(_ modifier: TerminalPublicStickyModifier) {
stickyModifiers.toggle(internalModifier(for: modifier))
}
/// Read current activation for inspection / sync (e.g. host UI
/// reflecting state changes triggered by the bundled accessory).
public func stickyActivation(
for modifier: TerminalPublicStickyModifier
) -> TerminalPublicStickyActivation {
switch modifier {
case .ctrl: publicActivation(stickyModifiers.ctrl)
case .alt: publicActivation(stickyModifiers.alt)
case .command: publicActivation(stickyModifiers.command)
}
}
/// True iff any modifier is `.armed` or `.locked`.
public var hasActiveStickyModifiers: Bool {
stickyModifiers.hasActiveModifiers
}
/// Clear all sticky activation. No-op when nothing is active.
public func resetStickyModifiers() {
stickyModifiers.reset()
}
/// Subscribe to sticky-state changes. Called on every transition
/// (toggle / consume / reset). Replaces any prior closure pass
/// `nil` to detach. Useful for host UIs that mirror the activation
/// in their own chip pill.
public func setStickyModifierChangeHandler(_ handler: (() -> Void)?) {
stickyModifiers.onChange = handler
}
// MARK: - Internal mappers
private func internalModifier(
for modifier: TerminalPublicStickyModifier
) -> TerminalStickyModifierState.Modifier {
switch modifier {
case .ctrl: .ctrl
case .alt: .alt
case .command: .command
}
}
private func publicActivation(
_ activation: TerminalStickyModifierState.Activation
) -> TerminalPublicStickyActivation {
switch activation {
case .inactive: .inactive
case .armed: .armed
case .locked: .locked
}
}
}
#endif

View File

@@ -0,0 +1,449 @@
//
// UITerminalView+UITextInput.swift
// libghostty-spm
//
#if canImport(UIKit)
import GhosttyKit
import UIKit
extension UITerminalView: UITextInput, UITextInputTraits {
// MARK: - UITextInputTraits
open var autocorrectionType: UITextAutocorrectionType {
get { .no }
set {}
}
open var autocapitalizationType: UITextAutocapitalizationType {
get { .none }
set {}
}
open var smartQuotesType: UITextSmartQuotesType {
get { .no }
set {}
}
open var smartDashesType: UITextSmartDashesType {
get { .no }
set {}
}
open var smartInsertDeleteType: UITextSmartInsertDeleteType {
get { .no }
set {}
}
open var spellCheckingType: UITextSpellCheckingType {
get { .no }
set {}
}
open var keyboardType: UIKeyboardType {
get { .default }
set {}
}
// MARK: - UIKeyInput
open func insertText(_ text: String) {
guard !hardwareKeyHandled else {
TerminalDebugLog.log(
.input,
"insertText suppressed text=\(TerminalDebugLog.describe(text))"
)
hardwareKeyHandled = false
return
}
#if !targetEnvironment(macCatalyst)
if inputHandler.hasMarkedText {
inputHandler.insertText(text)
return
}
if stickyModifiers.hasActiveModifiers {
_ = handleStickyTextInput(text)
return
}
#endif
inputHandler.insertText(text)
}
open func deleteBackward() {
if inputHandler.deleteBackwardInMarkedText() {
TerminalDebugLog.log(.input, "deleteBackward handled by marked text")
hardwareKeyHandled = false
return
}
guard !hardwareKeyHandled else {
TerminalDebugLog.log(.input, "deleteBackward suppressed")
hardwareKeyHandled = false
return
}
let usage = UInt16(UIKeyboardHIDUsage.keyboardDeleteOrBackspace.rawValue)
#if !targetEnvironment(macCatalyst)
if stickyModifiers.hasActiveModifiers {
let mods = stickyModifiers.consumeForNextKey()
sendSyntheticKey(usage: usage, additionalMods: mods)
return
}
#endif
let delivery = TerminalHardwareKeyRouter.routeUIKit(
usage: usage,
backend: configuration.backend
)
if case let .data(sequence) = delivery,
case let .inMemory(session) = configuration.backend
{
session.sendInput(sequence)
return
}
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.mods = ghostty_input_mods_e(rawValue: 0)
keyEvent.keycode = TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(
usage: usage
)
keyEvent.composing = false
let delete = "\u{7F}"
delete.withCString { ptr in
keyEvent.text = ptr
surface?.sendKeyEvent(keyEvent)
}
}
// MARK: - UITextInput Marked Text
open func setMarkedText(
_ markedText: String?,
selectedRange: NSRange
) {
inputHandler.setMarkedText(markedText, selectedRange: selectedRange)
}
open func unmarkText() {
inputHandler.unmarkText(applyingStickyModifiers: false)
}
open var markedTextRange: UITextRange? {
inputHandler.markedTextRange()
}
open var markedTextStyle: [NSAttributedString.Key: Any]? {
get { nil }
set {}
}
// MARK: - UITextInput Selection
open var selectedTextRange: UITextRange? {
get { inputHandler.selectedTextRange() }
set { inputHandler.setSelectedTextRange(newValue) }
}
// MARK: - UITextInput Positions
open var beginningOfDocument: UITextPosition {
TerminalTextPosition(0)
}
open var endOfDocument: UITextPosition {
TerminalTextPosition(inputHandler.documentLength)
}
open func textRange(
from fromPosition: UITextPosition,
to toPosition: UITextPosition
) -> UITextRange? {
guard
let from = fromPosition as? TerminalTextPosition,
let to = toPosition as? TerminalTextPosition
else { return nil }
return TerminalTextRange(start: from, end: to)
}
open func position(
from position: UITextPosition,
offset: Int
) -> UITextPosition? {
guard let pos = position as? TerminalTextPosition else { return nil }
let newIndex = pos.index + offset
guard newIndex >= 0, newIndex <= inputHandler.documentLength else { return nil }
return TerminalTextPosition(newIndex)
}
open func position(
from position: UITextPosition,
in _: UITextLayoutDirection,
offset: Int
) -> UITextPosition? {
self.position(from: position, offset: offset)
}
open func compare(
_ position: UITextPosition,
to other: UITextPosition
) -> ComparisonResult {
guard
let lhs = position as? TerminalTextPosition,
let rhs = other as? TerminalTextPosition
else { return .orderedSame }
if lhs.index < rhs.index { return .orderedAscending }
if lhs.index > rhs.index { return .orderedDescending }
return .orderedSame
}
open func offset(
from: UITextPosition,
to toPosition: UITextPosition
) -> Int {
guard
let f = from as? TerminalTextPosition,
let t = toPosition as? TerminalTextPosition
else { return 0 }
return t.index - f.index
}
// MARK: - UITextInput Text
open func text(in range: UITextRange) -> String? {
guard let range = range as? TerminalTextRange else { return nil }
return inputHandler.text(in: range)
}
open func replace(_: UITextRange, withText text: String) {
#if !targetEnvironment(macCatalyst)
if inputHandler.hasMarkedText {
inputHandler.insertText(text)
return
}
if stickyModifiers.hasActiveModifiers {
_ = handleStickyTextInput(text)
return
}
#endif
inputHandler.insertText(text)
}
// MARK: - UITextInput Delegate
open var inputDelegate: (any UITextInputDelegate)? {
get { _inputDelegate }
set { _inputDelegate = newValue }
}
// MARK: - UITextInput Tokenizer
open var tokenizer: any UITextInputTokenizer {
UITextInputStringTokenizer(textInput: self)
}
open var textInputView: UIView {
self
}
// MARK: - UITextInput Geometry
open func firstRect(for range: UITextRange) -> CGRect {
if let range = range as? TerminalTextRange {
return rectForRange(range)
}
return markedTextRect()
}
open func caretRect(for position: UITextPosition) -> CGRect {
caretRectForPosition(position)
}
open func selectionRects(
for _: UITextRange
) -> [UITextSelectionRect] {
[]
}
open func closestPosition(to point: CGPoint) -> UITextPosition? {
TerminalTextPosition(textIndex(for: point))
}
open func closestPosition(
to point: CGPoint,
within _: UITextRange
) -> UITextPosition? {
closestPosition(to: point)
}
open func characterRange(at point: CGPoint) -> UITextRange? {
let index = textIndex(for: point)
return TerminalTextRange(location: index, length: 0)
}
open func position(
within range: UITextRange,
farthestIn direction: UITextLayoutDirection
) -> UITextPosition? {
switch direction {
case .left, .up: return range.start
case .right, .down: return range.end
@unknown default: return range.start
}
}
open func characterRange(
byExtending position: UITextPosition,
in _: UITextLayoutDirection
) -> UITextRange? {
guard inputHandler.documentLength > 0,
let position = position as? TerminalTextPosition
else {
return TerminalTextRange(location: 0, length: 0)
}
let location = min(max(position.index, 0), inputHandler.documentLength - 1)
return TerminalTextRange(location: location, length: 1)
}
open func baseWritingDirection(
for _: UITextPosition,
in _: UITextStorageDirection
) -> NSWritingDirection {
.leftToRight
}
open func setBaseWritingDirection(
_: NSWritingDirection,
for _: UITextRange
) {}
private func imeRect() -> CGRect {
guard let surface else { return .zero }
let point = surface.imePoint()
return CGRect(
x: point.x,
y: point.y,
width: point.width,
height: point.height
)
}
private func markedTextRect() -> CGRect {
let baseRect = imeRect()
guard
inputHandler.documentLength > 0,
let range = markedTextRange as? TerminalTextRange
else {
return baseRect
}
return rect(for: range, in: baseRect, fallbackWidth: baseRect.width)
}
private func rectForRange(_ range: TerminalTextRange) -> CGRect {
rect(for: range, in: imeRect(), fallbackWidth: 2)
}
private func caretRectForPosition(_ position: UITextPosition) -> CGRect {
let baseRect = imeRect()
let cellWidth = compositionCellWidth(in: baseRect)
guard inputHandler.documentLength > 0 else {
let rect = CGRect(
x: baseRect.minX,
y: baseRect.minY,
width: cellWidth,
height: baseRect.height
)
TerminalDebugLog.log(
.ime,
"caretRect empty position base=\(NSCoder.string(for: baseRect)) rect=\(NSCoder.string(for: rect))"
)
return rect
}
guard let position = position as? TerminalTextPosition else {
let rect = CGRect(
x: baseRect.maxX,
y: baseRect.minY,
width: cellWidth,
height: baseRect.height
)
TerminalDebugLog.log(
.ime,
"caretRect fallback base=\(NSCoder.string(for: baseRect)) rect=\(NSCoder.string(for: rect))"
)
return rect
}
let clampedIndex = min(max(position.index, 0), inputHandler.documentLength)
let x = baseRect.minX + CGFloat(clampedIndex) * cellWidth
let rect = CGRect(
x: x,
y: baseRect.minY,
width: cellWidth,
height: baseRect.height
)
TerminalDebugLog.log(
.ime,
"caretRect index=\(clampedIndex) base=\(NSCoder.string(for: baseRect)) cellWidth=\(String(format: "%.2f", cellWidth)) rect=\(NSCoder.string(for: rect))"
)
return rect
}
private func rect(
for range: TerminalTextRange,
in baseRect: CGRect,
fallbackWidth: CGFloat
) -> CGRect {
let documentLength = max(inputHandler.documentLength, 1)
let cellWidth = compositionCellWidth(in: baseRect)
let location = min(max(range.location, 0), documentLength)
let length = max(range.length, 0)
let x = baseRect.minX + CGFloat(location) * cellWidth
let width = max(CGFloat(length) * cellWidth, fallbackWidth)
return CGRect(
x: x,
y: baseRect.minY,
width: width,
height: baseRect.height
)
}
private func compositionCellWidth(in baseRect: CGRect) -> CGFloat {
if baseRect.width > 0 {
return max(baseRect.width, 2)
}
guard let size = surface?.size() else { return 2 }
let scale = resolvedDisplayScale()
guard scale > 0 else { return CGFloat(max(size.cellWidthPixels, 2)) }
return max(CGFloat(size.cellWidthPixels) / scale, 2)
}
private func textIndex(for point: CGPoint) -> Int {
let baseRect = imeRect()
guard inputHandler.documentLength > 0 else { return 0 }
let cellWidth = compositionCellWidth(in: baseRect)
guard cellWidth > 0 else { return 0 }
let relativeX = point.x - baseRect.minX
let rawIndex = Int((relativeX / cellWidth).rounded(.down))
let index = min(max(rawIndex, 0), inputHandler.documentLength)
TerminalDebugLog.log(
.ime,
"textIndex point=\(NSCoder.string(for: point)) base=\(NSCoder.string(for: baseRect)) cellWidth=\(String(format: "%.2f", cellWidth)) index=\(index)"
)
return index
}
}
#endif

View File

@@ -0,0 +1,285 @@
//
// UITerminalView.swift
// libghostty-spm
//
// Created by Lakr233 on 2026/3/16.
//
#if canImport(UIKit)
import GhosttyKit
import UIKit
@MainActor
open class UITerminalView: UIView {
let core = TerminalSurfaceCoordinator()
var momentumDisplayLink: CADisplayLink?
var momentumVelocity: CGPoint = .zero
#if !targetEnvironment(macCatalyst)
static let minFontSize: Float = 4
static let maxFontSize: Float = 64
#endif
var activePointerButton: ghostty_input_mouse_button_e?
var pointerSelectionStartPoint: CGPoint?
var lastPointerSelectionRect: CGRect?
var pendingSelectionMenuPoint: CGPoint?
#if !targetEnvironment(macCatalyst)
var indirectPointerPanOwnsTouchSequence = false
var suppressNextIndirectPointerTouchEnd = false
#endif
lazy var selectionContextMenuInteraction = UIContextMenuInteraction(delegate: self)
var hardwareKeyHandled = false
let touchScrollMultiplier: CGFloat = 3.0
#if !targetEnvironment(macCatalyst)
var currentFontSize: Float = 14
var lastPinchScale: CGFloat = 1.0
#endif
lazy var inputHandler = TerminalTextInputHandler(view: self)
weak var _inputDelegate: (any UITextInputDelegate)?
var onFocusChange: ((Bool) -> Void)?
#if !targetEnvironment(macCatalyst)
lazy var terminalInputAccessory = TerminalInputAccessoryView(terminalView: self)
let stickyModifiers = TerminalStickyModifierState()
var softwareKeyboardVisible = false
var pendingKeyboardDismissOnTouchEnd = false
var touchDidScrollDuringCurrentTouch = false
#endif
#if !targetEnvironment(macCatalyst)
open var inputAccessoryStyle: TerminalInputAccessoryStyle {
get { terminalInputAccessory.style }
set { terminalInputAccessory.style = newValue }
}
open var inputAccessoryItems: [TerminalInputAccessoryItem] = TerminalInputAccessoryItem.defaultItems {
didSet {
terminalInputAccessory.rebuildContent()
reloadInputViews()
}
}
#endif
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 }
}
var surface: TerminalSurface? {
core.surface
}
open var hasText: Bool {
true
}
override open var canBecomeFirstResponder: Bool {
true
}
override public init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
@available(*, unavailable)
public required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func commonInit() {
backgroundColor = .clear
isOpaque = false
isUserInteractionEnabled = true
updateDisplayScale()
core.isAttached = { [weak self] in self?.window != nil }
core.scaleFactor = { [weak self] in
Double(self?.resolvedDisplayScale() ?? UIScreen.main.nativeScale)
}
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_IOS
config.platform = ghostty_platform_u(
ios: ghostty_platform_ios_s(
uiview: Unmanaged.passUnretained(self).toOpaque()
)
)
}
core.onMetricsUpdate = { [weak self] in
self?.updateSublayerFrames()
}
core.onCellSizeDidChange = { [weak self] in
self?.refreshTextInputGeometry(reason: "cell-size-action")
}
core.onPostRender = { [weak self] in
self?.enforceSublayerScale()
}
setupApplicationLifecycleObservers()
syncApplicationActiveState()
setupPlatformInput()
#if !targetEnvironment(macCatalyst)
setupKeyboardObservers()
#endif
}
open func selectionMenuPoint(at point: CGPoint) -> CGPoint? {
logPointerSelectionDiagnostics(
context: "selectionMenuPoint",
point: point
)
if let rect = lastPointerSelectionRect {
let pointIsInsidePointerSelection = rect.insetBy(dx: -4, dy: -4).contains(point)
guard pointIsInsidePointerSelection else {
TerminalDebugLog.log(
.input,
"selection menu miss point=\(NSCoder.string(for: point)) outside pointer selection"
)
return nil
}
guard surface?.hasSelection() == true else {
TerminalDebugLog.log(
.input,
"selection menu miss point=\(NSCoder.string(for: point)) inside pointer selection without active selection"
)
return nil
}
TerminalDebugLog.log(
.input,
"selection menu hit point=\(NSCoder.string(for: point)) inside pointer selection"
)
return point
}
guard surface?.hasSelection() == true else {
TerminalDebugLog.log(
.input,
"selection menu miss point=\(NSCoder.string(for: point))"
)
return nil
}
guard surface?.selectionContainsQuicklookWord() == true else {
TerminalDebugLog.log(
.input,
"selection menu miss point=\(NSCoder.string(for: point)) outside quicklook word"
)
return nil
}
TerminalDebugLog.log(
.input,
"selection menu hit point=\(NSCoder.string(for: point))"
)
return point
}
open func showSelectionCopyMenu(at point: CGPoint) {
becomeFirstResponder()
let menu = UIMenuController.shared
menu.menuItems = nil
menu.showMenu(
from: self,
rect: CGRect(x: point.x, y: point.y, width: 1, height: 1)
)
menu.update()
}
@discardableResult
open func copySelectedTextToPasteboard() -> Bool {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing") {
accessibilityValue = nil
}
#endif
guard let text = surface?.readSelection(), !text.isEmpty else {
return false
}
UIPasteboard.general.string = text
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--ui-testing") {
accessibilityValue = text
}
#endif
TerminalDebugLog.log(
.input,
"selection copied bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text))"
)
return true
}
open func selectionContextMenuConfiguration(
at _: CGPoint
) -> UIContextMenuConfiguration {
UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
UIMenu(children: self?.selectionContextMenuElements() ?? [])
}
}
open func selectionContextMenuElements() -> [UIMenuElement] {
let copy = UIAction(
title: "Copy",
image: UIImage(systemName: "doc.on.doc")
) { [weak self] _ in
self?.copySelectedTextToPasteboard()
}
return [copy]
}
deinit {
NotificationCenter.default.removeObserver(self)
}
#if !targetEnvironment(macCatalyst)
func setupKeyboardObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardDidShow),
name: UIResponder.keyboardDidShowNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardDidHide),
name: UIResponder.keyboardDidHideNotification,
object: nil
)
}
@objc func keyboardDidShow(_: Notification) {
guard isFirstResponder else { return }
softwareKeyboardVisible = true
}
@objc func keyboardDidHide(_: Notification) {
softwareKeyboardVisible = false
}
#endif
func refreshTextInputGeometry(reason: String) {
guard isFirstResponder || inputHandler.hasMarkedText else { return }
TerminalDebugLog.log(.ime, "refresh text geometry reason=\(reason)")
inputHandler.notifyGeometryDidChange(reason: reason)
}
func refreshInputAccessoryContent() {
#if !targetEnvironment(macCatalyst)
terminalInputAccessory.refreshContent()
#endif
}
}
#endif