初始提交: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:
91
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSelectionAnchor.swift
vendored
Normal file
91
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSelectionAnchor.swift
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
416
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurface.swift
vendored
Normal file
416
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurface.swift
vendored
Normal file
@@ -0,0 +1,416 @@
|
||||
//
|
||||
// TerminalSurface.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
/// Thread-safe wrapper around `ghostty_surface_t`.
|
||||
///
|
||||
/// All access must happen on the main actor. The surface should be freed
|
||||
/// explicitly via ``free()`` before the wrapper is deallocated; `deinit`
|
||||
/// includes a safety net but relying on it is discouraged.
|
||||
@MainActor
|
||||
public final class TerminalSurface {
|
||||
private var surface: ghostty_surface_t?
|
||||
private var hasBeenFreed = false
|
||||
|
||||
init(_ surface: ghostty_surface_t) {
|
||||
self.surface = surface
|
||||
}
|
||||
|
||||
var rawValue: ghostty_surface_t? {
|
||||
surface
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
@discardableResult
|
||||
func sendKeyEvent(_ event: ghostty_input_key_s) -> Bool {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.input, "surface key ignored: missing surface")
|
||||
return false
|
||||
}
|
||||
let result = ghostty_surface_key(s, event)
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"surface key action=\(TerminalDebugLog.describe(event.action)) keycode=\(event.keycode) mods=0x\(String(event.mods.rawValue, radix: 16)) consumed=0x\(String(event.consumed_mods.rawValue, radix: 16)) text=\(terminalKeyText(event)) composing=\(event.composing) result=\(result)"
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func sendText(_ text: String) -> Bool {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.input, "surface text ignored: missing surface")
|
||||
return false
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"surface text=\(TerminalDebugLog.describe(text))"
|
||||
)
|
||||
text.withCString { cStr in
|
||||
ghostty_surface_text(s, cStr, UInt(text.utf8.count))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func sendMouseButton(
|
||||
state: ghostty_input_mouse_state_e,
|
||||
button: ghostty_input_mouse_button_e,
|
||||
mods: ghostty_input_mods_e
|
||||
) -> Bool {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.input, "surface mouse button ignored: missing surface")
|
||||
return false
|
||||
}
|
||||
let result = ghostty_surface_mouse_button(s, state, button, mods)
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"surface mouseButton state=\(TerminalDebugLog.describe(state)) button=\(button.rawValue) mods=0x\(String(mods.rawValue, radix: 16)) result=\(result)"
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
func sendMousePos(x: Double, y: Double, mods: ghostty_input_mods_e) {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.input, "surface mouse position ignored: missing surface")
|
||||
return
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"surface mousePos x=\(String(format: "%.2f", x)) y=\(String(format: "%.2f", y)) mods=0x\(String(mods.rawValue, radix: 16))"
|
||||
)
|
||||
ghostty_surface_mouse_pos(s, x, y, mods)
|
||||
}
|
||||
|
||||
func sendMouseScroll(x: Double, y: Double, mods: ghostty_input_scroll_mods_t) {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.input, "surface scroll ignored: missing surface")
|
||||
return
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"surface scroll x=\(String(format: "%.2f", x)) y=\(String(format: "%.2f", y)) mods=0x\(String(mods, radix: 16))"
|
||||
)
|
||||
ghostty_surface_mouse_scroll(s, x, y, mods)
|
||||
}
|
||||
|
||||
func preedit(_ text: String) {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.ime, "surface preedit ignored: missing surface")
|
||||
return
|
||||
}
|
||||
TerminalDebugLog.log(.ime, "surface preedit=\(TerminalDebugLog.describe(text))")
|
||||
text.withCString { cStr in
|
||||
ghostty_surface_preedit(s, cStr, UInt(text.utf8.count))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
/// Invoke a named Ghostty binding action.
|
||||
///
|
||||
/// Action names use the same syntax as Ghostty's `keybind` configuration,
|
||||
/// such as `copy_to_clipboard` or `scroll_page_lines:-3`.
|
||||
@discardableResult
|
||||
public func performBindingAction(_ action: String) -> Bool {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.actions, "binding action ignored: missing surface")
|
||||
return false
|
||||
}
|
||||
let result = action.withCString { cStr in
|
||||
ghostty_surface_binding_action(s, cStr, UInt(action.utf8.count))
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"binding action=\(TerminalDebugLog.describe(action)) result=\(result)"
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
/// Jump the viewport by a number of shell prompts.
|
||||
///
|
||||
/// Negative offsets move toward older prompts and positive offsets move
|
||||
/// toward newer prompts. This requires prompt markers from Ghostty shell
|
||||
/// integration, or equivalent OSC 133 markers from a host-managed backend.
|
||||
@discardableResult
|
||||
public func jumpToPrompt(by offset: Int16) -> Bool {
|
||||
performBindingAction("jump_to_prompt:\(offset)")
|
||||
}
|
||||
|
||||
/// Reveal an absolute scrollback row, where zero is the first row.
|
||||
@discardableResult
|
||||
public func scrollToRow(_ row: UInt) -> Bool {
|
||||
performBindingAction("scroll_to_row:\(row)")
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
func draw() {
|
||||
guard let s = surface else { return }
|
||||
TerminalDebugLog.log(.render, "surface draw")
|
||||
ghostty_surface_draw(s)
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
guard let s = surface else { return }
|
||||
TerminalDebugLog.log(.render, "surface refresh")
|
||||
ghostty_surface_refresh(s)
|
||||
}
|
||||
|
||||
func setSize(width: UInt32, height: UInt32) {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.metrics, "surface setSize ignored: missing surface")
|
||||
return
|
||||
}
|
||||
TerminalDebugLog.log(.metrics, "surface setSize \(width)x\(height)")
|
||||
ghostty_surface_set_size(s, width, height)
|
||||
}
|
||||
|
||||
func setContentScale(x: Double, y: Double) {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.metrics, "surface contentScale ignored: missing surface")
|
||||
return
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"surface contentScale x=\(String(format: "%.2f", x)) y=\(String(format: "%.2f", y))"
|
||||
)
|
||||
ghostty_surface_set_content_scale(s, x, y)
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
func setFocus(_ focused: Bool) {
|
||||
guard let s = surface else { return }
|
||||
TerminalDebugLog.log(.lifecycle, "surface focus=\(focused)")
|
||||
ghostty_surface_set_focus(s, focused)
|
||||
}
|
||||
|
||||
func setColorScheme(_ scheme: ghostty_color_scheme_e) {
|
||||
guard let s = surface else { return }
|
||||
TerminalDebugLog.log(.lifecycle, "surface colorScheme=\(scheme.rawValue)")
|
||||
ghostty_surface_set_color_scheme(s, scheme)
|
||||
}
|
||||
|
||||
func setOcclusion(_ visible: Bool) {
|
||||
guard let s = surface else { return }
|
||||
TerminalDebugLog.log(.lifecycle, "surface occlusion visible=\(visible)")
|
||||
ghostty_surface_set_occlusion(s, visible)
|
||||
}
|
||||
|
||||
// MARK: - Size Query
|
||||
|
||||
func size() -> TerminalGridMetrics? {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.metrics, "surface size query ignored: missing surface")
|
||||
return nil
|
||||
}
|
||||
let metrics = TerminalGridMetrics(ghostty_surface_size(s))
|
||||
TerminalDebugLog.log(.metrics, "surface size \(metrics.debugSummary)")
|
||||
return metrics
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
struct SelectionResult {
|
||||
let text: String
|
||||
let offsetStart: UInt32
|
||||
let offsetLength: UInt32
|
||||
}
|
||||
|
||||
func hasSelection() -> Bool {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.input, "surface selection query ignored: missing surface")
|
||||
return false
|
||||
}
|
||||
let result = ghostty_surface_has_selection(s)
|
||||
TerminalDebugLog.log(.input, "surface hasSelection=\(result)")
|
||||
return result
|
||||
}
|
||||
|
||||
func readSelection() -> String? {
|
||||
readSelectionResult()?.text
|
||||
}
|
||||
|
||||
func readSelectionResult() -> SelectionResult? {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.input, "surface readSelection ignored: missing surface")
|
||||
return nil
|
||||
}
|
||||
var out = ghostty_text_s()
|
||||
guard ghostty_surface_read_selection(s, &out) else {
|
||||
TerminalDebugLog.log(.input, "surface readSelection returned false")
|
||||
return nil
|
||||
}
|
||||
defer { ghostty_surface_free_text(s, &out) }
|
||||
|
||||
guard let textPtr = out.text, out.text_len > 0 else {
|
||||
TerminalDebugLog.log(.input, "surface readSelection empty")
|
||||
return SelectionResult(
|
||||
text: "",
|
||||
offsetStart: out.offset_start,
|
||||
offsetLength: out.offset_len
|
||||
)
|
||||
}
|
||||
|
||||
let bytes = UnsafeBufferPointer(start: textPtr, count: Int(out.text_len))
|
||||
.map { UInt8(bitPattern: $0) }
|
||||
let text = String(decoding: bytes, as: UTF8.self)
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"surface readSelection bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text)) offset=\(out.offset_start)+\(out.offset_len)"
|
||||
)
|
||||
return SelectionResult(
|
||||
text: text,
|
||||
offsetStart: out.offset_start,
|
||||
offsetLength: out.offset_len
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - IME
|
||||
|
||||
func imePoint() -> (x: Double, y: Double, width: Double, height: Double) {
|
||||
var x: Double = 0
|
||||
var y: Double = 0
|
||||
var w: Double = 0
|
||||
var h: Double = 0
|
||||
if let s = surface {
|
||||
ghostty_surface_ime_point(s, &x, &y, &w, &h)
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"surface imePoint x=\(String(format: "%.2f", x)) y=\(String(format: "%.2f", y)) width=\(String(format: "%.2f", w)) height=\(String(format: "%.2f", h))"
|
||||
)
|
||||
return (x, y, w, h)
|
||||
}
|
||||
|
||||
// MARK: - Mouse Capture
|
||||
|
||||
var isMouseCaptured: Bool {
|
||||
guard let s = surface else { return false }
|
||||
return ghostty_surface_mouse_captured(s)
|
||||
}
|
||||
|
||||
// MARK: - Quicklook Word (Apple-only)
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
struct QuicklookWordResult {
|
||||
let word: String
|
||||
let offsetStart: UInt32
|
||||
let offsetLength: UInt32
|
||||
// tl_px_x / tl_px_y are reported in host points (view coordinates),
|
||||
// not surface pixels. Ghostty's embedded API receives mouse_pos in
|
||||
// points and stores the cursor position * contentScale internally,
|
||||
// then divides by contentScale when reporting selection coordinates
|
||||
// back. Callers must convert cell pixel dimensions to points before
|
||||
// dividing.
|
||||
let pointX: Double
|
||||
let pointY: Double
|
||||
}
|
||||
|
||||
func quicklookWord() -> QuicklookWordResult? {
|
||||
guard let s = surface else {
|
||||
TerminalDebugLog.log(.input, "surface quicklookWord ignored: missing surface")
|
||||
return nil
|
||||
}
|
||||
var out = ghostty_text_s()
|
||||
guard ghostty_surface_quicklook_word(s, &out) else {
|
||||
TerminalDebugLog.log(.input, "surface quicklookWord returned false")
|
||||
return nil
|
||||
}
|
||||
defer { ghostty_surface_free_text(s, &out) }
|
||||
|
||||
let word: String
|
||||
if let textPtr = out.text, out.text_len > 0 {
|
||||
let bytes = UnsafeBufferPointer(start: textPtr, count: Int(out.text_len))
|
||||
.map { UInt8(bitPattern: $0) }
|
||||
word = String(decoding: bytes, as: UTF8.self)
|
||||
} else {
|
||||
word = ""
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"surface quicklookWord word=\(TerminalDebugLog.describe(word)) offset=\(out.offset_start)+\(out.offset_len) pointX=\(String(format: "%.2f", out.tl_px_x)) pointY=\(String(format: "%.2f", out.tl_px_y))"
|
||||
)
|
||||
return QuicklookWordResult(
|
||||
word: word,
|
||||
offsetStart: out.offset_start,
|
||||
offsetLength: out.offset_len,
|
||||
pointX: out.tl_px_x,
|
||||
pointY: out.tl_px_y
|
||||
)
|
||||
}
|
||||
|
||||
func selectionContainsQuicklookWord() -> Bool {
|
||||
guard let selected = readSelectionResult(),
|
||||
let word = quicklookWord(),
|
||||
!word.word.isEmpty,
|
||||
word.offsetLength > 0
|
||||
else { return false }
|
||||
|
||||
let selectionStart = UInt64(selected.offsetStart)
|
||||
let selectionEnd = selectionStart + UInt64(selected.offsetLength)
|
||||
let wordStart = UInt64(word.offsetStart)
|
||||
let wordEnd = wordStart + UInt64(word.offsetLength)
|
||||
let contains = wordStart >= selectionStart && wordEnd <= selectionEnd
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"surface selectionContainsQuicklookWord=\(contains) selection=\(selected.offsetStart)+\(selected.offsetLength) word=\(word.offsetStart)+\(word.offsetLength)"
|
||||
)
|
||||
return contains
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Process
|
||||
|
||||
/// 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 a surface with an external process list. Ghostty returns 0
|
||||
/// when the surface has no process yet — surfaced here as nil.
|
||||
var foregroundPid: pid_t? {
|
||||
guard let s = surface else { return nil }
|
||||
let pid = ghostty_surface_foreground_pid(s)
|
||||
return pid == 0 ? nil : pid_t(pid)
|
||||
}
|
||||
|
||||
/// Name of the pty's controlling tty (e.g. `/dev/ttys004`), or nil when the
|
||||
/// surface has no process yet. Useful as a cross-check for ``foregroundPid``.
|
||||
var ttyName: String? {
|
||||
guard let s = surface else { return nil }
|
||||
let str = ghostty_surface_tty_name(s)
|
||||
defer { ghostty_string_free(str) }
|
||||
guard let ptr = str.ptr, str.len > 0 else { return nil }
|
||||
return String(
|
||||
decoding: UnsafeRawBufferPointer(start: ptr, count: Int(str.len)),
|
||||
as: UTF8.self
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func free() {
|
||||
guard !hasBeenFreed, let s = surface else { return }
|
||||
TerminalDebugLog.log(.lifecycle, "surface free")
|
||||
hasBeenFreed = true
|
||||
surface = nil
|
||||
ghostty_surface_free(s)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Surface should be freed explicitly via free() before deinit.
|
||||
// The deinit safety net is intentionally removed because
|
||||
// Swift 6 strict concurrency prevents accessing @MainActor
|
||||
// state from nonisolated deinit.
|
||||
}
|
||||
}
|
||||
|
||||
private func terminalKeyText(_ event: ghostty_input_key_s) -> String {
|
||||
guard let text = event.text else { return "nil" }
|
||||
return TerminalDebugLog.describe(String(cString: text))
|
||||
}
|
||||
18
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceContext.swift
vendored
Normal file
18
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceContext.swift
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// TerminalSurfaceContext.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
import GhosttyKit
|
||||
|
||||
public enum TerminalSurfaceContext: Sendable, Equatable {
|
||||
case window
|
||||
case split
|
||||
|
||||
var ghosttyValue: ghostty_surface_context_e {
|
||||
switch self {
|
||||
case .window: GHOSTTY_SURFACE_CONTEXT_WINDOW
|
||||
case .split: GHOSTTY_SURFACE_CONTEXT_SPLIT
|
||||
}
|
||||
}
|
||||
}
|
||||
409
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceCoordinator.swift
vendored
Normal file
409
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceCoordinator.swift
vendored
Normal file
@@ -0,0 +1,409 @@
|
||||
//
|
||||
// TerminalSurfaceCoordinator.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
import MSDisplayLink
|
||||
|
||||
/// Shared terminal state and logic used by both UIKit and AppKit views.
|
||||
///
|
||||
/// Platform views own a `TerminalSurfaceCoordinator` instance and set platform-specific
|
||||
/// hooks via closures. The core handles surface lifecycle, metrics
|
||||
/// synchronization, and frame rendering via scheduled wakeups.
|
||||
@MainActor
|
||||
final class TerminalSurfaceCoordinator {
|
||||
weak var delegate: (any TerminalSurfaceViewDelegate)? {
|
||||
didSet { bridge.delegate = delegate }
|
||||
}
|
||||
|
||||
var controller: TerminalController? {
|
||||
didSet {
|
||||
guard controller !== oldValue else { return }
|
||||
rebuildIfReady(removingBridgeFrom: oldValue)
|
||||
}
|
||||
}
|
||||
|
||||
var configuration: TerminalSurfaceOptions = .init() {
|
||||
didSet {
|
||||
guard !configuration.isEquivalent(to: oldValue) else { return }
|
||||
rebuildIfReady()
|
||||
}
|
||||
}
|
||||
|
||||
var surface: TerminalSurface?
|
||||
let bridge = TerminalCallbackBridge()
|
||||
|
||||
// MARK: - Platform Hooks
|
||||
|
||||
var isAttached: () -> Bool = { false }
|
||||
var scaleFactor: () -> Double = { 2.0 }
|
||||
var viewSize: () -> (width: Double, height: Double) = { (0, 0) }
|
||||
var platformSetup: ((inout ghostty_surface_config_s) -> Void)?
|
||||
var onMetricsUpdate: (() -> Void)?
|
||||
var onCellSizeDidChange: (() -> Void)?
|
||||
|
||||
/// Called after every display-link render (`tick`).
|
||||
///
|
||||
/// When `synchronizeMetrics` sends a new pixel size to ghostty via
|
||||
/// `setSize`, the underlying IOSurface is not rebuilt synchronously.
|
||||
/// Until the next full render pass ghostty still uses the **old**
|
||||
/// IOSurface, so it derives an incorrect `contentsScale` for the
|
||||
/// IOSurfaceLayer (e.g. old-pixel-height / new-point-height → 4.62
|
||||
/// instead of the expected 3.0). This causes a visible "jump" on
|
||||
/// every layout change (keyboard show/hide, rotation, color-scheme
|
||||
/// toggle, etc.).
|
||||
///
|
||||
/// Platform views use this hook to silently enforce the correct
|
||||
/// `contentsScale` and `frame` on sublayers after each render,
|
||||
/// correcting any drift introduced by ghostty within a single frame.
|
||||
var onPostRender: (() -> Void)?
|
||||
|
||||
private var lastMetrics: TerminalViewportMetrics?
|
||||
private var isDisplayVisible = true
|
||||
private var isApplicationActive = true
|
||||
private var isSurfaceFocused = false
|
||||
private var pendingImmediateTick = true
|
||||
private var lastTickTimestamp: TimeInterval = 0
|
||||
private var tickScheduled = false
|
||||
|
||||
init() {
|
||||
bridge.onCellSizeChange = { [weak self] width, height in
|
||||
self?.handleCellSizeChange(width: width, height: height)
|
||||
}
|
||||
bridge.onRenderRequest = { [weak self] in
|
||||
self?.requestImmediateTick()
|
||||
}
|
||||
}
|
||||
|
||||
func requestImmediateTick() {
|
||||
pendingImmediateTick = true
|
||||
scheduleTickIfNeeded()
|
||||
}
|
||||
|
||||
func startDisplayLink() {
|
||||
scheduleTickIfNeeded()
|
||||
}
|
||||
|
||||
func stopDisplayLink() {
|
||||
tickScheduled = false
|
||||
}
|
||||
|
||||
// MARK: - Surface Lifecycle
|
||||
|
||||
func rebuildIfReady(removingBridgeFrom previousController: TerminalController? = nil) {
|
||||
tearDownSurface(removingBridgeFrom: previousController ?? controller)
|
||||
guard let controller else {
|
||||
TerminalDebugLog.log(.lifecycle, "surface rebuild skipped: missing controller")
|
||||
return
|
||||
}
|
||||
guard isAttached() else {
|
||||
TerminalDebugLog.log(.lifecycle, "surface rebuild skipped: view detached")
|
||||
return
|
||||
}
|
||||
guard hasValidViewSize else {
|
||||
let size = viewSize()
|
||||
TerminalDebugLog.log(
|
||||
.lifecycle,
|
||||
"surface rebuild skipped: invalid view size=\(String(format: "%.2f", size.width))x\(String(format: "%.2f", size.height))"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let scale = scaleFactor()
|
||||
TerminalDebugLog.log(
|
||||
.lifecycle,
|
||||
"surface rebuild scale=\(String(format: "%.2f", scale)) \(configuration.debugSummary)"
|
||||
)
|
||||
let rawSurface = controller.createSurface(
|
||||
bridge: bridge,
|
||||
configuration: configuration,
|
||||
platformSetup: { [self] config in
|
||||
platformSetup?(&config)
|
||||
config.scale_factor = scale
|
||||
}
|
||||
)
|
||||
guard let rawSurface else {
|
||||
TerminalDebugLog.log(.lifecycle, "surface rebuild failed")
|
||||
return
|
||||
}
|
||||
|
||||
bridge.rawSurface = rawSurface
|
||||
let newSurface = TerminalSurface(rawSurface)
|
||||
surface = newSurface
|
||||
newSurface.setOcclusion(effectiveSurfaceVisible)
|
||||
controller.shouldProcessWakeup = { [weak self] in
|
||||
self?.canRenderFrame == true
|
||||
}
|
||||
controller.onWakeup = { [weak self] in
|
||||
self?.requestImmediateTick()
|
||||
}
|
||||
TerminalDebugLog.log(.lifecycle, "surface rebuild succeeded")
|
||||
(delegate as? any TerminalSurfaceLifecycleDelegate)?
|
||||
.terminalDidAttachSurface(newSurface)
|
||||
synchronizeMetrics()
|
||||
requestImmediateTick()
|
||||
}
|
||||
|
||||
// MARK: - Metrics
|
||||
|
||||
func synchronizeMetrics() {
|
||||
guard let surface else {
|
||||
TerminalDebugLog.log(.metrics, "synchronizeMetrics skipped: missing surface")
|
||||
return
|
||||
}
|
||||
|
||||
let scale = scaleFactor()
|
||||
let size = viewSize()
|
||||
guard size.width > 0, size.height > 0 else {
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"synchronizeMetrics skipped: invalid view size=\(String(format: "%.2f", size.width))x\(String(format: "%.2f", size.height))"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let pixelWidth = UInt32((size.width * scale).rounded(.down))
|
||||
let pixelHeight = UInt32((size.height * scale).rounded(.down))
|
||||
guard pixelWidth > 0, pixelHeight > 0 else {
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"synchronizeMetrics skipped: invalid pixel size=\(pixelWidth)x\(pixelHeight)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"sync view=\(String(format: "%.2f", size.width))x\(String(format: "%.2f", size.height)) scale=\(String(format: "%.2f", scale)) pixels=\(pixelWidth)x\(pixelHeight)"
|
||||
)
|
||||
|
||||
surface.setContentScale(x: scale, y: scale)
|
||||
surface.setSize(width: pixelWidth, height: pixelHeight)
|
||||
|
||||
guard let surfaceSize = surface.size(),
|
||||
surfaceSize.columns > 0, surfaceSize.rows > 0
|
||||
else {
|
||||
TerminalDebugLog.log(.metrics, "sync missing grid metrics after resize")
|
||||
onMetricsUpdate?()
|
||||
return
|
||||
}
|
||||
|
||||
let metrics = TerminalViewportMetrics(surfaceSize: surfaceSize, scale: scale)
|
||||
guard metrics != lastMetrics else {
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"sync unchanged \(metrics.debugSummary)"
|
||||
)
|
||||
onMetricsUpdate?()
|
||||
return
|
||||
}
|
||||
|
||||
lastMetrics = metrics
|
||||
TerminalDebugLog.log(.metrics, "sync updated \(metrics.debugSummary)")
|
||||
configuration.inMemorySession?.updateViewport(surfaceSize)
|
||||
if let delegate = delegate as? any TerminalSurfaceGridResizeDelegate {
|
||||
delegate.terminalDidResize(surfaceSize)
|
||||
} else if let delegate = delegate as? any TerminalSurfaceResizeDelegate {
|
||||
delegate.terminalDidResize(
|
||||
columns: Int(surfaceSize.columns),
|
||||
rows: Int(surfaceSize.rows)
|
||||
)
|
||||
}
|
||||
onMetricsUpdate?()
|
||||
}
|
||||
|
||||
func fitToSize() {
|
||||
if surface == nil {
|
||||
rebuildIfReady()
|
||||
} else {
|
||||
synchronizeMetrics()
|
||||
}
|
||||
if surface != nil {
|
||||
requestImmediateTick()
|
||||
}
|
||||
}
|
||||
|
||||
func setDisplayVisible(_ visible: Bool) {
|
||||
guard isDisplayVisible != visible else {
|
||||
surface?.setOcclusion(effectiveSurfaceVisible)
|
||||
return
|
||||
}
|
||||
|
||||
isDisplayVisible = visible
|
||||
surface?.setOcclusion(effectiveSurfaceVisible)
|
||||
|
||||
if canRenderFrame {
|
||||
requestImmediateTick()
|
||||
} else {
|
||||
stopDisplayLink()
|
||||
}
|
||||
}
|
||||
|
||||
func setApplicationActive(_ active: Bool) {
|
||||
guard isApplicationActive != active else {
|
||||
if active {
|
||||
renderImmediately()
|
||||
} else {
|
||||
stopDisplayLink()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
isApplicationActive = active
|
||||
surface?.setOcclusion(effectiveSurfaceVisible)
|
||||
|
||||
if active {
|
||||
synchronizeMetrics()
|
||||
renderImmediately()
|
||||
} else {
|
||||
stopDisplayLink()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Frame Rendering
|
||||
|
||||
func tick(context: DisplayLinkCallbackContext) {
|
||||
guard shouldRenderFrame(at: context.timestamp) else {
|
||||
return
|
||||
}
|
||||
pendingImmediateTick = false
|
||||
lastTickTimestamp = context.timestamp
|
||||
TerminalDebugLog.log(.render, "tick")
|
||||
controller?.tick()
|
||||
surface?.refresh()
|
||||
surface?.draw()
|
||||
onPostRender?()
|
||||
}
|
||||
|
||||
// MARK: - Focus
|
||||
|
||||
func setFocus(_ focused: Bool) {
|
||||
isSurfaceFocused = focused
|
||||
requestImmediateTick()
|
||||
TerminalDebugLog.log(.lifecycle, "focus=\(focused)")
|
||||
surface?.setFocus(focused)
|
||||
(delegate as? any TerminalSurfaceFocusDelegate)?
|
||||
.terminalDidChangeFocus(focused)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
func freeSurface() {
|
||||
TerminalDebugLog.log(.lifecycle, "free surface")
|
||||
tearDownSurface(removingBridgeFrom: controller)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// `@MainActor` classes have a nonisolated deinit by default, but
|
||||
// `tearDownSurface` calls methods on other main-actor types (surface,
|
||||
// bridge, controller). We rely on deinit running synchronously with
|
||||
// exclusive access; assume main-actor isolation so teardown can run
|
||||
// inline without crossing isolation.
|
||||
MainActor.assumeIsolated {
|
||||
tearDownSurface(removingBridgeFrom: controller)
|
||||
}
|
||||
}
|
||||
|
||||
private func tearDownSurface(removingBridgeFrom controller: TerminalController?) {
|
||||
TerminalDebugLog.log(.lifecycle, "tear down surface")
|
||||
tickScheduled = false
|
||||
if let session = configuration.inMemorySession {
|
||||
session.clearSurface(ifMatches: surface?.rawValue)
|
||||
}
|
||||
controller?.onWakeup = nil
|
||||
controller?.shouldProcessWakeup = nil
|
||||
bridge.rawSurface = nil
|
||||
let hadSurface = surface != nil
|
||||
surface?.setFocus(false)
|
||||
surface?.free()
|
||||
surface = nil
|
||||
lastMetrics = nil
|
||||
pendingImmediateTick = true
|
||||
lastTickTimestamp = 0
|
||||
controller?.remove(bridge)
|
||||
if hadSurface {
|
||||
(delegate as? any TerminalSurfaceLifecycleDelegate)?
|
||||
.terminalDidDetachSurface()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCellSizeChange(width: UInt32, height: UInt32) {
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"cell size changed width=\(width) height=\(height)"
|
||||
)
|
||||
synchronizeMetrics()
|
||||
requestImmediateTick()
|
||||
onCellSizeDidChange?()
|
||||
}
|
||||
|
||||
private func shouldRenderFrame(at _: TimeInterval) -> Bool {
|
||||
guard canRenderFrame else {
|
||||
return false
|
||||
}
|
||||
return pendingImmediateTick || lastTickTimestamp == 0
|
||||
}
|
||||
|
||||
private func scheduleTickIfNeeded() {
|
||||
guard canRenderFrame else {
|
||||
tickScheduled = false
|
||||
return
|
||||
}
|
||||
guard !tickScheduled else {
|
||||
return
|
||||
}
|
||||
tickScheduled = true
|
||||
TerminalDebugLog.log(.lifecycle, "tick scheduled")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
tickScheduled = false
|
||||
let timestamp = Self.monotonicTimestamp()
|
||||
tick(
|
||||
context: .init(
|
||||
duration: 0,
|
||||
timestamp: timestamp,
|
||||
targetTimestamp: timestamp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func monotonicTimestamp() -> TimeInterval {
|
||||
ProcessInfo.processInfo.systemUptime
|
||||
}
|
||||
|
||||
private var effectiveSurfaceVisible: Bool {
|
||||
isDisplayVisible && isApplicationActive
|
||||
}
|
||||
|
||||
private var canRenderFrame: Bool {
|
||||
effectiveSurfaceVisible && isAttached()
|
||||
}
|
||||
|
||||
private var hasValidViewSize: Bool {
|
||||
let size = viewSize()
|
||||
return size.width > 0 && size.height > 0
|
||||
}
|
||||
|
||||
private func renderImmediately() {
|
||||
guard canRenderFrame else {
|
||||
tickScheduled = false
|
||||
return
|
||||
}
|
||||
|
||||
pendingImmediateTick = true
|
||||
tickScheduled = false
|
||||
let timestamp = Self.monotonicTimestamp()
|
||||
tick(
|
||||
context: .init(
|
||||
duration: 0,
|
||||
timestamp: timestamp,
|
||||
targetTimestamp: timestamp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
48
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceOptions.swift
vendored
Normal file
48
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceOptions.swift
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// TerminalSurfaceOptions.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import GhosttyKit
|
||||
|
||||
public struct TerminalSurfaceOptions: Sendable {
|
||||
public var backend: TerminalSessionBackend
|
||||
public var fontSize: Float?
|
||||
public var workingDirectory: String?
|
||||
/// Extra environment variables set in the child process spawned for this
|
||||
/// surface (exec backend). Passed through `ghostty_surface_config_s.env_vars`;
|
||||
/// every process launched from the surface's shell inherits them, which lets
|
||||
/// embedding hosts tag a surface (e.g. `MYAPP_PANE=<uuid>`) and correlate
|
||||
/// externally observed processes back to it.
|
||||
public var envVars: [String: String]
|
||||
public var context: TerminalSurfaceContext
|
||||
|
||||
public init(
|
||||
backend: TerminalSessionBackend = .exec,
|
||||
fontSize: Float? = nil,
|
||||
workingDirectory: String? = nil,
|
||||
envVars: [String: String] = [:],
|
||||
context: TerminalSurfaceContext = .window
|
||||
) {
|
||||
self.backend = backend
|
||||
self.fontSize = fontSize
|
||||
self.workingDirectory = workingDirectory
|
||||
self.envVars = envVars
|
||||
self.context = context
|
||||
}
|
||||
|
||||
func isEquivalent(to other: TerminalSurfaceOptions) -> Bool {
|
||||
fontSize == other.fontSize
|
||||
&& workingDirectory == other.workingDirectory
|
||||
&& envVars == other.envVars
|
||||
&& context == other.context
|
||||
&& backend.isEquivalent(to: other.backend)
|
||||
}
|
||||
|
||||
var inMemorySession: InMemoryTerminalSession? {
|
||||
guard case let .inMemory(session) = backend else { return nil }
|
||||
return session
|
||||
}
|
||||
}
|
||||
82
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceView.swift
vendored
Normal file
82
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceView.swift
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// TerminalSurfaceView.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct TerminalSurfaceView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@ObservedObject var context: TerminalViewState
|
||||
let focusBinding: TerminalFocusBinding?
|
||||
|
||||
public init(context: TerminalViewState) {
|
||||
self.context = context
|
||||
focusBinding = nil
|
||||
}
|
||||
|
||||
init(
|
||||
context: TerminalViewState,
|
||||
focusBinding: TerminalFocusBinding?
|
||||
) {
|
||||
self.context = context
|
||||
self.focusBinding = focusBinding
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
TerminalViewRepresentable(
|
||||
context: context,
|
||||
controller: context.controller,
|
||||
configuration: context.configuration,
|
||||
focusBinding: focusBinding
|
||||
)
|
||||
.background(.clear)
|
||||
.onChange(of: colorScheme) { newScheme in
|
||||
context.adopt(colorScheme: newScheme)
|
||||
}
|
||||
.onAppear {
|
||||
context.adopt(colorScheme: colorScheme)
|
||||
}
|
||||
}
|
||||
|
||||
public func terminalFocused(
|
||||
_ condition: FocusState<Bool>.Binding
|
||||
) -> TerminalSurfaceView {
|
||||
TerminalSurfaceView(
|
||||
context: context,
|
||||
focusBinding: .bool(condition)
|
||||
)
|
||||
}
|
||||
|
||||
public func terminalFocused<Value: Hashable>(
|
||||
_ binding: FocusState<Value?>.Binding,
|
||||
equals value: Value
|
||||
) -> TerminalSurfaceView {
|
||||
TerminalSurfaceView(
|
||||
context: context,
|
||||
focusBinding: .optional(binding, equals: value)
|
||||
)
|
||||
}
|
||||
|
||||
public func terminalFocusOnAppear(
|
||||
_ condition: FocusState<Bool>.Binding
|
||||
) -> some View {
|
||||
terminalFocused(condition)
|
||||
.onAppear {
|
||||
condition.wrappedValue = true
|
||||
}
|
||||
}
|
||||
|
||||
public func terminalFocusOnAppear<Value: Hashable>(
|
||||
_ binding: FocusState<Value?>.Binding,
|
||||
equals value: Value
|
||||
) -> some View {
|
||||
terminalFocused(binding, equals: value)
|
||||
.onAppear {
|
||||
binding.wrappedValue = value
|
||||
}
|
||||
}
|
||||
}
|
||||
168
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceViewDelegate.swift
vendored
Normal file
168
vendor/libghostty-spm/Sources/GhosttyTerminal/Surface/TerminalSurfaceViewDelegate.swift
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
//
|
||||
// TerminalSurfaceViewDelegate.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceViewDelegate: AnyObject {}
|
||||
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceTitleDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidChangeTitle(_ title: String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceGridResizeDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidResize(_ size: TerminalGridMetrics)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceResizeDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidResize(columns: Int, rows: Int)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceFocusDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidChangeFocus(_ focused: Bool)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceBellDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidRingBell()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceCloseDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidClose(processAlive: Bool)
|
||||
}
|
||||
|
||||
// MARK: - Extended action delegates
|
||||
|
||||
/// State of an OSC 9;4 / DECSET progress report.
|
||||
public enum TerminalProgressState: Sendable {
|
||||
case remove
|
||||
case set
|
||||
case error
|
||||
case indeterminate
|
||||
case pause
|
||||
|
||||
init?(_ raw: ghostty_action_progress_report_state_e) {
|
||||
switch raw {
|
||||
case GHOSTTY_PROGRESS_STATE_REMOVE: self = .remove
|
||||
case GHOSTTY_PROGRESS_STATE_SET: self = .set
|
||||
case GHOSTTY_PROGRESS_STATE_ERROR: self = .error
|
||||
case GHOSTTY_PROGRESS_STATE_INDETERMINATE: self = .indeterminate
|
||||
case GHOSTTY_PROGRESS_STATE_PAUSE: self = .pause
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OSC 9;4 progress report (state + 0-100 percent, nil percent when the
|
||||
/// emitter didn't provide one — e.g. INDETERMINATE / REMOVE).
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceProgressReportDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidReportProgress(state: TerminalProgressState, percent: Int?)
|
||||
}
|
||||
|
||||
/// Fires when a shell-integration-aware command exits. `exitCode` is nil
|
||||
/// when not reported; `duration` is the wall clock in nanoseconds.
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceCommandFinishedDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidFinishCommand(exitCode: Int?, durationNanos: UInt64)
|
||||
}
|
||||
|
||||
/// OSC 9 (iTerm2) / OSC 777 (rxvt-unicode) desktop notification.
|
||||
/// Empty title/body surface as empty strings rather than nil.
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceDesktopNotificationDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidRequestDesktopNotification(title: String, body: String)
|
||||
}
|
||||
|
||||
public enum TerminalOpenURLKind: Sendable {
|
||||
case unknown
|
||||
case text
|
||||
case html
|
||||
|
||||
init(_ raw: ghostty_action_open_url_kind_e) {
|
||||
switch raw {
|
||||
case GHOSTTY_ACTION_OPEN_URL_KIND_TEXT: self = .text
|
||||
case GHOSTTY_ACTION_OPEN_URL_KIND_HTML: self = .html
|
||||
default: self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User activated (cmd-clicked) a hyperlink inside the terminal grid.
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceOpenURLDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidRequestOpenURL(_ url: String, kind: TerminalOpenURLKind)
|
||||
}
|
||||
|
||||
/// Mouse hovered over a recognized hyperlink. nil = hover ended / link lost.
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceHoverLinkDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidUpdateHoverLink(_ url: String?)
|
||||
}
|
||||
|
||||
/// OSC 7 working-directory update.
|
||||
@MainActor
|
||||
public protocol TerminalSurfacePwdDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidChangeWorkingDirectory(_ path: String)
|
||||
}
|
||||
|
||||
/// Scrollbar geometry reported by the terminal, in rows: `offset` rows are
|
||||
/// scrolled off above the viewport, `len` rows are visible, out of `total`
|
||||
/// rows of content (scrollback + screen).
|
||||
public struct TerminalScrollbar: Equatable, Sendable {
|
||||
public let total: UInt64
|
||||
public let offset: UInt64
|
||||
public let len: UInt64
|
||||
|
||||
public init(total: UInt64, offset: UInt64, len: UInt64) {
|
||||
self.total = total
|
||||
self.offset = offset
|
||||
self.len = len
|
||||
}
|
||||
}
|
||||
|
||||
/// The scrollbar geometry changed (the viewport scrolled or the content grew).
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceScrollbarDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidUpdateScrollbar(_ scrollbar: TerminalScrollbar)
|
||||
}
|
||||
|
||||
/// User long-pressed to request a selection-page presentation.
|
||||
public struct TerminalTextSelectionRequest: Sendable {
|
||||
/// Viewport text snapshot. Lines separated by `\n`.
|
||||
public let text: String
|
||||
|
||||
/// Recommended pre-selection range in UTF-16 units, suitable for direct
|
||||
/// assignment to `UITextView.selectedRange`. `nil` means the host should
|
||||
/// `selectAll` instead.
|
||||
public let anchorRange: NSRange?
|
||||
|
||||
/// Long-press point in the terminal view's coordinate space (points).
|
||||
/// Hosts may use this as a popover anchor.
|
||||
public let sourcePoint: CGPoint
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceTextSelectionRequestDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidRequestTextSelection(_ request: TerminalTextSelectionRequest)
|
||||
}
|
||||
|
||||
/// Notifies a delegate when the underlying ``TerminalSurface`` is created or
|
||||
/// torn down. Useful when a consumer needs surface-level APIs (e.g.
|
||||
/// ``TerminalSurface/sendText(_:)``) reachable from outside the platform view.
|
||||
@MainActor
|
||||
public protocol TerminalSurfaceLifecycleDelegate: TerminalSurfaceViewDelegate {
|
||||
func terminalDidAttachSurface(_ surface: TerminalSurface)
|
||||
func terminalDidDetachSurface()
|
||||
}
|
||||
Reference in New Issue
Block a user