初始提交: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:
40
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/GhosttyConfigRenderer.swift
vendored
Normal file
40
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/GhosttyConfigRenderer.swift
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// GhosttyConfigRenderer.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum GhosttyConfigRenderer {
|
||||
static func render(
|
||||
baseContents: String,
|
||||
configuration: TerminalConfiguration,
|
||||
theme: TerminalConfiguration
|
||||
) -> String {
|
||||
var sections: [String] = []
|
||||
|
||||
let normalizedBase = normalize(baseContents)
|
||||
if !normalizedBase.isEmpty {
|
||||
sections.append(normalizedBase)
|
||||
}
|
||||
|
||||
let configurationLines = configuration.commands.map(\.renderedLine)
|
||||
if !configurationLines.isEmpty {
|
||||
sections.append(configurationLines.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
let themeLines = theme.commands.map(\.renderedLine)
|
||||
if !themeLines.isEmpty {
|
||||
sections.append(themeLines.joined(separator: "\n"))
|
||||
}
|
||||
|
||||
guard !sections.isEmpty else { return "" }
|
||||
return sections.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
private static func normalize(_ contents: String) -> String {
|
||||
contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
21
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalColorScheme+SwiftUI.swift
vendored
Normal file
21
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalColorScheme+SwiftUI.swift
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// TerminalColorScheme+SwiftUI.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(SwiftUI)
|
||||
import SwiftUI
|
||||
|
||||
extension TerminalColorScheme {
|
||||
init(_ colorScheme: ColorScheme) {
|
||||
switch colorScheme {
|
||||
case .dark:
|
||||
self = .dark
|
||||
default:
|
||||
self = .light
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
18
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalColorScheme.swift
vendored
Normal file
18
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalColorScheme.swift
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// TerminalColorScheme.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
import GhosttyKit
|
||||
|
||||
public enum TerminalColorScheme: Sendable {
|
||||
case light
|
||||
case dark
|
||||
|
||||
var ghosttyValue: ghostty_color_scheme_e {
|
||||
switch self {
|
||||
case .light: GHOSTTY_COLOR_SCHEME_LIGHT
|
||||
case .dark: GHOSTTY_COLOR_SCHEME_DARK
|
||||
}
|
||||
}
|
||||
}
|
||||
360
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalConfiguration.swift
vendored
Normal file
360
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalConfiguration.swift
vendored
Normal file
@@ -0,0 +1,360 @@
|
||||
//
|
||||
// TerminalConfiguration.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
public enum TerminalCursorStyle: String, Sendable, Hashable {
|
||||
case block
|
||||
case bar
|
||||
case underline
|
||||
}
|
||||
|
||||
public enum TerminalConfigCommand: Sendable, Hashable {
|
||||
// Font
|
||||
case fontFamily(String)
|
||||
case fontSize(Float)
|
||||
case fontThicken(Bool)
|
||||
case fontThickenStrength(Int)
|
||||
|
||||
// Cursor
|
||||
case cursorStyle(TerminalCursorStyle)
|
||||
case cursorStyleBlink(Bool)
|
||||
case cursorColor(String)
|
||||
case cursorText(String)
|
||||
case cursorOpacity(Double)
|
||||
|
||||
// Colors
|
||||
case background(String)
|
||||
case foreground(String)
|
||||
case selectionBackground(String)
|
||||
case selectionForeground(String)
|
||||
case boldColor(String)
|
||||
case palette(index: Int, color: String)
|
||||
case minimumContrast(Double)
|
||||
|
||||
// Background
|
||||
case backgroundOpacity(Double)
|
||||
case backgroundBlur(Int)
|
||||
|
||||
// Layout
|
||||
case windowPaddingX(Int)
|
||||
case windowPaddingY(Int)
|
||||
|
||||
/// Escape hatch
|
||||
case custom(key: String, value: String)
|
||||
|
||||
var renderedLine: String {
|
||||
switch self {
|
||||
case let .fontFamily(value):
|
||||
"font-family = \(value)"
|
||||
|
||||
case let .fontSize(value):
|
||||
"font-size = \(value.formatted(.number.precision(.fractionLength(0 ... 2))))"
|
||||
|
||||
case let .fontThicken(enabled):
|
||||
"font-thicken = \(enabled)"
|
||||
|
||||
case let .fontThickenStrength(value):
|
||||
"font-thicken-strength = \(value)"
|
||||
|
||||
case let .cursorStyle(style):
|
||||
"cursor-style = \(style.rawValue)"
|
||||
|
||||
case let .cursorStyleBlink(enabled):
|
||||
"cursor-style-blink = \(enabled)"
|
||||
|
||||
case let .cursorColor(value):
|
||||
"cursor-color = \(value)"
|
||||
|
||||
case let .cursorText(value):
|
||||
"cursor-text = \(value)"
|
||||
|
||||
case let .cursorOpacity(value):
|
||||
"cursor-opacity = \(value.formatted(.number.precision(.fractionLength(0 ... 3))))"
|
||||
|
||||
case let .background(value):
|
||||
"background = \(value)"
|
||||
|
||||
case let .foreground(value):
|
||||
"foreground = \(value)"
|
||||
|
||||
case let .selectionBackground(value):
|
||||
"selection-background = \(value)"
|
||||
|
||||
case let .selectionForeground(value):
|
||||
"selection-foreground = \(value)"
|
||||
|
||||
case let .boldColor(value):
|
||||
"bold-color = \(value)"
|
||||
|
||||
case let .palette(index, color):
|
||||
"palette = \(index)=\(color)"
|
||||
|
||||
case let .minimumContrast(value):
|
||||
"minimum-contrast = \(value.formatted(.number.precision(.fractionLength(0 ... 2))))"
|
||||
|
||||
case let .backgroundOpacity(value):
|
||||
"background-opacity = \(value.formatted(.number.precision(.fractionLength(0 ... 3))))"
|
||||
|
||||
case let .backgroundBlur(value):
|
||||
"background-blur = \(value)"
|
||||
|
||||
case let .windowPaddingX(value):
|
||||
"window-padding-x = \(value)"
|
||||
|
||||
case let .windowPaddingY(value):
|
||||
"window-padding-y = \(value)"
|
||||
|
||||
case let .custom(key, value):
|
||||
"\(key) = \(value)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct TerminalConfiguration: Sendable, Hashable {
|
||||
public struct Builder {
|
||||
var commands: [TerminalConfigCommand] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
init(commands: [TerminalConfigCommand]) {
|
||||
self.commands = commands
|
||||
}
|
||||
|
||||
/// Font
|
||||
public mutating func withFontFamily(_ value: String) {
|
||||
commands.append(.fontFamily(value))
|
||||
}
|
||||
|
||||
public mutating func withFontSize(_ value: Float) {
|
||||
commands.append(.fontSize(value))
|
||||
}
|
||||
|
||||
public mutating func withFontThicken(_ enabled: Bool) {
|
||||
commands.append(.fontThicken(enabled))
|
||||
}
|
||||
|
||||
public mutating func withFontThickenStrength(_ value: Int) {
|
||||
commands.append(.fontThickenStrength(value))
|
||||
}
|
||||
|
||||
/// Cursor
|
||||
public mutating func withCursorStyle(_ style: TerminalCursorStyle) {
|
||||
commands.append(.cursorStyle(style))
|
||||
}
|
||||
|
||||
public mutating func withCursorStyleBlink(_ enabled: Bool) {
|
||||
commands.append(.cursorStyleBlink(enabled))
|
||||
}
|
||||
|
||||
public mutating func withCursorColor(_ value: String) {
|
||||
commands.append(.cursorColor(value))
|
||||
}
|
||||
|
||||
public mutating func withCursorText(_ value: String) {
|
||||
commands.append(.cursorText(value))
|
||||
}
|
||||
|
||||
public mutating func withCursorOpacity(_ value: Double) {
|
||||
commands.append(.cursorOpacity(value))
|
||||
}
|
||||
|
||||
/// Colors
|
||||
public mutating func withBackground(_ value: String) {
|
||||
commands.append(.background(value))
|
||||
}
|
||||
|
||||
public mutating func withForeground(_ value: String) {
|
||||
commands.append(.foreground(value))
|
||||
}
|
||||
|
||||
public mutating func withSelectionBackground(_ value: String) {
|
||||
commands.append(.selectionBackground(value))
|
||||
}
|
||||
|
||||
public mutating func withSelectionForeground(_ value: String) {
|
||||
commands.append(.selectionForeground(value))
|
||||
}
|
||||
|
||||
public mutating func withBoldColor(_ value: String) {
|
||||
commands.append(.boldColor(value))
|
||||
}
|
||||
|
||||
public mutating func withPalette(_ index: Int, color: String) {
|
||||
commands.append(.palette(index: index, color: color))
|
||||
}
|
||||
|
||||
public mutating func withMinimumContrast(_ value: Double) {
|
||||
commands.append(.minimumContrast(value))
|
||||
}
|
||||
|
||||
/// Background
|
||||
public mutating func withBackgroundOpacity(_ value: Double) {
|
||||
commands.append(.backgroundOpacity(value))
|
||||
}
|
||||
|
||||
public mutating func withBackgroundBlur(_ value: Int) {
|
||||
commands.append(.backgroundBlur(value))
|
||||
}
|
||||
|
||||
/// Layout
|
||||
public mutating func withWindowPaddingX(_ value: Int) {
|
||||
commands.append(.windowPaddingX(value))
|
||||
}
|
||||
|
||||
public mutating func withWindowPaddingY(_ value: Int) {
|
||||
commands.append(.windowPaddingY(value))
|
||||
}
|
||||
|
||||
/// Escape hatch
|
||||
public mutating func withCustom(_ key: String, _ value: String) {
|
||||
commands.append(.custom(key: key, value: value))
|
||||
}
|
||||
}
|
||||
|
||||
let commands: [TerminalConfigCommand]
|
||||
|
||||
public init() {
|
||||
commands = []
|
||||
}
|
||||
|
||||
public init(configure: (inout Builder) -> Void) {
|
||||
self.init(startingFrom: .init(), configure: configure)
|
||||
}
|
||||
|
||||
public init(
|
||||
startingFrom base: TerminalConfiguration,
|
||||
configure: (inout Builder) -> Void
|
||||
) {
|
||||
var builder = Builder(commands: base.commands)
|
||||
configure(&builder)
|
||||
commands = builder.commands
|
||||
}
|
||||
|
||||
public func appending(_ command: TerminalConfigCommand) -> TerminalConfiguration {
|
||||
TerminalConfiguration(commands: commands + [command])
|
||||
}
|
||||
|
||||
// MARK: - Font
|
||||
|
||||
public func fontFamily(_ value: String) -> TerminalConfiguration {
|
||||
appending(.fontFamily(value))
|
||||
}
|
||||
|
||||
public func fontSize(_ value: Float) -> TerminalConfiguration {
|
||||
appending(.fontSize(value))
|
||||
}
|
||||
|
||||
public func fontThicken(_ enabled: Bool) -> TerminalConfiguration {
|
||||
appending(.fontThicken(enabled))
|
||||
}
|
||||
|
||||
public func fontThickenStrength(_ value: Int) -> TerminalConfiguration {
|
||||
appending(.fontThickenStrength(value))
|
||||
}
|
||||
|
||||
// MARK: - Cursor
|
||||
|
||||
public func cursorStyle(_ style: TerminalCursorStyle) -> TerminalConfiguration {
|
||||
appending(.cursorStyle(style))
|
||||
}
|
||||
|
||||
public func cursorStyleBlink(_ enabled: Bool) -> TerminalConfiguration {
|
||||
appending(.cursorStyleBlink(enabled))
|
||||
}
|
||||
|
||||
public func cursorColor(_ value: String) -> TerminalConfiguration {
|
||||
appending(.cursorColor(value))
|
||||
}
|
||||
|
||||
public func cursorText(_ value: String) -> TerminalConfiguration {
|
||||
appending(.cursorText(value))
|
||||
}
|
||||
|
||||
public func cursorOpacity(_ value: Double) -> TerminalConfiguration {
|
||||
appending(.cursorOpacity(value))
|
||||
}
|
||||
|
||||
// MARK: - Colors
|
||||
|
||||
public func background(_ value: String) -> TerminalConfiguration {
|
||||
appending(.background(value))
|
||||
}
|
||||
|
||||
public func foreground(_ value: String) -> TerminalConfiguration {
|
||||
appending(.foreground(value))
|
||||
}
|
||||
|
||||
public func selectionBackground(_ value: String) -> TerminalConfiguration {
|
||||
appending(.selectionBackground(value))
|
||||
}
|
||||
|
||||
public func selectionForeground(_ value: String) -> TerminalConfiguration {
|
||||
appending(.selectionForeground(value))
|
||||
}
|
||||
|
||||
public func boldColor(_ value: String) -> TerminalConfiguration {
|
||||
appending(.boldColor(value))
|
||||
}
|
||||
|
||||
public func palette(_ index: Int, color: String) -> TerminalConfiguration {
|
||||
appending(.palette(index: index, color: color))
|
||||
}
|
||||
|
||||
public func minimumContrast(_ value: Double) -> TerminalConfiguration {
|
||||
appending(.minimumContrast(value))
|
||||
}
|
||||
|
||||
// MARK: - Background
|
||||
|
||||
public func backgroundOpacity(_ value: Double) -> TerminalConfiguration {
|
||||
appending(.backgroundOpacity(value))
|
||||
}
|
||||
|
||||
public func backgroundBlur(_ value: Int) -> TerminalConfiguration {
|
||||
appending(.backgroundBlur(value))
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
public func windowPaddingX(_ value: Int) -> TerminalConfiguration {
|
||||
appending(.windowPaddingX(value))
|
||||
}
|
||||
|
||||
public func windowPaddingY(_ value: Int) -> TerminalConfiguration {
|
||||
appending(.windowPaddingY(value))
|
||||
}
|
||||
|
||||
// MARK: - Escape Hatch
|
||||
|
||||
public func custom(_ key: String, _ value: String) -> TerminalConfiguration {
|
||||
appending(.custom(key: key, value: value))
|
||||
}
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
public static let `default` = TerminalConfiguration { builder in
|
||||
builder.withCursorStyle(.block)
|
||||
builder.withCursorStyleBlink(true)
|
||||
#if os(iOS)
|
||||
builder.withFontSize(10)
|
||||
#else
|
||||
builder.withFontSize(14)
|
||||
#endif
|
||||
builder.withFontThicken(true)
|
||||
}
|
||||
|
||||
public var rendered: String {
|
||||
commands.map(\.renderedLine).joined(separator: "\n")
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
commands.isEmpty
|
||||
}
|
||||
|
||||
init(commands: [TerminalConfigCommand]) {
|
||||
self.commands = commands
|
||||
}
|
||||
}
|
||||
70
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalTheme+Defaults.swift
vendored
Normal file
70
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalTheme+Defaults.swift
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// TerminalTheme+Defaults.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
public extension TerminalTheme {
|
||||
/// Afterglow (dark) + Alabaster (light) default theme.
|
||||
static let `default` = TerminalTheme(
|
||||
light: .alabaster,
|
||||
dark: .afterglow
|
||||
)
|
||||
}
|
||||
|
||||
public extension TerminalConfiguration {
|
||||
/// Alabaster — light terminal theme.
|
||||
///
|
||||
/// Background: #F7F7F7, Foreground: #000000
|
||||
static let alabaster = TerminalConfiguration { builder in
|
||||
builder.withBackground("F7F7F7")
|
||||
builder.withForeground("000000")
|
||||
builder.withCursorColor("007ACC")
|
||||
builder.withSelectionBackground("C9D0D9")
|
||||
// Normal colors (0-7)
|
||||
builder.withPalette(0, color: "#000000")
|
||||
builder.withPalette(1, color: "#AA3731")
|
||||
builder.withPalette(2, color: "#448C27")
|
||||
builder.withPalette(3, color: "#CB8800")
|
||||
builder.withPalette(4, color: "#325CC0")
|
||||
builder.withPalette(5, color: "#7A3E9D")
|
||||
builder.withPalette(6, color: "#0083B2")
|
||||
builder.withPalette(7, color: "#F7F7F7")
|
||||
// Bright colors (8-15)
|
||||
builder.withPalette(8, color: "#777777")
|
||||
builder.withPalette(9, color: "#F03E31")
|
||||
builder.withPalette(10, color: "#60CB00")
|
||||
builder.withPalette(11, color: "#FFBC5D")
|
||||
builder.withPalette(12, color: "#007ACC")
|
||||
builder.withPalette(13, color: "#E64CE6")
|
||||
builder.withPalette(14, color: "#00AACB")
|
||||
builder.withPalette(15, color: "#F7F7F7")
|
||||
}
|
||||
|
||||
/// Afterglow — dark terminal theme.
|
||||
///
|
||||
/// Background: #212121, Foreground: #D0D0D0
|
||||
static let afterglow = TerminalConfiguration { builder in
|
||||
builder.withBackground("212121")
|
||||
builder.withForeground("D0D0D0")
|
||||
builder.withCursorColor("D0D0D0")
|
||||
builder.withSelectionBackground("303030")
|
||||
// Normal colors (0-7)
|
||||
builder.withPalette(0, color: "#151515")
|
||||
builder.withPalette(1, color: "#AC4142")
|
||||
builder.withPalette(2, color: "#7E8E50")
|
||||
builder.withPalette(3, color: "#E4B567")
|
||||
builder.withPalette(4, color: "#6C99BB")
|
||||
builder.withPalette(5, color: "#9F4E86")
|
||||
builder.withPalette(6, color: "#7DD5CF")
|
||||
builder.withPalette(7, color: "#D0D0D0")
|
||||
// Bright colors (8-15)
|
||||
builder.withPalette(8, color: "#505050")
|
||||
builder.withPalette(9, color: "#AC4142")
|
||||
builder.withPalette(10, color: "#7E8E50")
|
||||
builder.withPalette(11, color: "#E4B567")
|
||||
builder.withPalette(12, color: "#6C99BB")
|
||||
builder.withPalette(13, color: "#9F4E86")
|
||||
builder.withPalette(14, color: "#7DD5CF")
|
||||
builder.withPalette(15, color: "#F5F5F5")
|
||||
}
|
||||
}
|
||||
32
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalTheme.swift
vendored
Normal file
32
vendor/libghostty-spm/Sources/GhosttyTerminal/Configuration/TerminalTheme.swift
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// TerminalTheme.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
public struct TerminalTheme: Sendable, Hashable {
|
||||
public var light: TerminalConfiguration
|
||||
public var dark: TerminalConfiguration
|
||||
|
||||
public init(
|
||||
light: TerminalConfiguration = .init(),
|
||||
dark: TerminalConfiguration = .init()
|
||||
) {
|
||||
self.light = light
|
||||
self.dark = dark
|
||||
}
|
||||
|
||||
var isEmpty: Bool {
|
||||
light.isEmpty && dark.isEmpty
|
||||
}
|
||||
|
||||
func configuration(for colorScheme: TerminalColorScheme) -> TerminalConfiguration {
|
||||
switch colorScheme {
|
||||
case .light:
|
||||
light
|
||||
case .dark:
|
||||
dark
|
||||
}
|
||||
}
|
||||
}
|
||||
197
vendor/libghostty-spm/Sources/GhosttyTerminal/Controller/TerminalController+Callbacks.swift
vendored
Normal file
197
vendor/libghostty-spm/Sources/GhosttyTerminal/Controller/TerminalController+Callbacks.swift
vendored
Normal file
@@ -0,0 +1,197 @@
|
||||
//
|
||||
// TerminalController+Callbacks.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
private enum TerminalCallbacks {
|
||||
static func wakeup(userdata: UnsafeMutableRawPointer?) {
|
||||
guard let userdata else { return }
|
||||
let controller = Unmanaged<TerminalController>.fromOpaque(userdata)
|
||||
.takeUnretainedValue()
|
||||
terminalRunOnMain {
|
||||
controller.handleWakeup()
|
||||
}
|
||||
}
|
||||
|
||||
static func action(
|
||||
appPtr: ghostty_app_t?,
|
||||
target: ghostty_target_s,
|
||||
action: ghostty_action_s
|
||||
) -> Bool {
|
||||
guard let appPtr else { return false }
|
||||
guard ghostty_app_userdata(appPtr) != nil else { return false }
|
||||
guard target.tag == GHOSTTY_TARGET_SURFACE else { return false }
|
||||
guard let surfacePtr = target.target.surface else { return false }
|
||||
guard let bridgePtr = ghostty_surface_userdata(surfacePtr) else { return false }
|
||||
|
||||
let bridge = Unmanaged<TerminalCallbackBridge>
|
||||
.fromOpaque(bridgePtr)
|
||||
.takeUnretainedValue()
|
||||
terminalRunOnMain {
|
||||
bridge.handleAction(action)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static func closeSurface(
|
||||
userdata: UnsafeMutableRawPointer?,
|
||||
processAlive: Bool
|
||||
) {
|
||||
guard let userdata else { return }
|
||||
let bridge = Unmanaged<TerminalCallbackBridge>
|
||||
.fromOpaque(userdata)
|
||||
.takeUnretainedValue()
|
||||
terminalRunOnMain {
|
||||
bridge.handleClose(processAlive: processAlive)
|
||||
}
|
||||
}
|
||||
|
||||
static func writeClipboard(
|
||||
userdata _: UnsafeMutableRawPointer?,
|
||||
clipboard _: ghostty_clipboard_e,
|
||||
contents: UnsafePointer<ghostty_clipboard_content_s>?,
|
||||
contentsLen: Int,
|
||||
confirm _: Bool
|
||||
) {
|
||||
guard contentsLen > 0 else { return }
|
||||
guard let content = contents?.pointee else { return }
|
||||
guard let data = content.data else { return }
|
||||
let string = String(cString: data)
|
||||
|
||||
#if canImport(UIKit)
|
||||
UIPasteboard.general.string = string
|
||||
#elseif canImport(AppKit)
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(string, forType: .string)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func readClipboard(
|
||||
userdata: UnsafeMutableRawPointer?,
|
||||
clipboard _: ghostty_clipboard_e,
|
||||
opaquePtr: UnsafeMutableRawPointer?
|
||||
) -> Bool {
|
||||
guard let userdata, let opaquePtr else { return false }
|
||||
|
||||
let bridge = Unmanaged<TerminalCallbackBridge>
|
||||
.fromOpaque(userdata)
|
||||
.takeUnretainedValue()
|
||||
guard let surface = bridge.rawSurface else { return false }
|
||||
|
||||
#if canImport(UIKit)
|
||||
let string = UIPasteboard.general.string
|
||||
#elseif canImport(AppKit)
|
||||
let string = NSPasteboard.general.string(forType: .string)
|
||||
#endif
|
||||
|
||||
guard let string else {
|
||||
TerminalDebugLog.log(.input, "clipboard paste read empty")
|
||||
return false
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"clipboard paste read bytes=\(string.utf8.count) lines=\(TerminalInputText.lineCount(in: string))"
|
||||
)
|
||||
string.withCString { cString in
|
||||
ghostty_surface_complete_clipboard_request(surface, cString, opaquePtr, false)
|
||||
}
|
||||
TerminalDebugLog.log(.input, "clipboard paste complete")
|
||||
return true
|
||||
}
|
||||
|
||||
static func confirmReadClipboard(
|
||||
userdata: UnsafeMutableRawPointer?,
|
||||
string: UnsafePointer<CChar>?,
|
||||
opaquePtr: UnsafeMutableRawPointer?,
|
||||
request: ghostty_clipboard_request_e
|
||||
) {
|
||||
guard let userdata, let string, let opaquePtr else { return }
|
||||
|
||||
let bridge = Unmanaged<TerminalCallbackBridge>
|
||||
.fromOpaque(userdata)
|
||||
.takeUnretainedValue()
|
||||
guard let surface = bridge.rawSurface else { return }
|
||||
|
||||
let text = String(cString: string)
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"clipboard paste confirm request=\(request.rawValue) bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text))"
|
||||
)
|
||||
text.withCString { cString in
|
||||
ghostty_surface_complete_clipboard_request(surface, cString, opaquePtr, true)
|
||||
}
|
||||
TerminalDebugLog.log(.input, "clipboard paste confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
func terminalControllerWakeupCallback(userdata: UnsafeMutableRawPointer?) {
|
||||
TerminalCallbacks.wakeup(userdata: userdata)
|
||||
}
|
||||
|
||||
func terminalControllerActionCallback(
|
||||
appPtr: ghostty_app_t?,
|
||||
target: ghostty_target_s,
|
||||
action: ghostty_action_s
|
||||
) -> Bool {
|
||||
TerminalCallbacks.action(appPtr: appPtr, target: target, action: action)
|
||||
}
|
||||
|
||||
func terminalControllerCloseSurfaceCallback(
|
||||
userdata: UnsafeMutableRawPointer?,
|
||||
processAlive: Bool
|
||||
) {
|
||||
TerminalCallbacks.closeSurface(userdata: userdata, processAlive: processAlive)
|
||||
}
|
||||
|
||||
func terminalControllerWriteClipboardCallback(
|
||||
userdata: UnsafeMutableRawPointer?,
|
||||
clipboard: ghostty_clipboard_e,
|
||||
contents: UnsafePointer<ghostty_clipboard_content_s>?,
|
||||
contentsLen: Int,
|
||||
confirm: Bool
|
||||
) {
|
||||
TerminalCallbacks.writeClipboard(
|
||||
userdata: userdata,
|
||||
clipboard: clipboard,
|
||||
contents: contents,
|
||||
contentsLen: contentsLen,
|
||||
confirm: confirm
|
||||
)
|
||||
}
|
||||
|
||||
func terminalControllerReadClipboardCallback(
|
||||
userdata: UnsafeMutableRawPointer?,
|
||||
clipboard: ghostty_clipboard_e,
|
||||
opaquePtr: UnsafeMutableRawPointer?
|
||||
) -> Bool {
|
||||
TerminalCallbacks.readClipboard(
|
||||
userdata: userdata,
|
||||
clipboard: clipboard,
|
||||
opaquePtr: opaquePtr
|
||||
)
|
||||
}
|
||||
|
||||
func terminalControllerConfirmReadClipboardCallback(
|
||||
userdata: UnsafeMutableRawPointer?,
|
||||
string: UnsafePointer<CChar>?,
|
||||
opaquePtr: UnsafeMutableRawPointer?,
|
||||
request: ghostty_clipboard_request_e
|
||||
) {
|
||||
TerminalCallbacks.confirmReadClipboard(
|
||||
userdata: userdata,
|
||||
string: string,
|
||||
opaquePtr: opaquePtr,
|
||||
request: request
|
||||
)
|
||||
}
|
||||
218
vendor/libghostty-spm/Sources/GhosttyTerminal/Controller/TerminalController+Config.swift
vendored
Normal file
218
vendor/libghostty-spm/Sources/GhosttyTerminal/Controller/TerminalController+Config.swift
vendored
Normal file
@@ -0,0 +1,218 @@
|
||||
//
|
||||
// TerminalController+Config.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
extension TerminalController {
|
||||
@discardableResult
|
||||
public func updateConfigSource(_ source: ConfigSource) -> Bool {
|
||||
guard source != configSource else { return true }
|
||||
|
||||
switch Self.prepareConfig(source: source) {
|
||||
case let .success(value):
|
||||
applyPreparedConfigToRuntime(value, source: source)
|
||||
return true
|
||||
|
||||
case let .failure(issue):
|
||||
lastConfigurationIssue = issue.description
|
||||
Self.reportConfigurationIssue(issue.description)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func applyResolvedConfig(
|
||||
_ resolved: (source: ConfigSource, contents: String),
|
||||
willChange: (() -> Void)?,
|
||||
applyState: () -> Void = {}
|
||||
) -> Bool {
|
||||
guard resolved.source != configSource else {
|
||||
// ObservableObject subscribers expect will-change semantics.
|
||||
willChange?()
|
||||
applyState()
|
||||
renderedConfigContents = resolved.contents
|
||||
return true
|
||||
}
|
||||
|
||||
switch Self.prepareConfig(source: resolved.source) {
|
||||
case let .success(prepared):
|
||||
// Notify after validation succeeds, but before committed state
|
||||
// changes become visible through computed TerminalViewState APIs.
|
||||
willChange?()
|
||||
applyState()
|
||||
applyPreparedConfigToRuntime(prepared, source: resolved.source)
|
||||
return true
|
||||
|
||||
case let .failure(issue):
|
||||
lastConfigurationIssue = issue.description
|
||||
Self.reportConfigurationIssue(issue.description)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func applyPreparedConfigToRuntime(_ prepared: PreparedConfig, source: ConfigSource) {
|
||||
let previousConfig = config
|
||||
let previousManagedConfigURL = managedConfigURL
|
||||
let nextConfig = prepared.rawValue
|
||||
|
||||
if let app {
|
||||
ghostty_app_update_config(app, nextConfig)
|
||||
}
|
||||
|
||||
for bridge in retainedBridges {
|
||||
guard let surface = bridge.rawSurface else { continue }
|
||||
ghostty_surface_update_config(surface, nextConfig)
|
||||
}
|
||||
|
||||
applyPreparedConfig(prepared, source: source)
|
||||
|
||||
if let previousConfig {
|
||||
ghostty_config_free(previousConfig)
|
||||
}
|
||||
|
||||
if let previousManagedConfigURL, previousManagedConfigURL != managedConfigURL {
|
||||
try? FileManager.default.removeItem(at: previousManagedConfigURL)
|
||||
}
|
||||
}
|
||||
|
||||
func applyInitialConfig(source: ConfigSource) {
|
||||
switch Self.prepareConfig(source: source) {
|
||||
case let .success(prepared):
|
||||
applyPreparedConfig(prepared, source: source)
|
||||
|
||||
case let .failure(issue):
|
||||
lastConfigurationIssue = issue.description
|
||||
Self.reportConfigurationIssue(issue.description)
|
||||
|
||||
guard source != .none else { return }
|
||||
guard case let .success(fallback) = Self.prepareConfig(source: ConfigSource.none) else {
|
||||
return
|
||||
}
|
||||
applyPreparedConfig(fallback, source: .none)
|
||||
}
|
||||
}
|
||||
|
||||
func createApp() {
|
||||
guard let cfg = config else { return }
|
||||
|
||||
let userdata = Unmanaged.passUnretained(self).toOpaque()
|
||||
|
||||
var runtimeConfig = ghostty_runtime_config_s()
|
||||
runtimeConfig.userdata = userdata
|
||||
runtimeConfig.supports_selection_clipboard = true
|
||||
runtimeConfig.wakeup_cb = terminalControllerWakeupCallback
|
||||
runtimeConfig.action_cb = terminalControllerActionCallback
|
||||
runtimeConfig.close_surface_cb = terminalControllerCloseSurfaceCallback
|
||||
runtimeConfig.write_clipboard_cb = terminalControllerWriteClipboardCallback
|
||||
runtimeConfig.read_clipboard_cb = terminalControllerReadClipboardCallback
|
||||
runtimeConfig.confirm_read_clipboard_cb = terminalControllerConfirmReadClipboardCallback
|
||||
|
||||
app = ghostty_app_new(&runtimeConfig, cfg)
|
||||
}
|
||||
|
||||
private static func prepareConfig(
|
||||
source: ConfigSource
|
||||
) -> Result<PreparedConfig, ConfigurationIssue> {
|
||||
let resolvedContents: String
|
||||
let configPath: String
|
||||
let managedConfigURL: URL?
|
||||
|
||||
switch source {
|
||||
case .none:
|
||||
resolvedContents = defaultRenderedConfig
|
||||
switch writeManagedConfig(contents: resolvedContents) {
|
||||
case let .success(url):
|
||||
managedConfigURL = url
|
||||
configPath = url.path
|
||||
case let .failure(issue):
|
||||
return .failure(issue)
|
||||
}
|
||||
|
||||
case let .generated(contents):
|
||||
resolvedContents = contents
|
||||
switch writeManagedConfig(contents: contents) {
|
||||
case let .success(url):
|
||||
managedConfigURL = url
|
||||
configPath = url.path
|
||||
case let .failure(issue):
|
||||
return .failure(issue)
|
||||
}
|
||||
|
||||
case let .file(path):
|
||||
do {
|
||||
resolvedContents = try String(contentsOfFile: path, encoding: .utf8)
|
||||
} catch {
|
||||
return .failure(ConfigurationIssue("failed to load ghostty config template: \(error)"))
|
||||
}
|
||||
managedConfigURL = nil
|
||||
configPath = path
|
||||
}
|
||||
|
||||
guard let rawValue = ghostty_config_new() else {
|
||||
if let managedConfigURL {
|
||||
try? FileManager.default.removeItem(at: managedConfigURL)
|
||||
}
|
||||
return .failure(ConfigurationIssue("ghostty_config_new returned nil"))
|
||||
}
|
||||
|
||||
ghostty_config_load_file(rawValue, configPath)
|
||||
ghostty_config_finalize(rawValue)
|
||||
|
||||
let diagnostics = configDiagnostics(from: rawValue)
|
||||
guard diagnostics.isEmpty else {
|
||||
ghostty_config_free(rawValue)
|
||||
if let managedConfigURL {
|
||||
try? FileManager.default.removeItem(at: managedConfigURL)
|
||||
}
|
||||
return .failure(
|
||||
ConfigurationIssue("ghostty config diagnostics: \(diagnostics.joined(separator: " | "))")
|
||||
)
|
||||
}
|
||||
|
||||
return .success(
|
||||
PreparedConfig(
|
||||
rawValue: rawValue,
|
||||
managedConfigURL: managedConfigURL,
|
||||
renderedContents: resolvedContents
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func writeManagedConfig(contents: String) -> Result<URL, ConfigurationIssue> {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("ghostty-config-\(UUID().uuidString)")
|
||||
.appendingPathExtension("conf")
|
||||
|
||||
do {
|
||||
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||
return .success(url)
|
||||
} catch {
|
||||
return .failure(ConfigurationIssue("failed to write generated ghostty config: \(error)"))
|
||||
}
|
||||
}
|
||||
|
||||
private static func configDiagnostics(from config: ghostty_config_t) -> [String] {
|
||||
let count = ghostty_config_diagnostics_count(config)
|
||||
guard count > 0 else { return [] }
|
||||
|
||||
return (0 ..< count).compactMap { index in
|
||||
let diagnostic = ghostty_config_get_diagnostic(config, index)
|
||||
guard let message = diagnostic.message else { return nil }
|
||||
return String(cString: message)
|
||||
}
|
||||
}
|
||||
|
||||
private static func reportConfigurationIssue(_ message: String) {
|
||||
NSLog("GhosttyTerminal configuration issue: %@", message)
|
||||
}
|
||||
|
||||
private func applyPreparedConfig(_ prepared: PreparedConfig, source: ConfigSource) {
|
||||
config = prepared.rawValue
|
||||
managedConfigURL = prepared.managedConfigURL
|
||||
renderedConfigContents = prepared.renderedContents
|
||||
configSource = source
|
||||
lastConfigurationIssue = nil
|
||||
}
|
||||
}
|
||||
145
vendor/libghostty-spm/Sources/GhosttyTerminal/Controller/TerminalController+Surface.swift
vendored
Normal file
145
vendor/libghostty-spm/Sources/GhosttyTerminal/Controller/TerminalController+Surface.swift
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
//
|
||||
// TerminalController+Surface.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
extension TerminalController {
|
||||
/// Creates a new Ghostty surface with the given configuration.
|
||||
///
|
||||
/// The `platformSetup` closure lets the caller fill in
|
||||
/// platform-specific fields (`platform_tag`, `platform`, `scale_factor`)
|
||||
/// on the raw surface config struct before the surface is created.
|
||||
func createSurface(
|
||||
bridge: TerminalCallbackBridge,
|
||||
configuration: TerminalSurfaceOptions,
|
||||
platformSetup: (inout ghostty_surface_config_s) -> Void
|
||||
) -> ghostty_surface_t? {
|
||||
guard let app else { return nil }
|
||||
|
||||
var surfaceConfig = ghostty_surface_config_new()
|
||||
surfaceConfig.userdata = Unmanaged.passUnretained(bridge).toOpaque()
|
||||
surfaceConfig.context = configuration.context.ghosttyValue
|
||||
configureBackend(&surfaceConfig, from: configuration)
|
||||
|
||||
if let fontSize = configuration.fontSize {
|
||||
surfaceConfig.font_size = fontSize
|
||||
}
|
||||
|
||||
// Like `working_directory` below, the pointers only need to outlive
|
||||
// `ghostty_surface_new`, which copies the values during surface init.
|
||||
return withEnvVarEntries(configuration.envVars) { entries, count in
|
||||
surfaceConfig.env_vars = entries
|
||||
surfaceConfig.env_var_count = count
|
||||
return finalizeSurface(
|
||||
app: app,
|
||||
bridge: bridge,
|
||||
configuration: configuration,
|
||||
config: &surfaceConfig,
|
||||
workingDirectory: configuration.workingDirectory,
|
||||
platformSetup: platformSetup
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `body` with a C representation of `envVars` (`ghostty_env_var_s`
|
||||
/// entries) that stays valid for the duration of the call.
|
||||
private func withEnvVarEntries<T>(
|
||||
_ envVars: [String: String],
|
||||
_ body: (UnsafeMutablePointer<ghostty_env_var_s>?, Int) -> T
|
||||
) -> T {
|
||||
guard !envVars.isEmpty else { return body(nil, 0) }
|
||||
let strings: [(key: UnsafeMutablePointer<CChar>, value: UnsafeMutablePointer<CChar>)] =
|
||||
envVars.map { (strdup($0.key), strdup($0.value)) }
|
||||
defer {
|
||||
for entry in strings {
|
||||
free(entry.key)
|
||||
free(entry.value)
|
||||
}
|
||||
}
|
||||
var entries = strings.map { ghostty_env_var_s(key: $0.key, value: $0.value) }
|
||||
return entries.withUnsafeMutableBufferPointer { buffer in
|
||||
body(buffer.baseAddress, buffer.count)
|
||||
}
|
||||
}
|
||||
|
||||
func retain(_ bridge: TerminalCallbackBridge) {
|
||||
retainedBridges.append(bridge)
|
||||
}
|
||||
|
||||
func remove(_ bridge: TerminalCallbackBridge) {
|
||||
retainedBridges.removeAll { $0 === bridge }
|
||||
}
|
||||
|
||||
var retainedBridgeCount: Int {
|
||||
retainedBridges.count
|
||||
}
|
||||
|
||||
private func configureBackend(
|
||||
_ config: inout ghostty_surface_config_s,
|
||||
from options: TerminalSurfaceOptions
|
||||
) {
|
||||
guard case let .inMemory(session) = options.backend else {
|
||||
config.backend = GHOSTTY_SURFACE_IO_BACKEND_EXEC
|
||||
return
|
||||
}
|
||||
|
||||
config.backend = GHOSTTY_SURFACE_IO_BACKEND_HOST_MANAGED
|
||||
config.receive_userdata = Unmanaged.passUnretained(session).toOpaque()
|
||||
config.receive_buffer = InMemoryTerminalSession.receiveBufferCallback
|
||||
config.receive_resize = InMemoryTerminalSession.receiveResizeCallback
|
||||
}
|
||||
|
||||
private func finalizeSurface(
|
||||
app: ghostty_app_t,
|
||||
bridge: TerminalCallbackBridge,
|
||||
configuration: TerminalSurfaceOptions,
|
||||
config: inout ghostty_surface_config_s,
|
||||
workingDirectory: String?,
|
||||
platformSetup: (inout ghostty_surface_config_s) -> Void
|
||||
) -> ghostty_surface_t? {
|
||||
guard let workingDirectory else {
|
||||
return buildSurface(
|
||||
app: app,
|
||||
bridge: bridge,
|
||||
configuration: configuration,
|
||||
config: &config,
|
||||
platformSetup: platformSetup
|
||||
)
|
||||
}
|
||||
|
||||
return workingDirectory.withCString { ptr in
|
||||
config.working_directory = ptr
|
||||
return buildSurface(
|
||||
app: app,
|
||||
bridge: bridge,
|
||||
configuration: configuration,
|
||||
config: &config,
|
||||
platformSetup: platformSetup
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildSurface(
|
||||
app: ghostty_app_t,
|
||||
bridge: TerminalCallbackBridge,
|
||||
configuration: TerminalSurfaceOptions,
|
||||
config: inout ghostty_surface_config_s,
|
||||
platformSetup: (inout ghostty_surface_config_s) -> Void
|
||||
) -> ghostty_surface_t? {
|
||||
platformSetup(&config)
|
||||
guard let surface = ghostty_surface_new(app, &config) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
retain(bridge)
|
||||
|
||||
if case let .inMemory(session) = configuration.backend {
|
||||
session.setSurface(surface)
|
||||
}
|
||||
|
||||
return surface
|
||||
}
|
||||
}
|
||||
313
vendor/libghostty-spm/Sources/GhosttyTerminal/Controller/TerminalController.swift
vendored
Normal file
313
vendor/libghostty-spm/Sources/GhosttyTerminal/Controller/TerminalController.swift
vendored
Normal file
@@ -0,0 +1,313 @@
|
||||
//
|
||||
// TerminalController.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Manages the Ghostty app lifecycle, configuration loading, and surface
|
||||
/// creation.
|
||||
///
|
||||
/// `TerminalController` is the **single source of truth** for terminal
|
||||
/// configuration, including the base config, per-session overrides, theme
|
||||
/// colors, and the active color scheme. When any of these change the
|
||||
/// controller re-resolves the effective config and pushes it to ghostty.
|
||||
@MainActor
|
||||
public final class TerminalController {
|
||||
struct PreparedConfig {
|
||||
let rawValue: ghostty_config_t
|
||||
let managedConfigURL: URL?
|
||||
let renderedContents: String
|
||||
}
|
||||
|
||||
struct ConfigurationIssue: Error, CustomStringConvertible {
|
||||
let description: String
|
||||
|
||||
init(_ description: String) {
|
||||
self.description = description
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConfigSource: Sendable, Hashable {
|
||||
case none
|
||||
case file(String)
|
||||
case generated(String)
|
||||
}
|
||||
|
||||
public static let shared = TerminalController()
|
||||
|
||||
static let defaultRenderedConfig = TerminalConfiguration.default.rendered
|
||||
private static var runtimeInitialized = false
|
||||
|
||||
nonisolated(unsafe) var app: ghostty_app_t?
|
||||
nonisolated(unsafe) var config: ghostty_config_t?
|
||||
var retainedBridges: [TerminalCallbackBridge] = []
|
||||
var configSource: ConfigSource
|
||||
var managedConfigURL: URL?
|
||||
var renderedConfigContents: String = TerminalController.defaultRenderedConfig
|
||||
|
||||
public internal(set) var lastConfigurationIssue: String?
|
||||
var onWakeup: (() -> Void)?
|
||||
var shouldProcessWakeup: (() -> Bool)?
|
||||
|
||||
// MARK: - Config Resolution State
|
||||
|
||||
/// The base config before theme/colorScheme are applied.
|
||||
private let baseConfigSource: ConfigSource
|
||||
private var baseConfigTemplate: String = ""
|
||||
|
||||
/// Per-session configuration overrides (e.g. font size changes).
|
||||
public private(set) var terminalConfiguration: TerminalConfiguration
|
||||
|
||||
/// Color theme (light + dark variants).
|
||||
public private(set) var theme: TerminalTheme
|
||||
|
||||
/// The currently active color scheme.
|
||||
public private(set) var effectiveColorScheme: TerminalColorScheme = .light
|
||||
|
||||
// MARK: - Public Accessors
|
||||
|
||||
public var currentConfigSource: ConfigSource {
|
||||
configSource
|
||||
}
|
||||
|
||||
public var renderedConfig: String {
|
||||
renderedConfigContents
|
||||
}
|
||||
|
||||
// MARK: - Initializers
|
||||
|
||||
/// Creates a controller with the default terminal configuration.
|
||||
public convenience init() {
|
||||
self.init(configuration: .default)
|
||||
}
|
||||
|
||||
/// Creates a controller with a fully custom configuration.
|
||||
public convenience init(
|
||||
configuration: TerminalConfiguration,
|
||||
theme: TerminalTheme = .default
|
||||
) {
|
||||
self.init(
|
||||
configSource: .generated(configuration.rendered),
|
||||
theme: theme
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a controller by composing additional commands on top of
|
||||
/// the default configuration.
|
||||
///
|
||||
/// TerminalController {
|
||||
/// $0.withBackgroundOpacity(0)
|
||||
/// $0.withCustom("keybind", "super+k=text:\\x0c")
|
||||
/// }
|
||||
public convenience init(
|
||||
theme: TerminalTheme = .default,
|
||||
configure: (inout TerminalConfiguration.Builder) -> Void
|
||||
) {
|
||||
self.init(
|
||||
configuration: TerminalConfiguration(
|
||||
startingFrom: .default,
|
||||
configure: configure
|
||||
),
|
||||
theme: theme
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a controller that loads its configuration from a file.
|
||||
public convenience init(
|
||||
configFilePath: String?,
|
||||
theme: TerminalTheme = .default
|
||||
) {
|
||||
guard let configFilePath else {
|
||||
self.init(configSource: .none, theme: theme)
|
||||
return
|
||||
}
|
||||
self.init(configSource: .file(configFilePath), theme: theme)
|
||||
}
|
||||
|
||||
/// Low-level initialiser for full control over the config source.
|
||||
public init(
|
||||
configSource: ConfigSource = .none,
|
||||
theme: TerminalTheme = .default,
|
||||
terminalConfiguration: TerminalConfiguration = .init()
|
||||
) {
|
||||
Self.initializeRuntimeIfNeeded()
|
||||
|
||||
baseConfigSource = configSource
|
||||
self.theme = theme
|
||||
self.terminalConfiguration = terminalConfiguration
|
||||
self.configSource = configSource
|
||||
|
||||
// Load the base config (without theme) so ghostty validates it.
|
||||
applyInitialConfig(source: configSource)
|
||||
baseConfigTemplate = renderedConfigContents
|
||||
|
||||
// Now apply theme on top and push to ghostty.
|
||||
reconfigure()
|
||||
createApp()
|
||||
}
|
||||
|
||||
// MARK: - Color Scheme
|
||||
|
||||
/// Updates the active color scheme and reconfigures the terminal.
|
||||
///
|
||||
/// Called by platform views when the OS appearance changes. This is
|
||||
/// the only method views need to call — the controller handles all
|
||||
/// config resolution internally.
|
||||
public func setColorScheme(_ scheme: TerminalColorScheme) {
|
||||
setColorScheme(scheme, willChange: nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setColorScheme(
|
||||
_ scheme: TerminalColorScheme,
|
||||
willChange: (() -> Void)?
|
||||
) -> Bool {
|
||||
let previous = effectiveColorScheme
|
||||
guard scheme != previous else {
|
||||
if let app {
|
||||
ghostty_app_set_color_scheme(app, scheme.ghosttyValue)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let resolved = resolveEffectiveConfig(colorScheme: scheme)
|
||||
guard applyResolvedConfig(
|
||||
resolved,
|
||||
willChange: willChange,
|
||||
applyState: { effectiveColorScheme = scheme }
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
|
||||
if let app {
|
||||
ghostty_app_set_color_scheme(app, scheme.ghosttyValue)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Theme
|
||||
|
||||
/// Updates the theme and reconfigures the terminal.
|
||||
@discardableResult
|
||||
public func setTheme(_ theme: TerminalTheme) -> Bool {
|
||||
setTheme(theme, willChange: nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setTheme(
|
||||
_ theme: TerminalTheme,
|
||||
willChange: (() -> Void)?
|
||||
) -> Bool {
|
||||
guard theme != self.theme else { return false }
|
||||
let resolved = resolveEffectiveConfig(theme: theme)
|
||||
return applyResolvedConfig(
|
||||
resolved,
|
||||
willChange: willChange,
|
||||
applyState: { self.theme = theme }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Terminal Configuration
|
||||
|
||||
/// Updates per-session configuration overrides and reconfigures.
|
||||
@discardableResult
|
||||
public func setTerminalConfiguration(
|
||||
_ terminalConfiguration: TerminalConfiguration
|
||||
) -> Bool {
|
||||
setTerminalConfiguration(terminalConfiguration, willChange: nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setTerminalConfiguration(
|
||||
_ terminalConfiguration: TerminalConfiguration,
|
||||
willChange: (() -> Void)?
|
||||
) -> Bool {
|
||||
guard terminalConfiguration != self.terminalConfiguration else { return false }
|
||||
let resolved = resolveEffectiveConfig(terminalConfiguration: terminalConfiguration)
|
||||
return applyResolvedConfig(
|
||||
resolved,
|
||||
willChange: willChange,
|
||||
applyState: { self.terminalConfiguration = terminalConfiguration }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Config Resolution
|
||||
|
||||
@discardableResult
|
||||
private func reconfigure() -> Bool {
|
||||
applyResolvedConfig(resolveEffectiveConfig(), willChange: nil)
|
||||
}
|
||||
|
||||
private func resolveEffectiveConfig() -> (
|
||||
source: ConfigSource, contents: String
|
||||
) {
|
||||
resolveEffectiveConfig(
|
||||
theme: theme,
|
||||
terminalConfiguration: terminalConfiguration,
|
||||
colorScheme: effectiveColorScheme
|
||||
)
|
||||
}
|
||||
|
||||
private func resolveEffectiveConfig(
|
||||
theme: TerminalTheme? = nil,
|
||||
terminalConfiguration: TerminalConfiguration? = nil,
|
||||
colorScheme: TerminalColorScheme? = nil
|
||||
) -> (source: ConfigSource, contents: String) {
|
||||
let nextTheme = theme ?? self.theme
|
||||
let nextTerminalConfiguration = terminalConfiguration ?? self.terminalConfiguration
|
||||
let nextColorScheme = colorScheme ?? effectiveColorScheme
|
||||
let themeConfig = nextTheme.configuration(for: nextColorScheme)
|
||||
if nextTerminalConfiguration.isEmpty, themeConfig.isEmpty {
|
||||
return (baseConfigSource, baseConfigTemplate)
|
||||
}
|
||||
|
||||
let contents = GhosttyConfigRenderer.render(
|
||||
baseContents: baseConfigTemplate,
|
||||
configuration: nextTerminalConfiguration,
|
||||
theme: themeConfig
|
||||
)
|
||||
return (.generated(contents), contents)
|
||||
}
|
||||
|
||||
// MARK: - Tick
|
||||
|
||||
public func tick() {
|
||||
guard let app else { return }
|
||||
ghostty_app_tick(app)
|
||||
}
|
||||
|
||||
func handleWakeup() {
|
||||
guard shouldProcessWakeup?() ?? true else {
|
||||
TerminalDebugLog.log(.lifecycle, "wakeup suspended")
|
||||
return
|
||||
}
|
||||
|
||||
tick()
|
||||
onWakeup?()
|
||||
}
|
||||
|
||||
private static func initializeRuntimeIfNeeded() {
|
||||
guard !runtimeInitialized else { return }
|
||||
runtimeInitialized = true
|
||||
ghostty_init(0, nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let app { ghostty_app_free(app) }
|
||||
if let config { ghostty_config_free(config) }
|
||||
if let managedConfigURL {
|
||||
try? FileManager.default.removeItem(at: managedConfigURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
304
vendor/libghostty-spm/Sources/GhosttyTerminal/Debug/TerminalDebugLog.swift
vendored
Normal file
304
vendor/libghostty-spm/Sources/GhosttyTerminal/Debug/TerminalDebugLog.swift
vendored
Normal file
@@ -0,0 +1,304 @@
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
public struct TerminalDebugCategory: OptionSet, Sendable {
|
||||
public let rawValue: UInt16
|
||||
|
||||
public init(rawValue: UInt16) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public static let lifecycle = Self(rawValue: 1 << 0)
|
||||
public static let metrics = Self(rawValue: 1 << 1)
|
||||
public static let input = Self(rawValue: 1 << 2)
|
||||
public static let output = Self(rawValue: 1 << 3)
|
||||
public static let ime = Self(rawValue: 1 << 4)
|
||||
public static let actions = Self(rawValue: 1 << 5)
|
||||
public static let render = Self(rawValue: 1 << 6)
|
||||
|
||||
public static let standard: Self = [
|
||||
.lifecycle,
|
||||
.metrics,
|
||||
.input,
|
||||
.output,
|
||||
.ime,
|
||||
.actions,
|
||||
]
|
||||
|
||||
public static let all: Self = [
|
||||
.standard,
|
||||
.render,
|
||||
]
|
||||
}
|
||||
|
||||
public enum TerminalDebugLog {
|
||||
public typealias Sink = @Sendable (String) -> Void
|
||||
|
||||
private struct Snapshot {
|
||||
let isEnabled: Bool
|
||||
let categories: TerminalDebugCategory
|
||||
let sink: Sink
|
||||
}
|
||||
|
||||
private final class Store: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var isEnabled = false
|
||||
var categories: TerminalDebugCategory = .standard
|
||||
var sink: Sink = { message in
|
||||
Swift.print(message)
|
||||
}
|
||||
}
|
||||
|
||||
private static let store = Store()
|
||||
|
||||
public static var isEnabled: Bool {
|
||||
get {
|
||||
withSnapshot { $0.isEnabled }
|
||||
}
|
||||
set {
|
||||
updateStore { $0.isEnabled = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
public static var categories: TerminalDebugCategory {
|
||||
get {
|
||||
withSnapshot { $0.categories }
|
||||
}
|
||||
set {
|
||||
updateStore { $0.categories = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
public static var sink: Sink {
|
||||
get {
|
||||
withSnapshot { $0.sink }
|
||||
}
|
||||
set {
|
||||
updateStore { $0.sink = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
public static func enable(_ categories: TerminalDebugCategory = .standard) {
|
||||
updateStore {
|
||||
$0.isEnabled = true
|
||||
$0.categories = categories
|
||||
}
|
||||
}
|
||||
|
||||
public static func disable() {
|
||||
updateStore { $0.isEnabled = false }
|
||||
}
|
||||
|
||||
static func log(
|
||||
_ category: TerminalDebugCategory,
|
||||
_ message: @autoclosure () -> String
|
||||
) {
|
||||
let snapshot = snapshot()
|
||||
guard snapshot.isEnabled else { return }
|
||||
guard snapshot.categories.contains(category) else { return }
|
||||
snapshot.sink(
|
||||
"[GhosttyTerminal][\(timestamp())][\(label(for: category))] \(message())"
|
||||
)
|
||||
}
|
||||
|
||||
static func describe(_ string: String?, limit: Int = 96) -> String {
|
||||
guard let string else { return "nil" }
|
||||
return "\"\(escaped(string, limit: limit))\""
|
||||
}
|
||||
|
||||
static func describe(_ data: Data, limit: Int = 48) -> String {
|
||||
let preview = data.prefix(limit)
|
||||
let text = String(decoding: preview, as: UTF8.self)
|
||||
let hex = preview.map { String(format: "%02X", $0) }.joined(separator: " ")
|
||||
let suffix = data.count > limit ? "..." : ""
|
||||
return "bytes=\(data.count) utf8=\"\(escaped(text, limit: limit))\(suffix)\" hex=\(hex)\(suffix)"
|
||||
}
|
||||
|
||||
static func describe(_ range: NSRange) -> String {
|
||||
"{location=\(range.location), length=\(range.length)}"
|
||||
}
|
||||
|
||||
static func describe(_ action: ghostty_input_action_e) -> String {
|
||||
switch action {
|
||||
case GHOSTTY_ACTION_PRESS:
|
||||
"press"
|
||||
case GHOSTTY_ACTION_RELEASE:
|
||||
"release"
|
||||
case GHOSTTY_ACTION_REPEAT:
|
||||
"repeat"
|
||||
default:
|
||||
"unknown(\(action.rawValue))"
|
||||
}
|
||||
}
|
||||
|
||||
static func describe(_ state: ghostty_input_mouse_state_e) -> String {
|
||||
switch state {
|
||||
case GHOSTTY_MOUSE_PRESS:
|
||||
"press"
|
||||
case GHOSTTY_MOUSE_RELEASE:
|
||||
"release"
|
||||
default:
|
||||
"unknown(\(state.rawValue))"
|
||||
}
|
||||
}
|
||||
|
||||
static func describe(_ tag: ghostty_action_tag_e) -> String {
|
||||
switch tag {
|
||||
case GHOSTTY_ACTION_CELL_SIZE:
|
||||
"cell_size"
|
||||
case GHOSTTY_ACTION_SET_TITLE:
|
||||
"set_title"
|
||||
case GHOSTTY_ACTION_SET_TAB_TITLE:
|
||||
"set_tab_title"
|
||||
case GHOSTTY_ACTION_RING_BELL:
|
||||
"ring_bell"
|
||||
case GHOSTTY_ACTION_RENDER:
|
||||
"render"
|
||||
case GHOSTTY_ACTION_CONFIG_CHANGE:
|
||||
"config_change"
|
||||
case GHOSTTY_ACTION_RELOAD_CONFIG:
|
||||
"reload_config"
|
||||
default:
|
||||
"tag(\(tag.rawValue))"
|
||||
}
|
||||
}
|
||||
|
||||
private static func withSnapshot<T>(
|
||||
_ body: (Snapshot) -> T
|
||||
) -> T {
|
||||
body(snapshot())
|
||||
}
|
||||
|
||||
private static func snapshot() -> Snapshot {
|
||||
store.lock.lock()
|
||||
defer { store.lock.unlock() }
|
||||
return Snapshot(
|
||||
isEnabled: store.isEnabled,
|
||||
categories: store.categories,
|
||||
sink: store.sink
|
||||
)
|
||||
}
|
||||
|
||||
private static func updateStore(
|
||||
_ body: (Store) -> Void
|
||||
) {
|
||||
store.lock.lock()
|
||||
defer { store.lock.unlock() }
|
||||
body(store)
|
||||
}
|
||||
|
||||
private static func label(
|
||||
for category: TerminalDebugCategory
|
||||
) -> String {
|
||||
switch category {
|
||||
case .lifecycle:
|
||||
"lifecycle"
|
||||
case .metrics:
|
||||
"metrics"
|
||||
case .input:
|
||||
"input"
|
||||
case .output:
|
||||
"output"
|
||||
case .ime:
|
||||
"ime"
|
||||
case .actions:
|
||||
"actions"
|
||||
case .render:
|
||||
"render"
|
||||
default:
|
||||
"debug"
|
||||
}
|
||||
}
|
||||
|
||||
private static func timestamp() -> String {
|
||||
String(format: "%.3f", Date().timeIntervalSince1970)
|
||||
}
|
||||
|
||||
private static func escaped(
|
||||
_ string: String,
|
||||
limit: Int
|
||||
) -> String {
|
||||
var result = ""
|
||||
var emitted = 0
|
||||
for scalar in string.unicodeScalars {
|
||||
guard emitted < limit else {
|
||||
result.append("...")
|
||||
break
|
||||
}
|
||||
|
||||
switch scalar.value {
|
||||
case 0x09:
|
||||
result.append("\\t")
|
||||
case 0x0A:
|
||||
result.append("\\n")
|
||||
case 0x0D:
|
||||
result.append("\\r")
|
||||
case 0x1B:
|
||||
result.append("\\e")
|
||||
case 0x20 ..< 0x7F:
|
||||
result.append(Character(scalar))
|
||||
default:
|
||||
if scalar.value < 0x20 || scalar.value == 0x7F {
|
||||
result.append(String(format: "\\u{%02X}", Int(scalar.value)))
|
||||
} else {
|
||||
result.append(Character(scalar))
|
||||
}
|
||||
}
|
||||
|
||||
emitted += 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension TerminalGridMetrics {
|
||||
var debugSummary: String {
|
||||
"cols=\(columns) rows=\(rows) pixels=\(widthPixels)x\(heightPixels) cell=\(cellWidthPixels)x\(cellHeightPixels)"
|
||||
}
|
||||
}
|
||||
|
||||
extension TerminalViewportMetrics {
|
||||
var debugSummary: String {
|
||||
"\(surfaceSize.debugSummary) scale=\(String(format: "%.2f", scale))"
|
||||
}
|
||||
}
|
||||
|
||||
extension TerminalSessionBackend {
|
||||
var debugSummary: String {
|
||||
switch self {
|
||||
case .exec:
|
||||
"exec"
|
||||
case .inMemory:
|
||||
"in-memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TerminalSurfaceOptions {
|
||||
var debugSummary: String {
|
||||
let fontSizeDescription = fontSize.map { String($0) } ?? "nil"
|
||||
return "backend=\(backend.debugSummary) fontSize=\(fontSizeDescription) workingDirectory=\(workingDirectory ?? "nil") context=\(context.debugSummary)"
|
||||
}
|
||||
}
|
||||
|
||||
extension TerminalSurfaceContext {
|
||||
var debugSummary: String {
|
||||
switch self {
|
||||
case .window:
|
||||
"window"
|
||||
case .split:
|
||||
"split"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TerminalHardwareKeyDelivery {
|
||||
var debugSummary: String {
|
||||
switch self {
|
||||
case let .ghostty(key):
|
||||
"ghostty(\(key.rawValue))"
|
||||
case let .data(data):
|
||||
"data(\(TerminalDebugLog.describe(data)))"
|
||||
}
|
||||
}
|
||||
}
|
||||
291
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/InMemoryTerminalSession.swift
vendored
Normal file
291
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/InMemoryTerminalSession.swift
vendored
Normal file
@@ -0,0 +1,291 @@
|
||||
//
|
||||
// InMemoryTerminalSession.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
public final class InMemoryTerminalSession: @unchecked Sendable {
|
||||
private static let slowSurfaceWriteThreshold: TimeInterval = 0.5
|
||||
|
||||
private let resizeLock = NSLock()
|
||||
private let surfaceAccess: InMemoryTerminalSurfaceAccess
|
||||
private var lastResize: InMemoryTerminalViewport?
|
||||
private let writeHandler: @Sendable (Data) -> Void
|
||||
private let resizeHandler: @Sendable (InMemoryTerminalViewport) -> Void
|
||||
|
||||
public init(
|
||||
write: @escaping @Sendable (Data) -> Void,
|
||||
resize: @escaping @Sendable (InMemoryTerminalViewport) -> Void
|
||||
) {
|
||||
writeHandler = write
|
||||
resizeHandler = resize
|
||||
surfaceAccess = InMemoryTerminalSurfaceAccess(
|
||||
write: Self.writeToSurface,
|
||||
processExit: Self.reportProcessExit
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
write: @escaping @Sendable (Data) -> Void,
|
||||
resize: @escaping @Sendable (InMemoryTerminalViewport) -> Void,
|
||||
surfaceWrite: @escaping InMemoryTerminalSurfaceAccess.Write,
|
||||
processExit: @escaping InMemoryTerminalSurfaceAccess.ProcessExit =
|
||||
InMemoryTerminalSession.reportProcessExit
|
||||
) {
|
||||
writeHandler = write
|
||||
resizeHandler = resize
|
||||
surfaceAccess = InMemoryTerminalSurfaceAccess(
|
||||
write: surfaceWrite,
|
||||
processExit: processExit
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Surface Lifecycle
|
||||
|
||||
func setSurface(_ surface: ghostty_surface_t?) {
|
||||
surfaceAccess.setSurface(surface)
|
||||
TerminalDebugLog.log(
|
||||
.lifecycle,
|
||||
"in-memory session surface=\(surface == nil ? "nil" : "set")"
|
||||
)
|
||||
}
|
||||
|
||||
func clearSurface(ifMatches expectedSurface: ghostty_surface_t?) {
|
||||
guard surfaceAccess.clearSurface(ifMatches: expectedSurface) else {
|
||||
TerminalDebugLog.log(
|
||||
.lifecycle,
|
||||
"in-memory session clear skipped expected=\(expectedSurface == nil ? "nil" : "set") current=\(surfaceAccess.currentSurface == nil ? "nil" : "set")"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(.lifecycle, "in-memory session surface=nil matched")
|
||||
}
|
||||
|
||||
var currentSurface: ghostty_surface_t? {
|
||||
surfaceAccess.currentSurface
|
||||
}
|
||||
|
||||
// MARK: - Viewport Read
|
||||
|
||||
/// Returns the active viewport as a UTF-8 string, or `nil` if no surface
|
||||
/// is attached. Lines are separated by `\n`. The `ghostty_text_s`
|
||||
/// lifecycle (allocate via `ghostty_surface_read_text`, free via
|
||||
/// `ghostty_surface_free_text`) is fully encapsulated — callers never
|
||||
/// touch the C buffer.
|
||||
///
|
||||
/// Selection grammar: `(VIEWPORT, TOP_LEFT)` to `(VIEWPORT, BOTTOM_RIGHT)`
|
||||
/// with `rectangle: false` (linear flow). This reads exactly the visible
|
||||
/// rows and ignores scrollback. Empty viewports return an empty string.
|
||||
///
|
||||
/// Thread-safe: keeps the surface alive for the duration of the read,
|
||||
/// preventing access against a surface mid-replacement.
|
||||
public func readViewportText() -> String? {
|
||||
surfaceAccess.withCurrentSurface { surface in
|
||||
let topLeft = ghostty_point_s(
|
||||
tag: GHOSTTY_POINT_VIEWPORT,
|
||||
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
|
||||
x: 0,
|
||||
y: 0
|
||||
)
|
||||
let bottomRight = ghostty_point_s(
|
||||
tag: GHOSTTY_POINT_VIEWPORT,
|
||||
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
|
||||
x: 0,
|
||||
y: 0
|
||||
)
|
||||
let selection = ghostty_selection_s(
|
||||
top_left: topLeft,
|
||||
bottom_right: bottomRight,
|
||||
rectangle: false
|
||||
)
|
||||
|
||||
var out = ghostty_text_s()
|
||||
guard ghostty_surface_read_text(surface, selection, &out) else {
|
||||
return nil
|
||||
}
|
||||
defer { ghostty_surface_free_text(surface, &out) }
|
||||
|
||||
guard let textPtr = out.text, out.text_len > 0 else {
|
||||
return ""
|
||||
}
|
||||
let bytes = UnsafeBufferPointer(start: textPtr, count: Int(out.text_len))
|
||||
.map { UInt8(bitPattern: $0) }
|
||||
return String(decoding: bytes, as: UTF8.self)
|
||||
} ?? nil
|
||||
}
|
||||
|
||||
func updateViewport(_ size: TerminalGridMetrics) {
|
||||
TerminalDebugLog.log(.metrics, "in-memory viewport update \(size.debugSummary)")
|
||||
dispatchResize(InMemoryTerminalViewport(
|
||||
columns: size.columns,
|
||||
rows: size.rows,
|
||||
widthPixels: size.widthPixels,
|
||||
heightPixels: size.heightPixels,
|
||||
cellWidthPixels: size.cellWidthPixels,
|
||||
cellHeightPixels: size.cellHeightPixels
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Receiving Data
|
||||
|
||||
/// Enqueue data for the terminal from the host backend.
|
||||
///
|
||||
/// Writes are processed in order on a per-session serial queue so parsing
|
||||
/// cannot block the caller or the main thread.
|
||||
public func receive(_ data: Data) {
|
||||
guard surfaceAccess.enqueueWrite(data) else {
|
||||
TerminalDebugLog.log(
|
||||
.output,
|
||||
"terminal <- host dropped \(TerminalDebugLog.describe(data))"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.output,
|
||||
"terminal <- host \(TerminalDebugLog.describe(data))"
|
||||
)
|
||||
}
|
||||
|
||||
/// Feed a UTF-8 string into the terminal from the host backend.
|
||||
public func receive(_ string: String) {
|
||||
guard let data = string.data(using: .utf8) else { return }
|
||||
receive(data)
|
||||
}
|
||||
|
||||
/// Inject input bytes directly into the host-side consumer.
|
||||
///
|
||||
/// This bypasses `ghostty_surface_key` translation and is intended for
|
||||
/// control sequences that the in-memory backend must interpret itself.
|
||||
public func sendInput(_ data: Data) {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"host <- direct input \(TerminalDebugLog.describe(data))"
|
||||
)
|
||||
writeHandler(data)
|
||||
}
|
||||
|
||||
// MARK: - Process Exit
|
||||
|
||||
/// Enqueue a host-managed process exit after all previously received data.
|
||||
public func finish(exitCode: UInt32, runtimeMilliseconds: UInt64) {
|
||||
guard surfaceAccess.enqueueProcessExit(
|
||||
exitCode: exitCode,
|
||||
runtimeMilliseconds: runtimeMilliseconds
|
||||
) else {
|
||||
TerminalDebugLog.log(
|
||||
.lifecycle,
|
||||
"process exit ignored: missing surface exitCode=\(exitCode) runtimeMs=\(runtimeMilliseconds)"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.lifecycle,
|
||||
"process exit exitCode=\(exitCode) runtimeMs=\(runtimeMilliseconds)"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - C Callbacks
|
||||
|
||||
static let receiveBufferCallback: ghostty_surface_receive_buffer_cb = { userdata, ptr, len in
|
||||
guard let userdata, let ptr else { return }
|
||||
let session = Unmanaged<InMemoryTerminalSession>
|
||||
.fromOpaque(userdata)
|
||||
.takeUnretainedValue()
|
||||
let data = Data(bytes: ptr, count: len)
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"host <- terminal \(TerminalDebugLog.describe(data))"
|
||||
)
|
||||
session.writeHandler(data)
|
||||
}
|
||||
|
||||
static let receiveResizeCallback: ghostty_surface_receive_resize_cb = { userdata, cols, rows, widthPx, heightPx in
|
||||
guard let userdata else { return }
|
||||
let session = Unmanaged<InMemoryTerminalSession>
|
||||
.fromOpaque(userdata)
|
||||
.takeUnretainedValue()
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"receive resize cols=\(cols) rows=\(rows) pixels=\(widthPx)x\(heightPx)"
|
||||
)
|
||||
session.dispatchResize(InMemoryTerminalViewport(
|
||||
columns: cols,
|
||||
rows: rows,
|
||||
widthPixels: widthPx,
|
||||
heightPixels: heightPx
|
||||
))
|
||||
}
|
||||
|
||||
private func dispatchResize(_ resize: InMemoryTerminalViewport) {
|
||||
resizeLock.lock()
|
||||
let mergedResize = mergedResize(resize)
|
||||
guard mergedResize != lastResize else {
|
||||
resizeLock.unlock()
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"resize unchanged cols=\(mergedResize.columns) rows=\(mergedResize.rows) pixels=\(mergedResize.widthPixels)x\(mergedResize.heightPixels) cell=\(mergedResize.cellWidthPixels)x\(mergedResize.cellHeightPixels)"
|
||||
)
|
||||
return
|
||||
}
|
||||
lastResize = mergedResize
|
||||
resizeLock.unlock()
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"resize dispatched cols=\(mergedResize.columns) rows=\(mergedResize.rows) pixels=\(mergedResize.widthPixels)x\(mergedResize.heightPixels) cell=\(mergedResize.cellWidthPixels)x\(mergedResize.cellHeightPixels)"
|
||||
)
|
||||
resizeHandler(mergedResize)
|
||||
}
|
||||
|
||||
private func mergedResize(_ resize: InMemoryTerminalViewport) -> InMemoryTerminalViewport {
|
||||
guard let lastResize else { return resize }
|
||||
|
||||
return InMemoryTerminalViewport(
|
||||
columns: resize.columns,
|
||||
rows: resize.rows,
|
||||
widthPixels: resize.widthPixels == 0 ? lastResize.widthPixels : resize.widthPixels,
|
||||
heightPixels: resize.heightPixels == 0 ? lastResize.heightPixels : resize.heightPixels,
|
||||
cellWidthPixels: resize.cellWidthPixels == 0 ? lastResize.cellWidthPixels : resize.cellWidthPixels,
|
||||
cellHeightPixels: resize.cellHeightPixels == 0 ? lastResize.cellHeightPixels : resize.cellHeightPixels
|
||||
)
|
||||
}
|
||||
|
||||
func waitForPendingOutput() {
|
||||
surfaceAccess.waitForPendingOutput()
|
||||
}
|
||||
|
||||
private static func writeToSurface(_ surface: ghostty_surface_t, _ data: Data) {
|
||||
let start = ProcessInfo.processInfo.systemUptime
|
||||
defer {
|
||||
let duration = ProcessInfo.processInfo.systemUptime - start
|
||||
if duration >= slowSurfaceWriteThreshold {
|
||||
TerminalDebugLog.log(
|
||||
.output,
|
||||
"surface write slow bytes=\(data.count) duration=\(String(format: "%.3f", duration))s"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data.withUnsafeBytes { buffer in
|
||||
guard let ptr = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||
return
|
||||
}
|
||||
ghostty_surface_write_buffer(surface, ptr, UInt(buffer.count))
|
||||
}
|
||||
}
|
||||
|
||||
private static func reportProcessExit(
|
||||
_ surface: ghostty_surface_t,
|
||||
_ exitCode: UInt32,
|
||||
_ runtimeMilliseconds: UInt64
|
||||
) {
|
||||
ghostty_surface_process_exit(surface, exitCode, runtimeMilliseconds)
|
||||
}
|
||||
}
|
||||
141
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/InMemoryTerminalSurfaceAccess.swift
vendored
Normal file
141
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/InMemoryTerminalSurfaceAccess.swift
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
/// Serializes host output while keeping the raw surface alive for each C call.
|
||||
final class InMemoryTerminalSurfaceAccess: @unchecked Sendable {
|
||||
typealias Write = @Sendable (ghostty_surface_t, Data) -> Void
|
||||
typealias ProcessExit = @Sendable (ghostty_surface_t, UInt32, UInt64) -> Void
|
||||
|
||||
private let condition = NSCondition()
|
||||
private let outputQueue = DispatchQueue(
|
||||
label: "com.lakr233.libghostty-spm.in-memory-output",
|
||||
qos: .userInitiated
|
||||
)
|
||||
private let write: Write
|
||||
private let processExit: ProcessExit
|
||||
|
||||
private var surface: ghostty_surface_t?
|
||||
/// Invalidates work that was enqueued for a surface that has been replaced.
|
||||
private var generation: UInt64 = 0
|
||||
/// Prevents the caller from freeing a surface while a C operation uses it.
|
||||
private var activeOperations = 0
|
||||
|
||||
init(
|
||||
write: @escaping Write,
|
||||
processExit: @escaping ProcessExit
|
||||
) {
|
||||
self.write = write
|
||||
self.processExit = processExit
|
||||
}
|
||||
|
||||
func setSurface(_ surface: ghostty_surface_t?) {
|
||||
condition.lock()
|
||||
generation &+= 1
|
||||
self.surface = nil
|
||||
waitForActiveOperations()
|
||||
self.surface = surface
|
||||
condition.unlock()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func clearSurface(ifMatches expectedSurface: ghostty_surface_t?) -> Bool {
|
||||
condition.lock()
|
||||
guard surface == expectedSurface else {
|
||||
condition.unlock()
|
||||
return false
|
||||
}
|
||||
|
||||
generation &+= 1
|
||||
surface = nil
|
||||
waitForActiveOperations()
|
||||
condition.unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
var currentSurface: ghostty_surface_t? {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return surface
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func enqueueWrite(_ data: Data) -> Bool {
|
||||
guard let generation = currentGeneration else { return false }
|
||||
outputQueue.async { [self] in
|
||||
withSurface(generation: generation) { surface in
|
||||
write(surface, data)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func enqueueProcessExit(
|
||||
exitCode: UInt32,
|
||||
runtimeMilliseconds: UInt64
|
||||
) -> Bool {
|
||||
guard let generation = currentGeneration else { return false }
|
||||
outputQueue.async { [self] in
|
||||
withSurface(generation: generation) { surface in
|
||||
processExit(surface, exitCode, runtimeMilliseconds)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func withCurrentSurface<Result>(
|
||||
_ operation: (ghostty_surface_t) -> Result
|
||||
) -> Result? {
|
||||
condition.lock()
|
||||
guard let surface else {
|
||||
condition.unlock()
|
||||
return nil
|
||||
}
|
||||
activeOperations += 1
|
||||
condition.unlock()
|
||||
|
||||
defer { finishOperation() }
|
||||
return operation(surface)
|
||||
}
|
||||
|
||||
func waitForPendingOutput() {
|
||||
outputQueue.sync {}
|
||||
}
|
||||
|
||||
private var currentGeneration: UInt64? {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return surface == nil ? nil : generation
|
||||
}
|
||||
|
||||
private func withSurface(
|
||||
generation expectedGeneration: UInt64,
|
||||
_ operation: (ghostty_surface_t) -> Void
|
||||
) {
|
||||
condition.lock()
|
||||
guard generation == expectedGeneration, let surface else {
|
||||
condition.unlock()
|
||||
return
|
||||
}
|
||||
activeOperations += 1
|
||||
condition.unlock()
|
||||
|
||||
defer { finishOperation() }
|
||||
operation(surface)
|
||||
}
|
||||
|
||||
private func finishOperation() {
|
||||
condition.lock()
|
||||
activeOperations -= 1
|
||||
if activeOperations == 0 {
|
||||
condition.broadcast()
|
||||
}
|
||||
condition.unlock()
|
||||
}
|
||||
|
||||
private func waitForActiveOperations() {
|
||||
while activeOperations > 0 {
|
||||
condition.wait()
|
||||
}
|
||||
}
|
||||
}
|
||||
31
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/InMemoryTerminalViewport.swift
vendored
Normal file
31
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/InMemoryTerminalViewport.swift
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// InMemoryTerminalViewport.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
public struct InMemoryTerminalViewport: Sendable, Equatable {
|
||||
public var columns: UInt16
|
||||
public var rows: UInt16
|
||||
public var widthPixels: UInt32
|
||||
public var heightPixels: UInt32
|
||||
public var cellWidthPixels: UInt32
|
||||
public var cellHeightPixels: UInt32
|
||||
|
||||
public init(
|
||||
columns: UInt16,
|
||||
rows: UInt16,
|
||||
widthPixels: UInt32 = 0,
|
||||
heightPixels: UInt32 = 0,
|
||||
cellWidthPixels: UInt32 = 0,
|
||||
cellHeightPixels: UInt32 = 0
|
||||
) {
|
||||
self.columns = columns
|
||||
self.rows = rows
|
||||
self.widthPixels = widthPixels
|
||||
self.heightPixels = heightPixels
|
||||
self.cellWidthPixels = cellWidthPixels
|
||||
self.cellHeightPixels = cellHeightPixels
|
||||
}
|
||||
}
|
||||
176
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/TerminalCallbackBridge.swift
vendored
Normal file
176
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/TerminalCallbackBridge.swift
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// TerminalCallbackBridge.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
/// Dispatches C runtime callbacks to a ``TerminalSurfaceViewDelegate``.
|
||||
///
|
||||
/// An instance of this class is passed as the `userdata` pointer in the
|
||||
/// surface config so that Ghostty callbacks can route actions back to
|
||||
/// the owning view.
|
||||
@MainActor
|
||||
final class TerminalCallbackBridge {
|
||||
weak var delegate: (any TerminalSurfaceViewDelegate)?
|
||||
/// Raw surface pointer for use in C callbacks (e.g. clipboard).
|
||||
nonisolated(unsafe) var rawSurface: ghostty_surface_t?
|
||||
var onCellSizeChange: ((UInt32, UInt32) -> Void)?
|
||||
var onRenderRequest: (() -> Void)?
|
||||
|
||||
init(delegate: (any TerminalSurfaceViewDelegate)? = nil) {
|
||||
self.delegate = delegate
|
||||
}
|
||||
|
||||
func handleAction(_ action: ghostty_action_s) {
|
||||
switch action.tag {
|
||||
case GHOSTTY_ACTION_SET_TITLE:
|
||||
if let cStr = action.action.set_title.title {
|
||||
let title = String(cString: cStr)
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=set_title title=\(TerminalDebugLog.describe(title))"
|
||||
)
|
||||
(delegate as? any TerminalSurfaceTitleDelegate)?
|
||||
.terminalDidChangeTitle(title)
|
||||
}
|
||||
|
||||
case GHOSTTY_ACTION_CELL_SIZE:
|
||||
let cellSize = action.action.cell_size
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=cell_size width=\(cellSize.width) height=\(cellSize.height)"
|
||||
)
|
||||
onCellSizeChange?(cellSize.width, cellSize.height)
|
||||
|
||||
case GHOSTTY_ACTION_RING_BELL:
|
||||
TerminalDebugLog.log(.actions, "callback action=ring_bell")
|
||||
(delegate as? any TerminalSurfaceBellDelegate)?
|
||||
.terminalDidRingBell()
|
||||
|
||||
case GHOSTTY_ACTION_RENDER:
|
||||
TerminalDebugLog.log(.render, "callback action=render")
|
||||
onRenderRequest?()
|
||||
|
||||
case GHOSTTY_ACTION_CONFIG_CHANGE:
|
||||
// Colors/theme may have changed (e.g. on system appearance
|
||||
// toggle). Ghostty applies the new config internally but won't
|
||||
// repaint until the next frame — request one so the refreshed
|
||||
// theme is visible without waiting for input or layout.
|
||||
TerminalDebugLog.log(.actions, "callback action=config_change")
|
||||
onRenderRequest?()
|
||||
|
||||
case GHOSTTY_ACTION_PROGRESS_REPORT:
|
||||
let report = action.action.progress_report
|
||||
let state = TerminalProgressState(report.state) ?? .set
|
||||
// int8_t -1 signals "no progress provided" — surface as nil.
|
||||
let percent: Int? = report.progress < 0 ? nil : Int(report.progress)
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=progress_report state=\(state) percent=\(percent.map { "\($0)" } ?? "nil")"
|
||||
)
|
||||
(delegate as? any TerminalSurfaceProgressReportDelegate)?
|
||||
.terminalDidReportProgress(state: state, percent: percent)
|
||||
|
||||
case GHOSTTY_ACTION_COMMAND_FINISHED:
|
||||
let finished = action.action.command_finished
|
||||
// int16_t -1 signals unknown exit code.
|
||||
let exit: Int? = finished.exit_code < 0 ? nil : Int(finished.exit_code)
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=command_finished exit=\(exit.map { "\($0)" } ?? "nil") duration_ns=\(finished.duration)"
|
||||
)
|
||||
(delegate as? any TerminalSurfaceCommandFinishedDelegate)?
|
||||
.terminalDidFinishCommand(
|
||||
exitCode: exit,
|
||||
durationNanos: finished.duration
|
||||
)
|
||||
|
||||
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
|
||||
let payload = action.action.desktop_notification
|
||||
let title = payload.title.map { String(cString: $0) } ?? ""
|
||||
let body = payload.body.map { String(cString: $0) } ?? ""
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=desktop_notification title=\(TerminalDebugLog.describe(title)) body=\(TerminalDebugLog.describe(body))"
|
||||
)
|
||||
(delegate as? any TerminalSurfaceDesktopNotificationDelegate)?
|
||||
.terminalDidRequestDesktopNotification(title: title, body: body)
|
||||
|
||||
case GHOSTTY_ACTION_OPEN_URL:
|
||||
let payload = action.action.open_url
|
||||
let kind = TerminalOpenURLKind(payload.kind)
|
||||
let url: String = payload.url.map { ptr in
|
||||
// Ghostty provides a length-prefixed string; respect the
|
||||
// documented length rather than trusting a NUL terminator.
|
||||
let buf = UnsafeBufferPointer(start: ptr, count: Int(payload.len))
|
||||
return String(decoding: buf.map(UInt8.init), as: UTF8.self)
|
||||
} ?? ""
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=open_url kind=\(kind) url=\(TerminalDebugLog.describe(url))"
|
||||
)
|
||||
(delegate as? any TerminalSurfaceOpenURLDelegate)?
|
||||
.terminalDidRequestOpenURL(url, kind: kind)
|
||||
|
||||
case GHOSTTY_ACTION_MOUSE_OVER_LINK:
|
||||
let payload = action.action.mouse_over_link
|
||||
let url: String? = {
|
||||
guard let ptr = payload.url, payload.len > 0 else { return nil }
|
||||
let buf = UnsafeBufferPointer(start: ptr, count: Int(payload.len))
|
||||
return String(decoding: buf.map(UInt8.init), as: UTF8.self)
|
||||
}()
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=mouse_over_link url=\(url.map { TerminalDebugLog.describe($0) } ?? "nil")"
|
||||
)
|
||||
(delegate as? any TerminalSurfaceHoverLinkDelegate)?
|
||||
.terminalDidUpdateHoverLink(url)
|
||||
|
||||
case GHOSTTY_ACTION_PWD:
|
||||
let payload = action.action.pwd
|
||||
if let cStr = payload.pwd {
|
||||
let pwd = String(cString: cStr)
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=pwd pwd=\(TerminalDebugLog.describe(pwd))"
|
||||
)
|
||||
(delegate as? any TerminalSurfacePwdDelegate)?
|
||||
.terminalDidChangeWorkingDirectory(pwd)
|
||||
}
|
||||
|
||||
case GHOSTTY_ACTION_SCROLLBAR:
|
||||
let payload = action.action.scrollbar
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=scrollbar total=\(payload.total) offset=\(payload.offset) len=\(payload.len)"
|
||||
)
|
||||
(delegate as? any TerminalSurfaceScrollbarDelegate)?
|
||||
.terminalDidUpdateScrollbar(
|
||||
TerminalScrollbar(
|
||||
total: payload.total,
|
||||
offset: payload.offset,
|
||||
len: payload.len
|
||||
)
|
||||
)
|
||||
|
||||
default:
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"callback action=\(TerminalDebugLog.describe(action.tag))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func handleClose(processAlive: Bool) {
|
||||
TerminalDebugLog.log(
|
||||
.lifecycle,
|
||||
"callback close processAlive=\(processAlive)"
|
||||
)
|
||||
(delegate as? any TerminalSurfaceCloseDelegate)?
|
||||
.terminalDidClose(processAlive: processAlive)
|
||||
}
|
||||
}
|
||||
22
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/TerminalSessionBackend.swift
vendored
Normal file
22
vendor/libghostty-spm/Sources/GhosttyTerminal/InMemory/TerminalSessionBackend.swift
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// TerminalSessionBackend.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
public enum TerminalSessionBackend: Sendable {
|
||||
case exec
|
||||
case inMemory(InMemoryTerminalSession)
|
||||
|
||||
func isEquivalent(to other: TerminalSessionBackend) -> Bool {
|
||||
switch (self, other) {
|
||||
case (.exec, .exec):
|
||||
true
|
||||
case let (.inMemory(lhs), .inMemory(rhs)):
|
||||
lhs === rhs
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
44
vendor/libghostty-spm/Sources/GhosttyTerminal/Metrics/TerminalGridMetrics.swift
vendored
Normal file
44
vendor/libghostty-spm/Sources/GhosttyTerminal/Metrics/TerminalGridMetrics.swift
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// TerminalGridMetrics.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import GhosttyKit
|
||||
|
||||
public struct TerminalGridMetrics: Sendable, Equatable {
|
||||
public var columns: UInt16
|
||||
public var rows: UInt16
|
||||
public var widthPixels: UInt32
|
||||
public var heightPixels: UInt32
|
||||
public var cellWidthPixels: UInt32
|
||||
public var cellHeightPixels: UInt32
|
||||
|
||||
public init(
|
||||
columns: UInt16,
|
||||
rows: UInt16,
|
||||
widthPixels: UInt32,
|
||||
heightPixels: UInt32,
|
||||
cellWidthPixels: UInt32,
|
||||
cellHeightPixels: UInt32
|
||||
) {
|
||||
self.columns = columns
|
||||
self.rows = rows
|
||||
self.widthPixels = widthPixels
|
||||
self.heightPixels = heightPixels
|
||||
self.cellWidthPixels = cellWidthPixels
|
||||
self.cellHeightPixels = cellHeightPixels
|
||||
}
|
||||
|
||||
init(_ rawValue: ghostty_surface_size_s) {
|
||||
self.init(
|
||||
columns: rawValue.columns,
|
||||
rows: rawValue.rows,
|
||||
widthPixels: rawValue.width_px,
|
||||
heightPixels: rawValue.height_px,
|
||||
cellWidthPixels: rawValue.cell_width_px,
|
||||
cellHeightPixels: rawValue.cell_height_px
|
||||
)
|
||||
}
|
||||
}
|
||||
62
vendor/libghostty-spm/Sources/GhosttyTerminal/Metrics/TerminalInputModifiers.swift
vendored
Normal file
62
vendor/libghostty-spm/Sources/GhosttyTerminal/Metrics/TerminalInputModifiers.swift
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// TerminalInputModifiers.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import GhosttyKit
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public struct TerminalInputModifiers: OptionSet, Sendable {
|
||||
public let rawValue: UInt32
|
||||
|
||||
public init(rawValue: UInt32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public static let shift = TerminalInputModifiers(rawValue: 1 << 0)
|
||||
public static let ctrl = TerminalInputModifiers(rawValue: 1 << 1)
|
||||
public static let alt = TerminalInputModifiers(rawValue: 1 << 2)
|
||||
public static let super_ = TerminalInputModifiers(rawValue: 1 << 3)
|
||||
public static let caps = TerminalInputModifiers(rawValue: 1 << 4)
|
||||
public static let num = TerminalInputModifiers(rawValue: 1 << 5)
|
||||
public static let shiftRight = TerminalInputModifiers(rawValue: 1 << 6)
|
||||
public static let ctrlRight = TerminalInputModifiers(rawValue: 1 << 7)
|
||||
public static let altRight = TerminalInputModifiers(rawValue: 1 << 8)
|
||||
public static let superRight = TerminalInputModifiers(rawValue: 1 << 9)
|
||||
|
||||
public var ghosttyMods: ghostty_input_mods_e {
|
||||
ghostty_input_mods_e(rawValue)
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
public init(from flags: UIKeyModifierFlags) {
|
||||
var mods = TerminalInputModifiers()
|
||||
if flags.contains(.shift) { mods.insert(.shift) }
|
||||
if flags.contains(.control) { mods.insert(.ctrl) }
|
||||
if flags.contains(.alternate) { mods.insert(.alt) }
|
||||
if flags.contains(.command) { mods.insert(.super_) }
|
||||
if flags.contains(.alphaShift) { mods.insert(.caps) }
|
||||
if flags.contains(.numericPad) { mods.insert(.num) }
|
||||
self = mods
|
||||
}
|
||||
|
||||
#elseif canImport(AppKit)
|
||||
public init(from flags: NSEvent.ModifierFlags) {
|
||||
var mods = TerminalInputModifiers()
|
||||
if flags.contains(.shift) { mods.insert(.shift) }
|
||||
if flags.contains(.control) { mods.insert(.ctrl) }
|
||||
if flags.contains(.option) { mods.insert(.alt) }
|
||||
if flags.contains(.command) { mods.insert(.super_) }
|
||||
if flags.contains(.capsLock) { mods.insert(.caps) }
|
||||
if flags.contains(.numericPad) { mods.insert(.num) }
|
||||
self = mods
|
||||
}
|
||||
#endif
|
||||
}
|
||||
51
vendor/libghostty-spm/Sources/GhosttyTerminal/Metrics/TerminalScrollModifiers.swift
vendored
Normal file
51
vendor/libghostty-spm/Sources/GhosttyTerminal/Metrics/TerminalScrollModifiers.swift
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// TerminalScrollModifiers.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import GhosttyKit
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
public struct TerminalScrollModifiers: Sendable {
|
||||
public let rawValue: ghostty_input_scroll_mods_t
|
||||
|
||||
public init(rawValue: ghostty_input_scroll_mods_t = 0) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public init(precision: Bool, momentum: Momentum = .none) {
|
||||
var value: Int32 = 0
|
||||
if precision { value |= 1 }
|
||||
value |= momentum.rawValue << 1
|
||||
rawValue = value
|
||||
}
|
||||
|
||||
public var precision: Bool {
|
||||
(rawValue & 1) != 0
|
||||
}
|
||||
|
||||
public var momentum: Momentum {
|
||||
Momentum(rawValue: (rawValue >> 1) & 0x3) ?? .none
|
||||
}
|
||||
|
||||
public enum Momentum: Int32, Sendable {
|
||||
case none = 0
|
||||
case began = 1
|
||||
case stationary = 2
|
||||
case changed = 3
|
||||
}
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
static func momentumFrom(phase: NSEvent.Phase) -> Momentum {
|
||||
if phase.contains(.began) { return .began }
|
||||
if phase.contains(.stationary) { return .stationary }
|
||||
if phase.contains(.changed) { return .changed }
|
||||
return .none
|
||||
}
|
||||
#endif
|
||||
}
|
||||
13
vendor/libghostty-spm/Sources/GhosttyTerminal/Metrics/TerminalViewportMetrics.swift
vendored
Normal file
13
vendor/libghostty-spm/Sources/GhosttyTerminal/Metrics/TerminalViewportMetrics.swift
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// TerminalViewportMetrics.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct TerminalViewportMetrics: Equatable {
|
||||
var surfaceSize: TerminalGridMetrics
|
||||
var scale: Double
|
||||
}
|
||||
295
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Input.swift
vendored
Normal file
295
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Input.swift
vendored
Normal file
@@ -0,0 +1,295 @@
|
||||
//
|
||||
// AppTerminalView+Input.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
extension AppTerminalView {
|
||||
override open func keyDown(with event: NSEvent) {
|
||||
inputHandler?.handleKeyDown(with: event)
|
||||
}
|
||||
|
||||
override open func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
guard event.type == .keyDown else { return false }
|
||||
guard window?.firstResponder === self else { return false }
|
||||
guard let surface else { return false }
|
||||
|
||||
if keyIsBinding(event, on: surface) {
|
||||
keyDown(with: event)
|
||||
return true
|
||||
}
|
||||
|
||||
let equivalent: String
|
||||
switch event.charactersIgnoringModifiers {
|
||||
case "\r":
|
||||
guard event.modifierFlags.contains(.control) else {
|
||||
return false
|
||||
}
|
||||
equivalent = "\r"
|
||||
|
||||
case "/":
|
||||
guard event.modifierFlags.contains(.control),
|
||||
event.modifierFlags.isDisjoint(with: [.shift, .command, .option])
|
||||
else {
|
||||
return false
|
||||
}
|
||||
equivalent = "_"
|
||||
|
||||
default:
|
||||
if event.timestamp == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if !event.modifierFlags.contains(.command),
|
||||
!event.modifierFlags.contains(.control)
|
||||
{
|
||||
lastPerformKeyEvent = nil
|
||||
return false
|
||||
}
|
||||
|
||||
if let lastPerformKeyEvent,
|
||||
lastPerformKeyEvent == event.timestamp
|
||||
{
|
||||
self.lastPerformKeyEvent = nil
|
||||
equivalent = event.characters ?? ""
|
||||
break
|
||||
}
|
||||
|
||||
lastPerformKeyEvent = event.timestamp
|
||||
return false
|
||||
}
|
||||
|
||||
guard let translatedEvent = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: event.locationInWindow,
|
||||
modifierFlags: event.modifierFlags,
|
||||
timestamp: event.timestamp,
|
||||
windowNumber: event.windowNumber,
|
||||
context: nil,
|
||||
characters: equivalent,
|
||||
charactersIgnoringModifiers: equivalent,
|
||||
isARepeat: event.isARepeat,
|
||||
keyCode: event.keyCode
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
|
||||
keyDown(with: translatedEvent)
|
||||
return true
|
||||
}
|
||||
|
||||
override open func keyUp(with event: NSEvent) {
|
||||
inputHandler?.handleKeyUp(with: event)
|
||||
}
|
||||
|
||||
override open func flagsChanged(with event: NSEvent) {
|
||||
inputHandler?.handleFlagsChanged(with: event)
|
||||
}
|
||||
|
||||
override open func doCommand(by selector: Selector) {
|
||||
if let lastPerformKeyEvent,
|
||||
let current = NSApp.currentEvent,
|
||||
lastPerformKeyEvent == current.timestamp
|
||||
{
|
||||
NSApp.sendEvent(current)
|
||||
return
|
||||
}
|
||||
|
||||
if TerminalKeyEventHandler.shouldReplayInterpretedCommand(selector) {
|
||||
inputHandler?.recordInterpretedCommand(selector)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction open func copy(_: Any?) {
|
||||
_ = copySelectedTextToPasteboard()
|
||||
}
|
||||
|
||||
@IBAction func paste(_: Any?) {
|
||||
if let text = NSPasteboard.general.string(forType: .string) {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"paste binding bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text))"
|
||||
)
|
||||
}
|
||||
_ = surface?.performBindingAction("paste_from_clipboard")
|
||||
}
|
||||
|
||||
@IBAction override open func selectAll(_: Any?) {
|
||||
_ = surface?.performBindingAction("select_all")
|
||||
}
|
||||
|
||||
internal func mousePoint(from event: NSEvent) -> (x: CGFloat, y: CGFloat) {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
return (point.x, bounds.height - point.y)
|
||||
}
|
||||
|
||||
override open func mouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
pointerSelectionStartPoint = CGPoint(x: x, y: y)
|
||||
pendingSelectionMenuPoint = nil
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_LEFT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func mouseUp(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_LEFT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
finishPointerSelection(at: CGPoint(x: x, y: y))
|
||||
}
|
||||
|
||||
override open func rightMouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
if let menuPoint = selectionMenuPoint(at: CGPoint(x: x, y: y)) {
|
||||
pendingSelectionMenuPoint = menuPoint
|
||||
return
|
||||
}
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_RIGHT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func rightMouseUp(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
if pendingSelectionMenuPoint != nil {
|
||||
pendingSelectionMenuPoint = nil
|
||||
showSelectionCopyMenu(with: event)
|
||||
return
|
||||
}
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_RIGHT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func menu(for event: NSEvent) -> NSMenu? {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
guard selectionMenuPoint(at: CGPoint(x: x, y: y)) != nil else {
|
||||
return super.menu(for: event)
|
||||
}
|
||||
return selectionContextMenu()
|
||||
}
|
||||
|
||||
override open func otherMouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_MIDDLE,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func otherMouseUp(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_MIDDLE,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func mouseMoved(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
}
|
||||
|
||||
override open func mouseDragged(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
updatePointerSelectionRect(to: CGPoint(x: x, y: y))
|
||||
mouseMoved(with: event)
|
||||
}
|
||||
|
||||
override open func rightMouseDragged(with event: NSEvent) {
|
||||
mouseMoved(with: event)
|
||||
}
|
||||
|
||||
override open func otherMouseDragged(with event: NSEvent) {
|
||||
mouseMoved(with: event)
|
||||
}
|
||||
|
||||
override open func scrollWheel(with event: NSEvent) {
|
||||
let scrollMods = TerminalScrollModifiers(
|
||||
precision: event.hasPreciseScrollingDeltas,
|
||||
momentum: TerminalScrollModifiers.momentumFrom(phase: event.momentumPhase)
|
||||
)
|
||||
surface?.sendMouseScroll(
|
||||
x: event.scrollingDeltaX,
|
||||
y: event.scrollingDeltaY,
|
||||
mods: scrollMods.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
private func updatePointerSelectionRect(to point: CGPoint) {
|
||||
guard 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)
|
||||
}
|
||||
|
||||
private 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)
|
||||
}
|
||||
}
|
||||
|
||||
private func showSelectionCopyMenu(with event: NSEvent) {
|
||||
let menu = selectionContextMenu()
|
||||
NSMenu.popUpContextMenu(menu, with: event, for: self)
|
||||
}
|
||||
|
||||
private func keyIsBinding(
|
||||
_ event: NSEvent,
|
||||
on surface: TerminalSurface
|
||||
) -> Bool {
|
||||
guard let rawSurface = surface.rawValue else {
|
||||
return false
|
||||
}
|
||||
|
||||
var keyEvent = event.buildKeyInput(action: GHOSTTY_ACTION_PRESS)
|
||||
var bindingFlags = ghostty_binding_flags_e(rawValue: 0)
|
||||
let text = event.characters ?? ""
|
||||
return text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
return ghostty_surface_key_is_binding(rawSurface, keyEvent, &bindingFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
230
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Lifecycle.swift
vendored
Normal file
230
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Lifecycle.swift
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
//
|
||||
// AppTerminalView+Lifecycle.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
|
||||
extension AppTerminalView {
|
||||
func setupTrackingArea() {
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.mouseMoved,
|
||||
.inVisibleRect,
|
||||
.activeAlways,
|
||||
]
|
||||
let area = NSTrackingArea(
|
||||
rect: bounds,
|
||||
options: options,
|
||||
owner: self,
|
||||
userInfo: nil
|
||||
)
|
||||
addTrackingArea(area)
|
||||
}
|
||||
|
||||
override open func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
trackingAreas.forEach { removeTrackingArea($0) }
|
||||
setupTrackingArea()
|
||||
}
|
||||
|
||||
override open var acceptsFirstResponder: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override open func becomeFirstResponder() -> Bool {
|
||||
let result = super.becomeFirstResponder()
|
||||
core.setFocus(true)
|
||||
onFocusChange?(true)
|
||||
return result
|
||||
}
|
||||
|
||||
override open func resignFirstResponder() -> Bool {
|
||||
let result = super.resignFirstResponder()
|
||||
core.setFocus(false)
|
||||
onFocusChange?(false)
|
||||
return result
|
||||
}
|
||||
|
||||
override open func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
removeWindowObservers()
|
||||
if window != nil {
|
||||
// SwiftUI/AppKit can temporarily detach and reattach the terminal view while
|
||||
// diffing the view hierarchy. Rebuilding on every reattach discards Ghostty's
|
||||
// scrollback/state, so only create a new surface when one does not already exist.
|
||||
if surface == nil {
|
||||
core.rebuildIfReady()
|
||||
} else {
|
||||
core.synchronizeMetrics()
|
||||
}
|
||||
updateMetalLayerMetrics()
|
||||
updateColorScheme()
|
||||
core.startDisplayLink()
|
||||
core.requestImmediateTick()
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidBecomeKey),
|
||||
name: NSWindow.didBecomeKeyNotification,
|
||||
object: window
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidResignKey),
|
||||
name: NSWindow.didResignKeyNotification,
|
||||
object: window
|
||||
)
|
||||
// Cross-display rescue: AppKit posts didChangeScreen when the
|
||||
// window's screen reference changes, even when the new screen
|
||||
// has the same backingScaleFactor (in which case
|
||||
// viewDidChangeBackingProperties does not fire). Listening
|
||||
// here lets us re-run metric sync on every screen transition
|
||||
// — required for the case where two displays share scale but
|
||||
// differ in geometry / color profile, and harmless when
|
||||
// viewDidChangeBackingProperties also fires for the
|
||||
// different-scale case.
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidChangeScreen),
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: window
|
||||
)
|
||||
} else {
|
||||
core.stopDisplayLink()
|
||||
core.setFocus(false)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func windowDidBecomeKey(_: Notification) {
|
||||
let focused = window?.isKeyWindow == true
|
||||
&& window?.firstResponder === self
|
||||
core.setFocus(focused)
|
||||
onFocusChange?(focused)
|
||||
}
|
||||
|
||||
@objc func windowDidResignKey(_: Notification) {
|
||||
core.setFocus(false)
|
||||
onFocusChange?(false)
|
||||
}
|
||||
|
||||
@objc func windowDidChangeScreen(_: Notification) {
|
||||
// Defer one runloop tick so AppKit's layout pass and the
|
||||
// window's new backingScaleFactor have both settled before we
|
||||
// re-derive metrics. Calling synchronously can race with the
|
||||
// layout pass and re-introduce the drift we're trying to fix.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
updateMetalLayerMetrics()
|
||||
core.synchronizeMetrics()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
}
|
||||
|
||||
private func removeWindowObservers() {
|
||||
// Remove any existing key-window observers before registering for the
|
||||
// current window. AppKit can move the view directly between windows
|
||||
// without an intermediate nil attachment.
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.didBecomeKeyNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.didResignKeyNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override open func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
core.fitToSize()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
|
||||
override open func layout() {
|
||||
super.layout()
|
||||
core.fitToSize()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
|
||||
override open func viewDidChangeBackingProperties() {
|
||||
super.viewDidChangeBackingProperties()
|
||||
updateMetalLayerMetrics()
|
||||
core.fitToSize()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
|
||||
public func fitToSize() {
|
||||
core.fitToSize()
|
||||
}
|
||||
|
||||
func updateMetalLayerMetrics() {
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
let scale = core.scaleFactor()
|
||||
// Write to the actually-attached backing layer (not just the
|
||||
// cached `metalLayer` ivar). The render pipeline can swap
|
||||
// `self.layer` to an IOSurfaceLayer for IOSurface-backed
|
||||
// compositing; once that happens the cached CAMetalLayer
|
||||
// reference is detached from the view tree and writes to its
|
||||
// contentsScale are no-ops as far as what's visible. The
|
||||
// observable symptom is text rendered at half size after the
|
||||
// window crosses to a display with a different
|
||||
// backingScaleFactor.
|
||||
layer?.contentsScale = scale
|
||||
if let metal = layer as? CAMetalLayer {
|
||||
metal.drawableSize = CGSize(
|
||||
width: bounds.width * scale,
|
||||
height: bounds.height * scale
|
||||
)
|
||||
}
|
||||
// Mirror to the cached ivar in case anything else still
|
||||
// reads through it during a transitional layout pass.
|
||||
metalLayer?.contentsScale = scale
|
||||
metalLayer?.drawableSize = CGSize(
|
||||
width: bounds.width * scale,
|
||||
height: bounds.height * scale
|
||||
)
|
||||
}
|
||||
|
||||
func enforceMetalLayerScale() {
|
||||
let scale = core.scaleFactor()
|
||||
if let layer, layer.contentsScale != scale {
|
||||
layer.contentsScale = scale
|
||||
}
|
||||
if let metalLayer, metalLayer.contentsScale != scale {
|
||||
metalLayer.contentsScale = scale
|
||||
}
|
||||
}
|
||||
|
||||
override open func viewDidChangeEffectiveAppearance() {
|
||||
super.viewDidChangeEffectiveAppearance()
|
||||
updateColorScheme()
|
||||
}
|
||||
|
||||
func updateColorScheme() {
|
||||
let scheme: TerminalColorScheme = switch effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) {
|
||||
case .darkAqua: .dark
|
||||
default: .light
|
||||
}
|
||||
surface?.setColorScheme(scheme.ghosttyValue)
|
||||
if let controller,
|
||||
let viewState = delegate as? TerminalViewState,
|
||||
viewState.controller === controller
|
||||
{
|
||||
viewState.adopt(terminalColorScheme: scheme)
|
||||
} else {
|
||||
controller?.setColorScheme(scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// AppTerminalView+NSTextInputClient.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
|
||||
extension AppTerminalView: @preconcurrency NSTextInputClient {
|
||||
open func insertText(_ string: Any, replacementRange _: NSRange) {
|
||||
inputHandler?.inputMethodHandler?.insertText(string)
|
||||
}
|
||||
|
||||
open func setMarkedText(
|
||||
_ string: Any,
|
||||
selectedRange: NSRange,
|
||||
replacementRange _: NSRange
|
||||
) {
|
||||
inputHandler?.inputMethodHandler?.setMarkedText(
|
||||
string,
|
||||
selectedRange: selectedRange
|
||||
)
|
||||
}
|
||||
|
||||
open func unmarkText() {
|
||||
inputHandler?.inputMethodHandler?.unmarkText()
|
||||
}
|
||||
|
||||
open func selectedRange() -> NSRange {
|
||||
inputHandler?.inputMethodHandler?.currentSelectedRange()
|
||||
?? NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
|
||||
open func markedRange() -> NSRange {
|
||||
inputHandler?.inputMethodHandler?.markedRange()
|
||||
?? NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
|
||||
open func hasMarkedText() -> Bool {
|
||||
inputHandler?.inputMethodHandler?.hasMarkedText ?? false
|
||||
}
|
||||
|
||||
open func attributedSubstring(
|
||||
forProposedRange range: NSRange,
|
||||
actualRange: NSRangePointer?
|
||||
) -> NSAttributedString? {
|
||||
inputHandler?.inputMethodHandler?.attributedSubstring(
|
||||
forProposedRange: range,
|
||||
actualRange: actualRange
|
||||
)
|
||||
}
|
||||
|
||||
open func validAttributesForMarkedText() -> [NSAttributedString.Key] {
|
||||
[]
|
||||
}
|
||||
|
||||
open func firstRect(
|
||||
forCharacterRange _: NSRange,
|
||||
actualRange _: NSRangePointer?
|
||||
) -> NSRect {
|
||||
guard let surface else { return .zero }
|
||||
|
||||
let point = surface.imePoint()
|
||||
let viewRect = NSRect(
|
||||
x: point.x,
|
||||
y: bounds.height - point.y - point.height,
|
||||
width: point.width,
|
||||
height: point.height
|
||||
)
|
||||
|
||||
guard let window else { return viewRect }
|
||||
let windowRect = convert(viewRect, to: nil)
|
||||
return window.convertToScreen(windowRect)
|
||||
}
|
||||
|
||||
open func characterIndex(for _: NSPoint) -> Int {
|
||||
NSNotFound
|
||||
}
|
||||
}
|
||||
#endif
|
||||
43
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift
vendored
Normal file
43
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// AppTerminalView+PublicInput.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Public wrappers around `TerminalSurface` write paths so hosts can
|
||||
// inject bytes into the pty without reaching for internal API.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
|
||||
extension AppTerminalView {
|
||||
/// Send raw UTF-8 text directly to the underlying pty (bypassing
|
||||
/// key translation). Use this for synthetic input like `\x1b[Z`
|
||||
/// (Shift+Tab / CSI Z) or multi-line paste-style injections.
|
||||
/// No-op when the surface has not been created yet.
|
||||
public func sendText(_ text: String) {
|
||||
surface?.sendText(text)
|
||||
}
|
||||
|
||||
/// 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
|
||||
176
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView.swift
vendored
Normal file
176
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView.swift
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// AppTerminalView.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
open class AppTerminalView: NSView {
|
||||
let core = TerminalSurfaceCoordinator()
|
||||
var metalLayer: CAMetalLayer?
|
||||
var inputHandler: TerminalKeyEventHandler?
|
||||
var lastPerformKeyEvent: TimeInterval?
|
||||
var pointerSelectionStartPoint: CGPoint?
|
||||
var lastPointerSelectionRect: CGRect?
|
||||
var pendingSelectionMenuPoint: CGPoint?
|
||||
var onFocusChange: ((Bool) -> Void)?
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
open func setSurfaceVisible(_ visible: Bool) {
|
||||
core.setDisplayVisible(visible)
|
||||
}
|
||||
|
||||
var surface: TerminalSurface? {
|
||||
core.surface
|
||||
}
|
||||
|
||||
override public init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
public required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func commonInit() {
|
||||
wantsLayer = true
|
||||
|
||||
let metal = CAMetalLayer()
|
||||
metal.device = MTLCreateSystemDefaultDevice()
|
||||
metal.pixelFormat = .bgra8Unorm
|
||||
metal.framebufferOnly = true
|
||||
metal.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
|
||||
metal.isOpaque = false
|
||||
metal.backgroundColor = NSColor.clear.cgColor
|
||||
layer = metal
|
||||
metalLayer = metal
|
||||
layer?.backgroundColor = NSColor.clear.cgColor
|
||||
|
||||
inputHandler = TerminalKeyEventHandler(view: self)
|
||||
setupTrackingArea()
|
||||
|
||||
core.isAttached = { [weak self] in self?.window != nil }
|
||||
core.scaleFactor = { [weak self] in
|
||||
Double(
|
||||
self?.window?.backingScaleFactor
|
||||
?? NSScreen.main?.backingScaleFactor ?? 2.0
|
||||
)
|
||||
}
|
||||
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_MACOS
|
||||
config.platform = ghostty_platform_u(
|
||||
macos: ghostty_platform_macos_s(
|
||||
nsview: Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
)
|
||||
}
|
||||
core.onMetricsUpdate = { [weak self] in
|
||||
self?.updateMetalLayerMetrics()
|
||||
}
|
||||
core.onPostRender = { [weak self] in
|
||||
self?.enforceMetalLayerScale()
|
||||
}
|
||||
}
|
||||
|
||||
open func selectionMenuPoint(at point: CGPoint) -> CGPoint? {
|
||||
guard surface?.hasSelection() == true else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(selectionPointDescription(point))"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if let rect = lastPointerSelectionRect {
|
||||
guard rect.insetBy(dx: -4, dy: -4).contains(point) else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(selectionPointDescription(point)) outside pointer selection"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu hit point=\(selectionPointDescription(point)) inside pointer selection"
|
||||
)
|
||||
return point
|
||||
}
|
||||
|
||||
guard surface?.selectionContainsQuicklookWord() == true else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(selectionPointDescription(point)) outside quicklook word"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu hit point=\(selectionPointDescription(point))"
|
||||
)
|
||||
return point
|
||||
}
|
||||
|
||||
open func selectionContextMenu() -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let copyItem = NSMenuItem(
|
||||
title: "Copy",
|
||||
action: #selector(copy(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
copyItem.target = self
|
||||
menu.addItem(copyItem)
|
||||
return menu
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
open func copySelectedTextToPasteboard() -> Bool {
|
||||
guard surface?.hasSelection() == true else {
|
||||
return false
|
||||
}
|
||||
guard surface?.performBindingAction("copy_to_clipboard") == true else {
|
||||
return false
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection copied to clipboard"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private func selectionPointDescription(_ point: CGPoint) -> String {
|
||||
"\(String(format: "%.2f", point.x))x\(String(format: "%.2f", point.y))"
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
22
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/KeyboardLayout.swift
vendored
Normal file
22
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/KeyboardLayout.swift
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import Carbon.HIToolbox
|
||||
|
||||
enum KeyboardLayout {
|
||||
static var id: String? {
|
||||
guard let inputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let rawProperty = TISGetInputSourceProperty(
|
||||
inputSource,
|
||||
kTISPropertyInputSourceID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let property = unsafeBitCast(rawProperty, to: CFString.self)
|
||||
return property as String
|
||||
}
|
||||
}
|
||||
#endif
|
||||
346
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalKeyEventHandler@AppKit.swift
vendored
Normal file
346
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalKeyEventHandler@AppKit.swift
vendored
Normal file
@@ -0,0 +1,346 @@
|
||||
//
|
||||
// TerminalKeyEventHandler@AppKit.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
// Reference:
|
||||
// - ghostty-org/ghostty
|
||||
// - macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
|
||||
// Translation modifiers, interpretKeyEvents dispatch, and text emission are
|
||||
// kept close to Ghostty's native AppKit implementation to reduce long-term
|
||||
// drift from upstream keyboard/IME semantics.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
final class TerminalKeyEventHandler {
|
||||
private weak var view: AppTerminalView?
|
||||
var inputMethodHandler: TerminalTextInputHandler?
|
||||
private var interpretedCommandSelector: Selector?
|
||||
|
||||
init(view: AppTerminalView) {
|
||||
self.view = view
|
||||
inputMethodHandler = TerminalTextInputHandler(view: view)
|
||||
}
|
||||
|
||||
nonisolated static func shouldUseDirectInput(
|
||||
modifierFlags: NSEvent.ModifierFlags
|
||||
) -> Bool {
|
||||
modifierFlags.intersection([.shift, .control, .option, .command]).isEmpty
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplayInterpretedCommand(
|
||||
_ selector: Selector
|
||||
) -> Bool {
|
||||
// AppKit sometimes resolves non-text keys into editing commands
|
||||
// (for example Shift-Tab -> insertBacktab:). In a terminal, those
|
||||
// commands still need to reach Ghostty as the original hardware key.
|
||||
let _ = selector
|
||||
return true
|
||||
}
|
||||
|
||||
func handleKeyDown(with event: NSEvent) {
|
||||
guard let view, let surface = view.surface else { return }
|
||||
|
||||
if handleDirectInputIfNeeded(event) {
|
||||
return
|
||||
}
|
||||
|
||||
let action: ghostty_input_action_e = event.isARepeat
|
||||
? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
|
||||
let translationEvent = translatedEvent(for: event, on: surface)
|
||||
|
||||
inputMethodHandler?.startCollectingText()
|
||||
interpretedCommandSelector = nil
|
||||
let markedTextBefore = inputMethodHandler?.hasMarkedText == true
|
||||
let keyboardIdBefore = markedTextBefore ? nil : KeyboardLayout.id
|
||||
view.lastPerformKeyEvent = nil
|
||||
view.interpretKeyEvents([translationEvent])
|
||||
if !markedTextBefore, keyboardIdBefore != KeyboardLayout.id {
|
||||
_ = inputMethodHandler?.finishCollectingText()
|
||||
return
|
||||
}
|
||||
inputMethodHandler?.syncPreedit(clearIfNeeded: markedTextBefore)
|
||||
|
||||
if let collected = inputMethodHandler?.finishCollectingText() {
|
||||
var input = event.buildKeyInput(
|
||||
action: action,
|
||||
translationModifiers: translationEvent.modifierFlags
|
||||
)
|
||||
for text in collected {
|
||||
text.withCString { ptr in
|
||||
input.text = ptr
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let selector = interpretedCommandSelector {
|
||||
interpretedCommandSelector = nil
|
||||
if Self.shouldReplayInterpretedCommand(selector) {
|
||||
sendKeyEvent(
|
||||
for: event,
|
||||
translationEvent: translationEvent,
|
||||
action: action,
|
||||
to: surface,
|
||||
includeText: false,
|
||||
composing: inputMethodHandler?.hasMarkedText == true || markedTextBefore
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sendKeyEvent(
|
||||
for: event,
|
||||
translationEvent: translationEvent,
|
||||
action: action,
|
||||
to: surface,
|
||||
includeText: true,
|
||||
composing: inputMethodHandler?.hasMarkedText == true || markedTextBefore
|
||||
)
|
||||
}
|
||||
|
||||
func handleKeyUp(with event: NSEvent) {
|
||||
guard let view, let surface = view.surface else { return }
|
||||
if shouldBypassGhosttyForDirectInput(event) {
|
||||
return
|
||||
}
|
||||
var input = event.buildKeyInput(action: GHOSTTY_ACTION_RELEASE)
|
||||
input.text = nil
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
|
||||
func handleFlagsChanged(with event: NSEvent) {
|
||||
guard let surface = view?.surface else { return }
|
||||
guard inputMethodHandler?.hasMarkedText != true else { return }
|
||||
|
||||
let mod: UInt32
|
||||
switch event.keyCode {
|
||||
case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue
|
||||
case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue
|
||||
case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue
|
||||
case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue
|
||||
case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue
|
||||
default: return
|
||||
}
|
||||
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags).ghosttyMods
|
||||
|
||||
var action = GHOSTTY_ACTION_RELEASE
|
||||
if mods.rawValue & mod != 0 {
|
||||
let sidePressed: Bool = switch event.keyCode {
|
||||
case 0x3C:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERSHIFTKEYMASK) != 0
|
||||
case 0x3E:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERCTLKEYMASK) != 0
|
||||
case 0x3D:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERALTKEYMASK) != 0
|
||||
case 0x36:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERCMDKEYMASK) != 0
|
||||
default:
|
||||
true
|
||||
}
|
||||
|
||||
if sidePressed {
|
||||
action = GHOSTTY_ACTION_PRESS
|
||||
}
|
||||
}
|
||||
|
||||
var input = event.buildKeyInput(action: action)
|
||||
input.text = nil
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
|
||||
private func sendKeyEvent(
|
||||
for event: NSEvent,
|
||||
translationEvent: NSEvent,
|
||||
action: ghostty_input_action_e,
|
||||
to surface: TerminalSurface,
|
||||
includeText: Bool,
|
||||
composing: Bool = false
|
||||
) {
|
||||
var input = event.buildKeyInput(
|
||||
action: action,
|
||||
translationModifiers: translationEvent.modifierFlags
|
||||
)
|
||||
input.composing = composing
|
||||
guard includeText,
|
||||
let chars = translationEvent.filteredCharacters,
|
||||
!chars.isEmpty
|
||||
else {
|
||||
surface.sendKeyEvent(input)
|
||||
return
|
||||
}
|
||||
|
||||
chars.withCString { ptr in
|
||||
input.text = ptr
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDirectInputIfNeeded(_ event: NSEvent) -> Bool {
|
||||
guard let view else { return false }
|
||||
// During IME composition, AppKit needs to keep ownership of editing
|
||||
// commands so marked text can shrink, cancel, and move correctly.
|
||||
guard inputMethodHandler?.hasMarkedText != true else { return false }
|
||||
guard Self.shouldUseDirectInput(modifierFlags: event.modifierFlags) else {
|
||||
return false
|
||||
}
|
||||
let delivery = TerminalHardwareKeyRouter.routeAppKit(
|
||||
keyCode: event.keyCode,
|
||||
backend: view.configuration.backend
|
||||
)
|
||||
guard case let .data(sequence) = delivery else { return false }
|
||||
guard case let .inMemory(session) = view.configuration.backend else { return false }
|
||||
|
||||
session.sendInput(sequence)
|
||||
return true
|
||||
}
|
||||
|
||||
private func shouldBypassGhosttyForDirectInput(_ event: NSEvent) -> Bool {
|
||||
guard let view else { return false }
|
||||
guard Self.shouldUseDirectInput(modifierFlags: event.modifierFlags) else {
|
||||
return false
|
||||
}
|
||||
return TerminalHardwareKeyRouter.routeAppKit(
|
||||
keyCode: event.keyCode,
|
||||
backend: view.configuration.backend
|
||||
).isDirectInput
|
||||
}
|
||||
|
||||
private func translatedEvent(
|
||||
for event: NSEvent,
|
||||
on surface: TerminalSurface
|
||||
) -> NSEvent {
|
||||
guard let rawSurface = surface.rawValue else {
|
||||
return event
|
||||
}
|
||||
|
||||
let translatedMods = eventModifierFlags(
|
||||
from: ghostty_surface_key_translation_mods(
|
||||
rawSurface,
|
||||
TerminalInputModifiers(from: event.modifierFlags).ghosttyMods
|
||||
)
|
||||
)
|
||||
|
||||
var finalModifiers = event.modifierFlags
|
||||
for flag in [
|
||||
NSEvent.ModifierFlags.shift,
|
||||
.control,
|
||||
.option,
|
||||
.command,
|
||||
] {
|
||||
if translatedMods.contains(flag) {
|
||||
finalModifiers.insert(flag)
|
||||
} else {
|
||||
finalModifiers.remove(flag)
|
||||
}
|
||||
}
|
||||
|
||||
guard finalModifiers != event.modifierFlags else {
|
||||
return event
|
||||
}
|
||||
|
||||
return NSEvent.keyEvent(
|
||||
with: event.type,
|
||||
location: event.locationInWindow,
|
||||
modifierFlags: finalModifiers,
|
||||
timestamp: event.timestamp,
|
||||
windowNumber: event.windowNumber,
|
||||
context: nil,
|
||||
characters: event.characters(byApplyingModifiers: finalModifiers) ?? "",
|
||||
charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "",
|
||||
isARepeat: event.isARepeat,
|
||||
keyCode: event.keyCode
|
||||
) ?? event
|
||||
}
|
||||
|
||||
private func eventModifierFlags(from mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags {
|
||||
var flags = NSEvent.ModifierFlags()
|
||||
if mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0 { flags.insert(.shift) }
|
||||
if mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0 { flags.insert(.control) }
|
||||
if mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0 { flags.insert(.option) }
|
||||
if mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0 { flags.insert(.command) }
|
||||
return flags
|
||||
}
|
||||
|
||||
func recordInterpretedCommand(_ selector: Selector) {
|
||||
interpretedCommandSelector = selector
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSEvent Terminal Input Helpers
|
||||
|
||||
extension NSEvent {
|
||||
func buildKeyInput(
|
||||
action: ghostty_input_action_e,
|
||||
translationModifiers: NSEvent.ModifierFlags? = nil
|
||||
) -> ghostty_input_key_s {
|
||||
var input = ghostty_input_key_s()
|
||||
input.action = action
|
||||
input.keycode = UInt32(keyCode)
|
||||
input.composing = false
|
||||
input.text = nil
|
||||
|
||||
input.mods = TerminalInputModifiers(from: modifierFlags).ghosttyMods
|
||||
|
||||
// Consumed modifiers: modifiers the key binding system should
|
||||
// treat as already handled by text generation. We pass through
|
||||
// all modifiers except control and command, which should remain
|
||||
// available for keybind matching.
|
||||
var consumedFlags = translationModifiers ?? modifierFlags
|
||||
consumedFlags.remove(.control)
|
||||
consumedFlags.remove(.command)
|
||||
input.consumed_mods = TerminalInputModifiers(from: consumedFlags).ghosttyMods
|
||||
|
||||
if type == .keyDown || type == .keyUp,
|
||||
let chars = characters(byApplyingModifiers: []),
|
||||
let codepoint = chars.unicodeScalars.first
|
||||
{
|
||||
input.unshifted_codepoint = codepoint.value
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
var filteredCharacters: String? {
|
||||
guard let filtered = TerminalInputText.filteredFunctionKeyText(characters) else {
|
||||
return nil
|
||||
}
|
||||
guard filtered.count == 1,
|
||||
let scalar = filtered.unicodeScalars.first
|
||||
else {
|
||||
return filtered
|
||||
}
|
||||
|
||||
// macOS encodes function keys as Private Use Area scalars —
|
||||
// these have no printable representation.
|
||||
// When the control modifier produces a raw control character,
|
||||
// re-derive printable text without the control modifier so
|
||||
// Ghostty can map the physical key correctly.
|
||||
if scalar.isASCIIControl {
|
||||
var flags = modifierFlags
|
||||
flags.remove(.control)
|
||||
return TerminalInputText.filteredFunctionKeyText(
|
||||
characters(byApplyingModifiers: flags)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
extension UnicodeScalar {
|
||||
var isASCIIControl: Bool {
|
||||
value < 0x20
|
||||
}
|
||||
}
|
||||
#endif
|
||||
130
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalTextInputHandler@AppKit.swift
vendored
Normal file
130
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalTextInputHandler@AppKit.swift
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// TerminalTextInputHandler@AppKit.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
// Reference:
|
||||
// - ghostty-org/ghostty
|
||||
// - macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
|
||||
// IME text accumulation intentionally follows Ghostty's AppKit flow:
|
||||
// marked/preedit text is passed through as-is, without extra app-specific
|
||||
// filtering, so composition behavior stays consistent with upstream.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
final class TerminalTextInputHandler: NSObject {
|
||||
private weak var view: AppTerminalView?
|
||||
private var markedTextState = TerminalMarkedTextState()
|
||||
private var accumulatedTexts: [String]?
|
||||
|
||||
var hasMarkedText: Bool {
|
||||
markedTextState.hasMarkedText
|
||||
}
|
||||
|
||||
init(view: AppTerminalView) {
|
||||
self.view = view
|
||||
super.init()
|
||||
}
|
||||
|
||||
func startCollectingText() {
|
||||
accumulatedTexts = []
|
||||
}
|
||||
|
||||
func finishCollectingText() -> [String]? {
|
||||
defer { accumulatedTexts = nil }
|
||||
guard let texts = accumulatedTexts, !texts.isEmpty else { return nil }
|
||||
return texts
|
||||
}
|
||||
|
||||
// MARK: - Text Input
|
||||
|
||||
func insertText(_ string: Any) {
|
||||
guard NSApp.currentEvent != nil else { return }
|
||||
|
||||
let text: String
|
||||
if let attrStr = string as? NSAttributedString {
|
||||
text = attrStr.string
|
||||
} else if let str = string as? String {
|
||||
text = str
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
unmarkText()
|
||||
|
||||
if accumulatedTexts != nil {
|
||||
accumulatedTexts?.append(text)
|
||||
} else {
|
||||
view?.surface?.sendText(text)
|
||||
}
|
||||
}
|
||||
|
||||
func setMarkedText(
|
||||
_ string: Any,
|
||||
selectedRange: NSRange
|
||||
) {
|
||||
let text: String
|
||||
if let attrStr = string as? NSAttributedString {
|
||||
text = attrStr.string
|
||||
} else if let str = string as? String {
|
||||
text = str
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
markedTextState.setMarkedText(text, selectedRange: selectedRange)
|
||||
|
||||
if accumulatedTexts == nil {
|
||||
syncPreedit()
|
||||
}
|
||||
}
|
||||
|
||||
func unmarkText() {
|
||||
guard markedTextState.hasMarkedText else { return }
|
||||
markedTextState.clear()
|
||||
syncPreedit()
|
||||
}
|
||||
|
||||
func currentSelectedRange() -> NSRange {
|
||||
markedTextState.currentSelectedRange
|
||||
}
|
||||
|
||||
func markedRange() -> NSRange {
|
||||
markedTextState.markedRange
|
||||
}
|
||||
|
||||
func attributedSubstring(
|
||||
forProposedRange range: NSRange,
|
||||
actualRange: NSRangePointer?
|
||||
) -> NSAttributedString? {
|
||||
guard markedTextState.hasMarkedText else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let length = markedTextState.documentLength
|
||||
let location = min(max(range.location, 0), length)
|
||||
let end = min(max(range.location + range.length, location), length)
|
||||
let clampedRange = NSRange(location: location, length: end - location)
|
||||
actualRange?.pointee = clampedRange
|
||||
|
||||
guard let text = markedTextState.text(in: clampedRange) else {
|
||||
return nil
|
||||
}
|
||||
return NSAttributedString(string: text)
|
||||
}
|
||||
|
||||
func syncPreedit(clearIfNeeded: Bool = true) {
|
||||
guard let text = markedTextState.text else {
|
||||
guard clearIfNeeded else { return }
|
||||
view?.surface?.preedit("")
|
||||
return
|
||||
}
|
||||
|
||||
view?.surface?.preedit(text)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
367
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalHardwareKeyRouter.swift
vendored
Normal file
367
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalHardwareKeyRouter.swift
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalInputText.swift
vendored
Normal file
45
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalInputText.swift
vendored
Normal 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")
|
||||
}
|
||||
}
|
||||
19
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalMainActor.swift
vendored
Normal file
19
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalMainActor.swift
vendored
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
86
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalMarkedTextState.swift
vendored
Normal file
86
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalMarkedTextState.swift
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
27
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalView+Process.swift
vendored
Normal file
27
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalView+Process.swift
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
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
|
||||
70
vendor/libghostty-spm/Sources/GhosttyTerminal/State/TerminalViewState+Delegate.swift
vendored
Normal file
70
vendor/libghostty-spm/Sources/GhosttyTerminal/State/TerminalViewState+Delegate.swift
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// TerminalViewState+Delegate.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
extension TerminalViewState:
|
||||
TerminalSurfaceTitleDelegate,
|
||||
TerminalSurfaceGridResizeDelegate,
|
||||
TerminalSurfaceFocusDelegate,
|
||||
TerminalSurfaceCloseDelegate,
|
||||
TerminalSurfaceBellDelegate,
|
||||
TerminalSurfaceDesktopNotificationDelegate,
|
||||
TerminalSurfacePwdDelegate,
|
||||
TerminalSurfaceScrollbarDelegate,
|
||||
TerminalSurfaceCommandFinishedDelegate,
|
||||
TerminalSurfaceLifecycleDelegate
|
||||
{
|
||||
public func terminalDidChangeTitle(_ title: String) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
public func terminalDidResize(_ size: TerminalGridMetrics) {
|
||||
surfaceSize = size
|
||||
}
|
||||
|
||||
public func terminalDidChangeFocus(_ focused: Bool) {
|
||||
isFocused = focused
|
||||
}
|
||||
|
||||
public func terminalDidClose(processAlive: Bool) {
|
||||
onClose?(processAlive)
|
||||
}
|
||||
|
||||
public func terminalDidRingBell() {
|
||||
bellCount += 1
|
||||
lastBellAt = Date()
|
||||
}
|
||||
|
||||
public func terminalDidRequestDesktopNotification(title: String, body: String) {
|
||||
lastDesktopNotificationTitle = title
|
||||
lastDesktopNotificationBody = body
|
||||
lastDesktopNotificationAt = Date()
|
||||
}
|
||||
|
||||
public func terminalDidChangeWorkingDirectory(_ path: String) {
|
||||
workingDirectory = path
|
||||
}
|
||||
|
||||
public func terminalDidUpdateScrollbar(_ scrollbar: TerminalScrollbar) {
|
||||
self.scrollbar = scrollbar
|
||||
}
|
||||
|
||||
public func terminalDidFinishCommand(exitCode: Int?, durationNanos: UInt64) {
|
||||
lastCommandExitCode = exitCode
|
||||
lastCommandDurationNanos = durationNanos
|
||||
}
|
||||
|
||||
public func terminalDidAttachSurface(_ surface: TerminalSurface) {
|
||||
self.surface = surface
|
||||
}
|
||||
|
||||
public func terminalDidDetachSurface() {
|
||||
surface = nil
|
||||
}
|
||||
}
|
||||
37
vendor/libghostty-spm/Sources/GhosttyTerminal/State/TerminalViewState+Mutation.swift
vendored
Normal file
37
vendor/libghostty-spm/Sources/GhosttyTerminal/State/TerminalViewState+Mutation.swift
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
//
|
||||
// TerminalViewState+Mutation.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public extension TerminalViewState {
|
||||
func adopt(colorScheme: ColorScheme) {
|
||||
adopt(terminalColorScheme: TerminalColorScheme(colorScheme))
|
||||
}
|
||||
|
||||
func adopt(terminalColorScheme colorScheme: TerminalColorScheme) {
|
||||
guard colorScheme != controller.effectiveColorScheme else { return }
|
||||
controller.setColorScheme(colorScheme) {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setTheme(_ theme: TerminalTheme) -> Bool {
|
||||
return controller.setTheme(theme) {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func setTerminalConfiguration(
|
||||
_ terminalConfiguration: TerminalConfiguration
|
||||
) -> Bool {
|
||||
return controller.setTerminalConfiguration(terminalConfiguration) {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
115
vendor/libghostty-spm/Sources/GhosttyTerminal/State/TerminalViewState.swift
vendored
Normal file
115
vendor/libghostty-spm/Sources/GhosttyTerminal/State/TerminalViewState.swift
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
//
|
||||
// TerminalViewState.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
public final class TerminalViewState: ObservableObject {
|
||||
@Published public internal(set) var title: String = ""
|
||||
@Published public internal(set) var surfaceSize: TerminalGridMetrics?
|
||||
@Published public internal(set) var isFocused: Bool = false
|
||||
|
||||
@Published public internal(set) var bellCount: Int = 0
|
||||
@Published public internal(set) var lastBellAt: Date?
|
||||
|
||||
@Published public internal(set) var lastDesktopNotificationTitle: String?
|
||||
@Published public internal(set) var lastDesktopNotificationBody: String?
|
||||
@Published public internal(set) var lastDesktopNotificationAt: Date?
|
||||
|
||||
@Published public internal(set) var workingDirectory: String?
|
||||
|
||||
@Published public internal(set) var lastCommandExitCode: Int?
|
||||
@Published public internal(set) var lastCommandDurationNanos: UInt64?
|
||||
|
||||
/// Latest scrollbar geometry reported by the terminal (nil until the first
|
||||
/// update). Drives a host-drawn scrollbar.
|
||||
@Published public internal(set) var scrollbar: TerminalScrollbar?
|
||||
|
||||
public internal(set) weak var surface: TerminalSurface?
|
||||
|
||||
@Published public var configuration: TerminalSurfaceOptions = .init()
|
||||
public var onClose: ((Bool) -> Void)?
|
||||
@Published public internal(set) var controller: TerminalController
|
||||
|
||||
/// Sends text to the attached surface.
|
||||
@discardableResult
|
||||
public func send(_ text: String) -> Bool {
|
||||
guard let surface else {
|
||||
TerminalDebugLog.log(.input, "view state send ignored: missing surface")
|
||||
return false
|
||||
}
|
||||
return surface.sendText(text)
|
||||
}
|
||||
|
||||
/// Invoke a named Ghostty binding action on the attached surface.
|
||||
@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
|
||||
}
|
||||
|
||||
public convenience init() {
|
||||
self.init(configSource: .none)
|
||||
}
|
||||
|
||||
public convenience init(configFilePath: String?) {
|
||||
if let configFilePath {
|
||||
self.init(configSource: .file(configFilePath))
|
||||
} else {
|
||||
self.init(configSource: .none)
|
||||
}
|
||||
}
|
||||
|
||||
public init(
|
||||
configSource: TerminalController.ConfigSource = .none,
|
||||
theme: TerminalTheme = .default,
|
||||
terminalConfiguration: TerminalConfiguration = .init()
|
||||
) {
|
||||
controller = TerminalController(
|
||||
configSource: configSource,
|
||||
theme: theme,
|
||||
terminalConfiguration: terminalConfiguration
|
||||
)
|
||||
}
|
||||
|
||||
public init(controller: TerminalController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
// MARK: - Forwarded from Controller (single source of truth)
|
||||
|
||||
public var renderedConfig: String {
|
||||
controller.renderedConfig
|
||||
}
|
||||
|
||||
public var effectiveColorScheme: TerminalColorScheme {
|
||||
controller.effectiveColorScheme
|
||||
}
|
||||
|
||||
public var theme: TerminalTheme {
|
||||
controller.theme
|
||||
}
|
||||
|
||||
public var terminalConfiguration: TerminalConfiguration {
|
||||
controller.terminalConfiguration
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
16
vendor/libghostty-spm/Sources/GhosttyTerminal/View/TerminalView.swift
vendored
Normal file
16
vendor/libghostty-spm/Sources/GhosttyTerminal/View/TerminalView.swift
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// TerminalView.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
|
||||
public typealias TerminalView = UITerminalView
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
|
||||
public typealias TerminalView = AppTerminalView
|
||||
#endif
|
||||
104
vendor/libghostty-spm/Sources/GhosttyTerminal/View/TerminalViewRepresentable.swift
vendored
Normal file
104
vendor/libghostty-spm/Sources/GhosttyTerminal/View/TerminalViewRepresentable.swift
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// TerminalViewRepresentable.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct TerminalViewRepresentable {
|
||||
let context: TerminalViewState
|
||||
let controller: TerminalController
|
||||
let configuration: TerminalSurfaceOptions
|
||||
let focusBinding: TerminalFocusBinding?
|
||||
|
||||
func configureView(_ view: TerminalView, initial: Bool) {
|
||||
if initial {
|
||||
view.delegate = context
|
||||
}
|
||||
|
||||
if let currentController = view.controller, currentController === controller {
|
||||
// Keep the current surface.
|
||||
} else {
|
||||
view.controller = controller
|
||||
}
|
||||
|
||||
if !view.configuration.isEquivalent(to: configuration) {
|
||||
view.configuration = configuration
|
||||
}
|
||||
}
|
||||
|
||||
static func synchronizeFocus(_ view: TerminalView, with binding: TerminalFocusBinding?) {
|
||||
guard let binding else { return }
|
||||
|
||||
DispatchQueue.main.async { [weak view] in
|
||||
#if canImport(UIKit)
|
||||
guard let view, view.window != nil else { return }
|
||||
if binding.isFocused {
|
||||
if !view.isFirstResponder { view.becomeFirstResponder() }
|
||||
} else if view.isFirstResponder {
|
||||
_ = view.resignFirstResponder()
|
||||
}
|
||||
#elseif canImport(AppKit)
|
||||
guard let view, let window = view.window else { return }
|
||||
if binding.isFocused {
|
||||
if window.firstResponder !== view {
|
||||
window.makeFirstResponder(view)
|
||||
}
|
||||
} else if window.firstResponder === view {
|
||||
window.makeFirstResponder(nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct TerminalFocusBinding {
|
||||
private let read: () -> Bool
|
||||
private let write: (Bool) -> Void
|
||||
|
||||
var isFocused: Bool {
|
||||
read()
|
||||
}
|
||||
|
||||
func setFocused(_ focused: Bool) {
|
||||
write(focused)
|
||||
}
|
||||
|
||||
static func bool(_ binding: FocusState<Bool>.Binding) -> TerminalFocusBinding {
|
||||
TerminalFocusBinding(
|
||||
read: { binding.wrappedValue },
|
||||
write: { binding.wrappedValue = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
static func optional<Value: Hashable>(
|
||||
_ binding: FocusState<Value?>.Binding,
|
||||
equals value: Value
|
||||
) -> TerminalFocusBinding {
|
||||
TerminalFocusBinding(
|
||||
read: { binding.wrappedValue == value },
|
||||
write: { focused in
|
||||
binding.wrappedValue = focused ? value : nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension TerminalFocusBinding? {
|
||||
func setFocused(_ focused: Bool) {
|
||||
guard let binding = self, binding.isFocused != focused else {
|
||||
return
|
||||
}
|
||||
binding.setFocused(focused)
|
||||
}
|
||||
}
|
||||
35
vendor/libghostty-spm/Sources/GhosttyTerminal/View/TerminalViewRepresentable@AppKit.swift
vendored
Normal file
35
vendor/libghostty-spm/Sources/GhosttyTerminal/View/TerminalViewRepresentable@AppKit.swift
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
//
|
||||
// TerminalViewRepresentable@AppKit.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
extension TerminalViewRepresentable: NSViewRepresentable {
|
||||
func makeNSView(context _: Context) -> TerminalView {
|
||||
let view = TerminalView(frame: .zero)
|
||||
configureView(view, initial: true)
|
||||
view.onFocusChange = { focused in
|
||||
focusBinding.setFocused(focused)
|
||||
}
|
||||
Self.synchronizeFocus(view, with: focusBinding)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: TerminalView, context _: Context) {
|
||||
configureView(view, initial: false)
|
||||
view.onFocusChange = { focused in
|
||||
focusBinding.setFocused(focused)
|
||||
}
|
||||
Self.synchronizeFocus(view, with: focusBinding)
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ view: TerminalView, coordinator _: ()) {
|
||||
view.onFocusChange = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
58
vendor/libghostty-spm/Sources/GhosttyTerminal/View/TerminalViewRepresentable@UIKit.swift
vendored
Normal file
58
vendor/libghostty-spm/Sources/GhosttyTerminal/View/TerminalViewRepresentable@UIKit.swift
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// TerminalViewRepresentable@UIKit.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
extension TerminalViewRepresentable: UIViewRepresentable {
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context viewContext: Context) -> TerminalView {
|
||||
let view = TerminalView(frame: .zero)
|
||||
configureView(view, initial: true)
|
||||
viewContext.coordinator.attach(to: view, focusBinding: focusBinding)
|
||||
Self.synchronizeFocus(view, with: focusBinding)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: TerminalView, context viewContext: Context) {
|
||||
configureView(view, initial: false)
|
||||
viewContext.coordinator.attach(to: view, focusBinding: focusBinding)
|
||||
Self.synchronizeFocus(view, with: focusBinding)
|
||||
}
|
||||
|
||||
static func dismantleUIView(_: TerminalView, coordinator: Coordinator) {
|
||||
coordinator.detach()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator {
|
||||
private weak var view: TerminalView?
|
||||
private var focusBinding: TerminalFocusBinding?
|
||||
|
||||
func attach(
|
||||
to view: TerminalView,
|
||||
focusBinding: TerminalFocusBinding?
|
||||
) {
|
||||
self.view = view
|
||||
self.focusBinding = focusBinding
|
||||
view.onFocusChange = { [weak self] focused in
|
||||
self?.focusBinding.setFocused(focused)
|
||||
}
|
||||
}
|
||||
|
||||
func detach() {
|
||||
view?.onFocusChange = nil
|
||||
focusBinding = nil
|
||||
view = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user