- 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>
92 lines
3.6 KiB
Swift
92 lines
3.6 KiB
Swift
//
|
|
// TerminalSelectionAnchor.swift
|
|
// libghostty-spm
|
|
//
|
|
|
|
import Foundation
|
|
|
|
enum TerminalSelectionAnchor {
|
|
/// Map a quicklook word + its top-left host-point coordinate back into
|
|
/// an `NSRange` inside the viewport text snapshot, suitable for direct
|
|
/// assignment to `UITextView.selectedRange`.
|
|
///
|
|
/// Strategy: derive `row` from `pointY / cellHeightPoints`; collect every
|
|
/// literal occurrence of `word` in that row; then use
|
|
/// `pointX / cellWidthPoints` as the expected UTF-16 column and pick the
|
|
/// match whose `location` is closest. This resolves substring ambiguity
|
|
/// (e.g. `catalog cat` long-pressed at the end picks the standalone
|
|
/// `cat`, not the prefix of `catalog`) without depending on word
|
|
/// boundaries — which would fail for tokens like `/foo` whose first
|
|
/// character is a non-word character.
|
|
///
|
|
/// Units: `pointX/Y` and `cellWidth/HeightPoints` must all be host
|
|
/// points (not surface pixels). Callers are responsible for converting
|
|
/// `cellPixels / displayScale → points` before invoking. Ghostty's
|
|
/// embedded API returns `tl_px_x/y` in host points, so passing them
|
|
/// through unchanged is correct.
|
|
///
|
|
/// Known limitation: when the target row contains CJK full-width
|
|
/// characters before the match, cell columns and UTF-16 offsets diverge
|
|
/// (CJK = 2 cells, 1 UTF-16 unit), so disambiguation between duplicates
|
|
/// may pick the wrong occurrence. ASCII-only scenarios are exact.
|
|
static func resolveRange(
|
|
in text: String,
|
|
word: String,
|
|
pointX: Double,
|
|
pointY: Double,
|
|
cellWidthPoints: Double,
|
|
cellHeightPoints: Double
|
|
) -> NSRange? {
|
|
guard !word.isEmpty else { return nil }
|
|
guard pointX.isFinite, pointY.isFinite,
|
|
cellWidthPoints.isFinite, cellHeightPoints.isFinite
|
|
else { return nil }
|
|
guard cellWidthPoints > 0, cellHeightPoints > 0 else { return nil }
|
|
guard pointX >= 0, pointY >= 0 else { return nil }
|
|
|
|
let rowDouble = pointY / cellHeightPoints
|
|
let columnDouble = pointX / cellWidthPoints
|
|
guard rowDouble.isFinite, columnDouble.isFinite,
|
|
rowDouble < Double(Int.max), columnDouble < Double(Int.max)
|
|
else { return nil }
|
|
|
|
let row = Int(rowDouble)
|
|
let expectedColumnUTF16 = Int(columnDouble)
|
|
|
|
let nsText = text as NSString
|
|
let lines = nsText.components(separatedBy: "\n")
|
|
guard row >= 0, row < lines.count else { return nil }
|
|
|
|
let line = lines[row] as NSString
|
|
let wordNS = word as NSString
|
|
|
|
var matches: [NSRange] = []
|
|
var searchLocation = 0
|
|
while searchLocation < line.length {
|
|
let searchRange = NSRange(
|
|
location: searchLocation,
|
|
length: line.length - searchLocation
|
|
)
|
|
let hit = line.range(of: word, options: .literal, range: searchRange)
|
|
if hit.location == NSNotFound { break }
|
|
matches.append(hit)
|
|
searchLocation = NSMaxRange(hit)
|
|
if wordNS.length == 0 { break }
|
|
}
|
|
guard !matches.isEmpty else { return nil }
|
|
|
|
let chosen = matches.min { lhs, rhs in
|
|
abs(lhs.location - expectedColumnUTF16) < abs(rhs.location - expectedColumnUTF16)
|
|
}!
|
|
|
|
var offset = 0
|
|
for i in 0 ..< row {
|
|
offset += (lines[i] as NSString).length + 1 // +1 for "\n"
|
|
}
|
|
|
|
let result = NSRange(location: offset + chosen.location, length: chosen.length)
|
|
guard NSMaxRange(result) <= nsText.length else { return nil }
|
|
return result
|
|
}
|
|
}
|