- 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>
255 lines
8.8 KiB
Swift
255 lines
8.8 KiB
Swift
//
|
|
// 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
|