初始提交: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:
29
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputAccessoryStyle.swift
vendored
Normal file
29
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputAccessoryStyle.swift
vendored
Normal 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
|
||||
400
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputAccessoryView.swift
vendored
Normal file
400
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputAccessoryView.swift
vendored
Normal 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
|
||||
55
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputBarKey.swift
vendored
Normal file
55
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputBarKey.swift
vendored
Normal 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
|
||||
81
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalStickyModifierState.swift
vendored
Normal file
81
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalStickyModifierState.swift
vendored
Normal 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
|
||||
254
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalTextInputHandler@UIKit.swift
vendored
Normal file
254
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalTextInputHandler@UIKit.swift
vendored
Normal 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
|
||||
63
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalTextPosition.swift
vendored
Normal file
63
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalTextPosition.swift
vendored
Normal 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
|
||||
313
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+InputAccessory.swift
vendored
Normal file
313
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+InputAccessory.swift
vendored
Normal 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
|
||||
652
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Interaction.swift
vendored
Normal file
652
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Interaction.swift
vendored
Normal 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
|
||||
246
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Keyboard.swift
vendored
Normal file
246
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Keyboard.swift
vendored
Normal 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
|
||||
172
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Lifecycle.swift
vendored
Normal file
172
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Lifecycle.swift
vendored
Normal 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
|
||||
79
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PinchZoom.swift
vendored
Normal file
79
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PinchZoom.swift
vendored
Normal 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
|
||||
34
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PublicInput.swift
vendored
Normal file
34
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PublicInput.swift
vendored
Normal 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
|
||||
107
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PublicSticky.swift
vendored
Normal file
107
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PublicSticky.swift
vendored
Normal 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
|
||||
449
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+UITextInput.swift
vendored
Normal file
449
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+UITextInput.swift
vendored
Normal 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
|
||||
285
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView.swift
vendored
Normal file
285
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView.swift
vendored
Normal 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
|
||||
Reference in New Issue
Block a user