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

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

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

View File

@@ -0,0 +1,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)
}
}

View 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

View 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
}
}
}

View 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
}
}

View 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")
}
}

View 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
}
}
}

View 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
)
}

View 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
}
}

View 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
}
}

View 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)
}
}
}

View 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)))"
}
}
}

View 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)
}
}

View 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()
}
}
}

View 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
}
}

View 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)
}
}

View 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
}
}
}

View 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
)
}
}

View 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
}

View 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
}

View 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
}

View 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

View 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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
}
}

View 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()
}
}
}

View 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
}
}

View 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
}
}

View 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))
}

View 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
}
}
}

View 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
)
)
}
}

View 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
}
}

View 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
}
}
}

View 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()
}

View 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

View 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)
}
}

View 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

View 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