初始提交:terminalX 可运行态(M0/M1/M1.5 已真机验证)

- M0: libghostty SSH 终端(渲染/输入/连接)+ 白屏修复(OutputGate) + 会话状态机/自动重连
- M1: tsnet 用户态组网 + SSH-over-tsnet(fd 桥),shell 级真机验证;R5(Go+gvisor+C+++Swift 同进程) retire
- M1.5: tmux -CC 原生 tab(MVP)
- 结构: packages/(TXCore·TXTransport), apps/TerminalX, vendor/(libghostty-spm/libssh2/mbedtls/tsnet-bridge), artifacts/
- 文档: CLAUDE.md + docs/HANDOFF.md(新会话入口)
- 环境: 认证代理→依赖 vendor 本地化;Go 在 ~/.local/go;仅模拟器/未签名
- 待续: M2 mosh, tmux 多 pane, M4 安全(host key/SE)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kid
2026-07-24 10:20:46 +08:00
commit aa92d0e676
2761 changed files with 803505 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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