初始提交: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,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