初始提交: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:
295
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Input.swift
vendored
Normal file
295
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Input.swift
vendored
Normal file
@@ -0,0 +1,295 @@
|
||||
//
|
||||
// AppTerminalView+Input.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
extension AppTerminalView {
|
||||
override open func keyDown(with event: NSEvent) {
|
||||
inputHandler?.handleKeyDown(with: event)
|
||||
}
|
||||
|
||||
override open func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
guard event.type == .keyDown else { return false }
|
||||
guard window?.firstResponder === self else { return false }
|
||||
guard let surface else { return false }
|
||||
|
||||
if keyIsBinding(event, on: surface) {
|
||||
keyDown(with: event)
|
||||
return true
|
||||
}
|
||||
|
||||
let equivalent: String
|
||||
switch event.charactersIgnoringModifiers {
|
||||
case "\r":
|
||||
guard event.modifierFlags.contains(.control) else {
|
||||
return false
|
||||
}
|
||||
equivalent = "\r"
|
||||
|
||||
case "/":
|
||||
guard event.modifierFlags.contains(.control),
|
||||
event.modifierFlags.isDisjoint(with: [.shift, .command, .option])
|
||||
else {
|
||||
return false
|
||||
}
|
||||
equivalent = "_"
|
||||
|
||||
default:
|
||||
if event.timestamp == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if !event.modifierFlags.contains(.command),
|
||||
!event.modifierFlags.contains(.control)
|
||||
{
|
||||
lastPerformKeyEvent = nil
|
||||
return false
|
||||
}
|
||||
|
||||
if let lastPerformKeyEvent,
|
||||
lastPerformKeyEvent == event.timestamp
|
||||
{
|
||||
self.lastPerformKeyEvent = nil
|
||||
equivalent = event.characters ?? ""
|
||||
break
|
||||
}
|
||||
|
||||
lastPerformKeyEvent = event.timestamp
|
||||
return false
|
||||
}
|
||||
|
||||
guard let translatedEvent = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: event.locationInWindow,
|
||||
modifierFlags: event.modifierFlags,
|
||||
timestamp: event.timestamp,
|
||||
windowNumber: event.windowNumber,
|
||||
context: nil,
|
||||
characters: equivalent,
|
||||
charactersIgnoringModifiers: equivalent,
|
||||
isARepeat: event.isARepeat,
|
||||
keyCode: event.keyCode
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
|
||||
keyDown(with: translatedEvent)
|
||||
return true
|
||||
}
|
||||
|
||||
override open func keyUp(with event: NSEvent) {
|
||||
inputHandler?.handleKeyUp(with: event)
|
||||
}
|
||||
|
||||
override open func flagsChanged(with event: NSEvent) {
|
||||
inputHandler?.handleFlagsChanged(with: event)
|
||||
}
|
||||
|
||||
override open func doCommand(by selector: Selector) {
|
||||
if let lastPerformKeyEvent,
|
||||
let current = NSApp.currentEvent,
|
||||
lastPerformKeyEvent == current.timestamp
|
||||
{
|
||||
NSApp.sendEvent(current)
|
||||
return
|
||||
}
|
||||
|
||||
if TerminalKeyEventHandler.shouldReplayInterpretedCommand(selector) {
|
||||
inputHandler?.recordInterpretedCommand(selector)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction open func copy(_: Any?) {
|
||||
_ = copySelectedTextToPasteboard()
|
||||
}
|
||||
|
||||
@IBAction func paste(_: Any?) {
|
||||
if let text = NSPasteboard.general.string(forType: .string) {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"paste binding bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text))"
|
||||
)
|
||||
}
|
||||
_ = surface?.performBindingAction("paste_from_clipboard")
|
||||
}
|
||||
|
||||
@IBAction override open func selectAll(_: Any?) {
|
||||
_ = surface?.performBindingAction("select_all")
|
||||
}
|
||||
|
||||
internal func mousePoint(from event: NSEvent) -> (x: CGFloat, y: CGFloat) {
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
return (point.x, bounds.height - point.y)
|
||||
}
|
||||
|
||||
override open func mouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
pointerSelectionStartPoint = CGPoint(x: x, y: y)
|
||||
pendingSelectionMenuPoint = nil
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_LEFT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func mouseUp(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_LEFT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
finishPointerSelection(at: CGPoint(x: x, y: y))
|
||||
}
|
||||
|
||||
override open func rightMouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
if let menuPoint = selectionMenuPoint(at: CGPoint(x: x, y: y)) {
|
||||
pendingSelectionMenuPoint = menuPoint
|
||||
return
|
||||
}
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_RIGHT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func rightMouseUp(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
if pendingSelectionMenuPoint != nil {
|
||||
pendingSelectionMenuPoint = nil
|
||||
showSelectionCopyMenu(with: event)
|
||||
return
|
||||
}
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_RIGHT,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func menu(for event: NSEvent) -> NSMenu? {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
guard selectionMenuPoint(at: CGPoint(x: x, y: y)) != nil else {
|
||||
return super.menu(for: event)
|
||||
}
|
||||
return selectionContextMenu()
|
||||
}
|
||||
|
||||
override open func otherMouseDown(with event: NSEvent) {
|
||||
window?.makeFirstResponder(self)
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_MIDDLE,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func otherMouseUp(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_MIDDLE,
|
||||
mods: mods.ghosttyMods
|
||||
)
|
||||
}
|
||||
|
||||
override open func mouseMoved(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags)
|
||||
surface?.sendMousePos(x: x, y: y, mods: mods.ghosttyMods)
|
||||
}
|
||||
|
||||
override open func mouseDragged(with event: NSEvent) {
|
||||
let (x, y) = mousePoint(from: event)
|
||||
updatePointerSelectionRect(to: CGPoint(x: x, y: y))
|
||||
mouseMoved(with: event)
|
||||
}
|
||||
|
||||
override open func rightMouseDragged(with event: NSEvent) {
|
||||
mouseMoved(with: event)
|
||||
}
|
||||
|
||||
override open func otherMouseDragged(with event: NSEvent) {
|
||||
mouseMoved(with: event)
|
||||
}
|
||||
|
||||
override open func scrollWheel(with event: NSEvent) {
|
||||
let scrollMods = TerminalScrollModifiers(
|
||||
precision: event.hasPreciseScrollingDeltas,
|
||||
momentum: TerminalScrollModifiers.momentumFrom(phase: event.momentumPhase)
|
||||
)
|
||||
surface?.sendMouseScroll(
|
||||
x: event.scrollingDeltaX,
|
||||
y: event.scrollingDeltaY,
|
||||
mods: scrollMods.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
private func updatePointerSelectionRect(to point: CGPoint) {
|
||||
guard let start = pointerSelectionStartPoint else { return }
|
||||
lastPointerSelectionRect = CGRect(
|
||||
x: min(start.x, point.x),
|
||||
y: min(start.y, point.y),
|
||||
width: abs(start.x - point.x),
|
||||
height: abs(start.y - point.y)
|
||||
).insetBy(dx: -2, dy: -2)
|
||||
}
|
||||
|
||||
private func finishPointerSelection(at point: CGPoint) {
|
||||
defer { pointerSelectionStartPoint = nil }
|
||||
guard let start = pointerSelectionStartPoint else { return }
|
||||
let dragDistance = hypot(point.x - start.x, point.y - start.y)
|
||||
if dragDistance < 2 {
|
||||
lastPointerSelectionRect = nil
|
||||
} else {
|
||||
updatePointerSelectionRect(to: point)
|
||||
}
|
||||
}
|
||||
|
||||
private func showSelectionCopyMenu(with event: NSEvent) {
|
||||
let menu = selectionContextMenu()
|
||||
NSMenu.popUpContextMenu(menu, with: event, for: self)
|
||||
}
|
||||
|
||||
private func keyIsBinding(
|
||||
_ event: NSEvent,
|
||||
on surface: TerminalSurface
|
||||
) -> Bool {
|
||||
guard let rawSurface = surface.rawValue else {
|
||||
return false
|
||||
}
|
||||
|
||||
var keyEvent = event.buildKeyInput(action: GHOSTTY_ACTION_PRESS)
|
||||
var bindingFlags = ghostty_binding_flags_e(rawValue: 0)
|
||||
let text = event.characters ?? ""
|
||||
return text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
return ghostty_surface_key_is_binding(rawSurface, keyEvent, &bindingFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
230
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Lifecycle.swift
vendored
Normal file
230
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+Lifecycle.swift
vendored
Normal file
@@ -0,0 +1,230 @@
|
||||
//
|
||||
// AppTerminalView+Lifecycle.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
|
||||
extension AppTerminalView {
|
||||
func setupTrackingArea() {
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.mouseMoved,
|
||||
.inVisibleRect,
|
||||
.activeAlways,
|
||||
]
|
||||
let area = NSTrackingArea(
|
||||
rect: bounds,
|
||||
options: options,
|
||||
owner: self,
|
||||
userInfo: nil
|
||||
)
|
||||
addTrackingArea(area)
|
||||
}
|
||||
|
||||
override open func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
trackingAreas.forEach { removeTrackingArea($0) }
|
||||
setupTrackingArea()
|
||||
}
|
||||
|
||||
override open var acceptsFirstResponder: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override open func becomeFirstResponder() -> Bool {
|
||||
let result = super.becomeFirstResponder()
|
||||
core.setFocus(true)
|
||||
onFocusChange?(true)
|
||||
return result
|
||||
}
|
||||
|
||||
override open func resignFirstResponder() -> Bool {
|
||||
let result = super.resignFirstResponder()
|
||||
core.setFocus(false)
|
||||
onFocusChange?(false)
|
||||
return result
|
||||
}
|
||||
|
||||
override open func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
removeWindowObservers()
|
||||
if window != nil {
|
||||
// SwiftUI/AppKit can temporarily detach and reattach the terminal view while
|
||||
// diffing the view hierarchy. Rebuilding on every reattach discards Ghostty's
|
||||
// scrollback/state, so only create a new surface when one does not already exist.
|
||||
if surface == nil {
|
||||
core.rebuildIfReady()
|
||||
} else {
|
||||
core.synchronizeMetrics()
|
||||
}
|
||||
updateMetalLayerMetrics()
|
||||
updateColorScheme()
|
||||
core.startDisplayLink()
|
||||
core.requestImmediateTick()
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidBecomeKey),
|
||||
name: NSWindow.didBecomeKeyNotification,
|
||||
object: window
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidResignKey),
|
||||
name: NSWindow.didResignKeyNotification,
|
||||
object: window
|
||||
)
|
||||
// Cross-display rescue: AppKit posts didChangeScreen when the
|
||||
// window's screen reference changes, even when the new screen
|
||||
// has the same backingScaleFactor (in which case
|
||||
// viewDidChangeBackingProperties does not fire). Listening
|
||||
// here lets us re-run metric sync on every screen transition
|
||||
// — required for the case where two displays share scale but
|
||||
// differ in geometry / color profile, and harmless when
|
||||
// viewDidChangeBackingProperties also fires for the
|
||||
// different-scale case.
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(windowDidChangeScreen),
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: window
|
||||
)
|
||||
} else {
|
||||
core.stopDisplayLink()
|
||||
core.setFocus(false)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func windowDidBecomeKey(_: Notification) {
|
||||
let focused = window?.isKeyWindow == true
|
||||
&& window?.firstResponder === self
|
||||
core.setFocus(focused)
|
||||
onFocusChange?(focused)
|
||||
}
|
||||
|
||||
@objc func windowDidResignKey(_: Notification) {
|
||||
core.setFocus(false)
|
||||
onFocusChange?(false)
|
||||
}
|
||||
|
||||
@objc func windowDidChangeScreen(_: Notification) {
|
||||
// Defer one runloop tick so AppKit's layout pass and the
|
||||
// window's new backingScaleFactor have both settled before we
|
||||
// re-derive metrics. Calling synchronously can race with the
|
||||
// layout pass and re-introduce the drift we're trying to fix.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
updateMetalLayerMetrics()
|
||||
core.synchronizeMetrics()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
}
|
||||
|
||||
private func removeWindowObservers() {
|
||||
// Remove any existing key-window observers before registering for the
|
||||
// current window. AppKit can move the view directly between windows
|
||||
// without an intermediate nil attachment.
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.didBecomeKeyNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.didResignKeyNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSWindow.didChangeScreenNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
override open func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
core.fitToSize()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
|
||||
override open func layout() {
|
||||
super.layout()
|
||||
core.fitToSize()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
|
||||
override open func viewDidChangeBackingProperties() {
|
||||
super.viewDidChangeBackingProperties()
|
||||
updateMetalLayerMetrics()
|
||||
core.fitToSize()
|
||||
core.requestImmediateTick()
|
||||
}
|
||||
|
||||
public func fitToSize() {
|
||||
core.fitToSize()
|
||||
}
|
||||
|
||||
func updateMetalLayerMetrics() {
|
||||
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||
let scale = core.scaleFactor()
|
||||
// Write to the actually-attached backing layer (not just the
|
||||
// cached `metalLayer` ivar). The render pipeline can swap
|
||||
// `self.layer` to an IOSurfaceLayer for IOSurface-backed
|
||||
// compositing; once that happens the cached CAMetalLayer
|
||||
// reference is detached from the view tree and writes to its
|
||||
// contentsScale are no-ops as far as what's visible. The
|
||||
// observable symptom is text rendered at half size after the
|
||||
// window crosses to a display with a different
|
||||
// backingScaleFactor.
|
||||
layer?.contentsScale = scale
|
||||
if let metal = layer as? CAMetalLayer {
|
||||
metal.drawableSize = CGSize(
|
||||
width: bounds.width * scale,
|
||||
height: bounds.height * scale
|
||||
)
|
||||
}
|
||||
// Mirror to the cached ivar in case anything else still
|
||||
// reads through it during a transitional layout pass.
|
||||
metalLayer?.contentsScale = scale
|
||||
metalLayer?.drawableSize = CGSize(
|
||||
width: bounds.width * scale,
|
||||
height: bounds.height * scale
|
||||
)
|
||||
}
|
||||
|
||||
func enforceMetalLayerScale() {
|
||||
let scale = core.scaleFactor()
|
||||
if let layer, layer.contentsScale != scale {
|
||||
layer.contentsScale = scale
|
||||
}
|
||||
if let metalLayer, metalLayer.contentsScale != scale {
|
||||
metalLayer.contentsScale = scale
|
||||
}
|
||||
}
|
||||
|
||||
override open func viewDidChangeEffectiveAppearance() {
|
||||
super.viewDidChangeEffectiveAppearance()
|
||||
updateColorScheme()
|
||||
}
|
||||
|
||||
func updateColorScheme() {
|
||||
let scheme: TerminalColorScheme = switch effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) {
|
||||
case .darkAqua: .dark
|
||||
default: .light
|
||||
}
|
||||
surface?.setColorScheme(scheme.ghosttyValue)
|
||||
if let controller,
|
||||
let viewState = delegate as? TerminalViewState,
|
||||
viewState.controller === controller
|
||||
{
|
||||
viewState.adopt(terminalColorScheme: scheme)
|
||||
} else {
|
||||
controller?.setColorScheme(scheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// AppTerminalView+NSTextInputClient.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
|
||||
extension AppTerminalView: @preconcurrency NSTextInputClient {
|
||||
open func insertText(_ string: Any, replacementRange _: NSRange) {
|
||||
inputHandler?.inputMethodHandler?.insertText(string)
|
||||
}
|
||||
|
||||
open func setMarkedText(
|
||||
_ string: Any,
|
||||
selectedRange: NSRange,
|
||||
replacementRange _: NSRange
|
||||
) {
|
||||
inputHandler?.inputMethodHandler?.setMarkedText(
|
||||
string,
|
||||
selectedRange: selectedRange
|
||||
)
|
||||
}
|
||||
|
||||
open func unmarkText() {
|
||||
inputHandler?.inputMethodHandler?.unmarkText()
|
||||
}
|
||||
|
||||
open func selectedRange() -> NSRange {
|
||||
inputHandler?.inputMethodHandler?.currentSelectedRange()
|
||||
?? NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
|
||||
open func markedRange() -> NSRange {
|
||||
inputHandler?.inputMethodHandler?.markedRange()
|
||||
?? NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
|
||||
open func hasMarkedText() -> Bool {
|
||||
inputHandler?.inputMethodHandler?.hasMarkedText ?? false
|
||||
}
|
||||
|
||||
open func attributedSubstring(
|
||||
forProposedRange range: NSRange,
|
||||
actualRange: NSRangePointer?
|
||||
) -> NSAttributedString? {
|
||||
inputHandler?.inputMethodHandler?.attributedSubstring(
|
||||
forProposedRange: range,
|
||||
actualRange: actualRange
|
||||
)
|
||||
}
|
||||
|
||||
open func validAttributesForMarkedText() -> [NSAttributedString.Key] {
|
||||
[]
|
||||
}
|
||||
|
||||
open func firstRect(
|
||||
forCharacterRange _: NSRange,
|
||||
actualRange _: NSRangePointer?
|
||||
) -> NSRect {
|
||||
guard let surface else { return .zero }
|
||||
|
||||
let point = surface.imePoint()
|
||||
let viewRect = NSRect(
|
||||
x: point.x,
|
||||
y: bounds.height - point.y - point.height,
|
||||
width: point.width,
|
||||
height: point.height
|
||||
)
|
||||
|
||||
guard let window else { return viewRect }
|
||||
let windowRect = convert(viewRect, to: nil)
|
||||
return window.convertToScreen(windowRect)
|
||||
}
|
||||
|
||||
open func characterIndex(for _: NSPoint) -> Int {
|
||||
NSNotFound
|
||||
}
|
||||
}
|
||||
#endif
|
||||
43
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift
vendored
Normal file
43
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView+PublicInput.swift
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// AppTerminalView+PublicInput.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Public wrappers around `TerminalSurface` write paths so hosts can
|
||||
// inject bytes into the pty without reaching for internal API.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
|
||||
extension AppTerminalView {
|
||||
/// Send raw UTF-8 text directly to the underlying pty (bypassing
|
||||
/// key translation). Use this for synthetic input like `\x1b[Z`
|
||||
/// (Shift+Tab / CSI Z) or multi-line paste-style injections.
|
||||
/// No-op when the surface has not been created yet.
|
||||
public func sendText(_ text: String) {
|
||||
surface?.sendText(text)
|
||||
}
|
||||
|
||||
/// Invoke a named Ghostty binding action (e.g. "copy_to_clipboard",
|
||||
/// "clear_screen"). Returns true when the action dispatched.
|
||||
@discardableResult
|
||||
public func performBindingAction(_ action: String) -> Bool {
|
||||
surface?.performBindingAction(action) ?? false
|
||||
}
|
||||
|
||||
/// Jump the viewport by a number of shell prompts.
|
||||
///
|
||||
/// Negative offsets move toward older prompts and positive offsets move
|
||||
/// toward newer prompts. Prompt navigation requires shell integration.
|
||||
@discardableResult
|
||||
public func jumpToPrompt(by offset: Int16) -> Bool {
|
||||
surface?.jumpToPrompt(by: offset) ?? false
|
||||
}
|
||||
|
||||
/// Reveal an absolute scrollback row, where zero is the first row.
|
||||
@discardableResult
|
||||
public func scrollToRow(_ row: UInt) -> Bool {
|
||||
surface?.scrollToRow(row) ?? false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
176
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView.swift
vendored
Normal file
176
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/AppTerminalView.swift
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// AppTerminalView.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
open class AppTerminalView: NSView {
|
||||
let core = TerminalSurfaceCoordinator()
|
||||
var metalLayer: CAMetalLayer?
|
||||
var inputHandler: TerminalKeyEventHandler?
|
||||
var lastPerformKeyEvent: TimeInterval?
|
||||
var pointerSelectionStartPoint: CGPoint?
|
||||
var lastPointerSelectionRect: CGRect?
|
||||
var pendingSelectionMenuPoint: CGPoint?
|
||||
var onFocusChange: ((Bool) -> Void)?
|
||||
|
||||
open weak var delegate: (any TerminalSurfaceViewDelegate)? {
|
||||
get { core.delegate }
|
||||
set { core.delegate = newValue }
|
||||
}
|
||||
|
||||
open var controller: TerminalController? {
|
||||
get { core.controller }
|
||||
set { core.controller = newValue }
|
||||
}
|
||||
|
||||
open var configuration: TerminalSurfaceOptions {
|
||||
get { core.configuration }
|
||||
set { core.configuration = newValue }
|
||||
}
|
||||
|
||||
open func setSurfaceVisible(_ visible: Bool) {
|
||||
core.setDisplayVisible(visible)
|
||||
}
|
||||
|
||||
var surface: TerminalSurface? {
|
||||
core.surface
|
||||
}
|
||||
|
||||
override public init(frame: NSRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
public required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func commonInit() {
|
||||
wantsLayer = true
|
||||
|
||||
let metal = CAMetalLayer()
|
||||
metal.device = MTLCreateSystemDefaultDevice()
|
||||
metal.pixelFormat = .bgra8Unorm
|
||||
metal.framebufferOnly = true
|
||||
metal.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
|
||||
metal.isOpaque = false
|
||||
metal.backgroundColor = NSColor.clear.cgColor
|
||||
layer = metal
|
||||
metalLayer = metal
|
||||
layer?.backgroundColor = NSColor.clear.cgColor
|
||||
|
||||
inputHandler = TerminalKeyEventHandler(view: self)
|
||||
setupTrackingArea()
|
||||
|
||||
core.isAttached = { [weak self] in self?.window != nil }
|
||||
core.scaleFactor = { [weak self] in
|
||||
Double(
|
||||
self?.window?.backingScaleFactor
|
||||
?? NSScreen.main?.backingScaleFactor ?? 2.0
|
||||
)
|
||||
}
|
||||
core.viewSize = { [weak self] in
|
||||
guard let self else { return (0, 0) }
|
||||
return (bounds.width, bounds.height)
|
||||
}
|
||||
core.platformSetup = { [weak self] config in
|
||||
guard let self else { return }
|
||||
config.platform_tag = GHOSTTY_PLATFORM_MACOS
|
||||
config.platform = ghostty_platform_u(
|
||||
macos: ghostty_platform_macos_s(
|
||||
nsview: Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
)
|
||||
}
|
||||
core.onMetricsUpdate = { [weak self] in
|
||||
self?.updateMetalLayerMetrics()
|
||||
}
|
||||
core.onPostRender = { [weak self] in
|
||||
self?.enforceMetalLayerScale()
|
||||
}
|
||||
}
|
||||
|
||||
open func selectionMenuPoint(at point: CGPoint) -> CGPoint? {
|
||||
guard surface?.hasSelection() == true else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(selectionPointDescription(point))"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
if let rect = lastPointerSelectionRect {
|
||||
guard rect.insetBy(dx: -4, dy: -4).contains(point) else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(selectionPointDescription(point)) outside pointer selection"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu hit point=\(selectionPointDescription(point)) inside pointer selection"
|
||||
)
|
||||
return point
|
||||
}
|
||||
|
||||
guard surface?.selectionContainsQuicklookWord() == true else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(selectionPointDescription(point)) outside quicklook word"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu hit point=\(selectionPointDescription(point))"
|
||||
)
|
||||
return point
|
||||
}
|
||||
|
||||
open func selectionContextMenu() -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
let copyItem = NSMenuItem(
|
||||
title: "Copy",
|
||||
action: #selector(copy(_:)),
|
||||
keyEquivalent: ""
|
||||
)
|
||||
copyItem.target = self
|
||||
menu.addItem(copyItem)
|
||||
return menu
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
open func copySelectedTextToPasteboard() -> Bool {
|
||||
guard surface?.hasSelection() == true else {
|
||||
return false
|
||||
}
|
||||
guard surface?.performBindingAction("copy_to_clipboard") == true else {
|
||||
return false
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection copied to clipboard"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private func selectionPointDescription(_ point: CGPoint) -> String {
|
||||
"\(String(format: "%.2f", point.x))x\(String(format: "%.2f", point.y))"
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
22
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/KeyboardLayout.swift
vendored
Normal file
22
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/KeyboardLayout.swift
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import Carbon.HIToolbox
|
||||
|
||||
enum KeyboardLayout {
|
||||
static var id: String? {
|
||||
guard let inputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let rawProperty = TISGetInputSourceProperty(
|
||||
inputSource,
|
||||
kTISPropertyInputSourceID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let property = unsafeBitCast(rawProperty, to: CFString.self)
|
||||
return property as String
|
||||
}
|
||||
}
|
||||
#endif
|
||||
346
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalKeyEventHandler@AppKit.swift
vendored
Normal file
346
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalKeyEventHandler@AppKit.swift
vendored
Normal file
@@ -0,0 +1,346 @@
|
||||
//
|
||||
// TerminalKeyEventHandler@AppKit.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
// Reference:
|
||||
// - ghostty-org/ghostty
|
||||
// - macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
|
||||
// Translation modifiers, interpretKeyEvents dispatch, and text emission are
|
||||
// kept close to Ghostty's native AppKit implementation to reduce long-term
|
||||
// drift from upstream keyboard/IME semantics.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
final class TerminalKeyEventHandler {
|
||||
private weak var view: AppTerminalView?
|
||||
var inputMethodHandler: TerminalTextInputHandler?
|
||||
private var interpretedCommandSelector: Selector?
|
||||
|
||||
init(view: AppTerminalView) {
|
||||
self.view = view
|
||||
inputMethodHandler = TerminalTextInputHandler(view: view)
|
||||
}
|
||||
|
||||
nonisolated static func shouldUseDirectInput(
|
||||
modifierFlags: NSEvent.ModifierFlags
|
||||
) -> Bool {
|
||||
modifierFlags.intersection([.shift, .control, .option, .command]).isEmpty
|
||||
}
|
||||
|
||||
nonisolated static func shouldReplayInterpretedCommand(
|
||||
_ selector: Selector
|
||||
) -> Bool {
|
||||
// AppKit sometimes resolves non-text keys into editing commands
|
||||
// (for example Shift-Tab -> insertBacktab:). In a terminal, those
|
||||
// commands still need to reach Ghostty as the original hardware key.
|
||||
let _ = selector
|
||||
return true
|
||||
}
|
||||
|
||||
func handleKeyDown(with event: NSEvent) {
|
||||
guard let view, let surface = view.surface else { return }
|
||||
|
||||
if handleDirectInputIfNeeded(event) {
|
||||
return
|
||||
}
|
||||
|
||||
let action: ghostty_input_action_e = event.isARepeat
|
||||
? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
|
||||
let translationEvent = translatedEvent(for: event, on: surface)
|
||||
|
||||
inputMethodHandler?.startCollectingText()
|
||||
interpretedCommandSelector = nil
|
||||
let markedTextBefore = inputMethodHandler?.hasMarkedText == true
|
||||
let keyboardIdBefore = markedTextBefore ? nil : KeyboardLayout.id
|
||||
view.lastPerformKeyEvent = nil
|
||||
view.interpretKeyEvents([translationEvent])
|
||||
if !markedTextBefore, keyboardIdBefore != KeyboardLayout.id {
|
||||
_ = inputMethodHandler?.finishCollectingText()
|
||||
return
|
||||
}
|
||||
inputMethodHandler?.syncPreedit(clearIfNeeded: markedTextBefore)
|
||||
|
||||
if let collected = inputMethodHandler?.finishCollectingText() {
|
||||
var input = event.buildKeyInput(
|
||||
action: action,
|
||||
translationModifiers: translationEvent.modifierFlags
|
||||
)
|
||||
for text in collected {
|
||||
text.withCString { ptr in
|
||||
input.text = ptr
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if let selector = interpretedCommandSelector {
|
||||
interpretedCommandSelector = nil
|
||||
if Self.shouldReplayInterpretedCommand(selector) {
|
||||
sendKeyEvent(
|
||||
for: event,
|
||||
translationEvent: translationEvent,
|
||||
action: action,
|
||||
to: surface,
|
||||
includeText: false,
|
||||
composing: inputMethodHandler?.hasMarkedText == true || markedTextBefore
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sendKeyEvent(
|
||||
for: event,
|
||||
translationEvent: translationEvent,
|
||||
action: action,
|
||||
to: surface,
|
||||
includeText: true,
|
||||
composing: inputMethodHandler?.hasMarkedText == true || markedTextBefore
|
||||
)
|
||||
}
|
||||
|
||||
func handleKeyUp(with event: NSEvent) {
|
||||
guard let view, let surface = view.surface else { return }
|
||||
if shouldBypassGhosttyForDirectInput(event) {
|
||||
return
|
||||
}
|
||||
var input = event.buildKeyInput(action: GHOSTTY_ACTION_RELEASE)
|
||||
input.text = nil
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
|
||||
func handleFlagsChanged(with event: NSEvent) {
|
||||
guard let surface = view?.surface else { return }
|
||||
guard inputMethodHandler?.hasMarkedText != true else { return }
|
||||
|
||||
let mod: UInt32
|
||||
switch event.keyCode {
|
||||
case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue
|
||||
case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue
|
||||
case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue
|
||||
case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue
|
||||
case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue
|
||||
default: return
|
||||
}
|
||||
|
||||
let mods = TerminalInputModifiers(from: event.modifierFlags).ghosttyMods
|
||||
|
||||
var action = GHOSTTY_ACTION_RELEASE
|
||||
if mods.rawValue & mod != 0 {
|
||||
let sidePressed: Bool = switch event.keyCode {
|
||||
case 0x3C:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERSHIFTKEYMASK) != 0
|
||||
case 0x3E:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERCTLKEYMASK) != 0
|
||||
case 0x3D:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERALTKEYMASK) != 0
|
||||
case 0x36:
|
||||
event.modifierFlags.rawValue
|
||||
& UInt(NX_DEVICERCMDKEYMASK) != 0
|
||||
default:
|
||||
true
|
||||
}
|
||||
|
||||
if sidePressed {
|
||||
action = GHOSTTY_ACTION_PRESS
|
||||
}
|
||||
}
|
||||
|
||||
var input = event.buildKeyInput(action: action)
|
||||
input.text = nil
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
|
||||
private func sendKeyEvent(
|
||||
for event: NSEvent,
|
||||
translationEvent: NSEvent,
|
||||
action: ghostty_input_action_e,
|
||||
to surface: TerminalSurface,
|
||||
includeText: Bool,
|
||||
composing: Bool = false
|
||||
) {
|
||||
var input = event.buildKeyInput(
|
||||
action: action,
|
||||
translationModifiers: translationEvent.modifierFlags
|
||||
)
|
||||
input.composing = composing
|
||||
guard includeText,
|
||||
let chars = translationEvent.filteredCharacters,
|
||||
!chars.isEmpty
|
||||
else {
|
||||
surface.sendKeyEvent(input)
|
||||
return
|
||||
}
|
||||
|
||||
chars.withCString { ptr in
|
||||
input.text = ptr
|
||||
surface.sendKeyEvent(input)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDirectInputIfNeeded(_ event: NSEvent) -> Bool {
|
||||
guard let view else { return false }
|
||||
// During IME composition, AppKit needs to keep ownership of editing
|
||||
// commands so marked text can shrink, cancel, and move correctly.
|
||||
guard inputMethodHandler?.hasMarkedText != true else { return false }
|
||||
guard Self.shouldUseDirectInput(modifierFlags: event.modifierFlags) else {
|
||||
return false
|
||||
}
|
||||
let delivery = TerminalHardwareKeyRouter.routeAppKit(
|
||||
keyCode: event.keyCode,
|
||||
backend: view.configuration.backend
|
||||
)
|
||||
guard case let .data(sequence) = delivery else { return false }
|
||||
guard case let .inMemory(session) = view.configuration.backend else { return false }
|
||||
|
||||
session.sendInput(sequence)
|
||||
return true
|
||||
}
|
||||
|
||||
private func shouldBypassGhosttyForDirectInput(_ event: NSEvent) -> Bool {
|
||||
guard let view else { return false }
|
||||
guard Self.shouldUseDirectInput(modifierFlags: event.modifierFlags) else {
|
||||
return false
|
||||
}
|
||||
return TerminalHardwareKeyRouter.routeAppKit(
|
||||
keyCode: event.keyCode,
|
||||
backend: view.configuration.backend
|
||||
).isDirectInput
|
||||
}
|
||||
|
||||
private func translatedEvent(
|
||||
for event: NSEvent,
|
||||
on surface: TerminalSurface
|
||||
) -> NSEvent {
|
||||
guard let rawSurface = surface.rawValue else {
|
||||
return event
|
||||
}
|
||||
|
||||
let translatedMods = eventModifierFlags(
|
||||
from: ghostty_surface_key_translation_mods(
|
||||
rawSurface,
|
||||
TerminalInputModifiers(from: event.modifierFlags).ghosttyMods
|
||||
)
|
||||
)
|
||||
|
||||
var finalModifiers = event.modifierFlags
|
||||
for flag in [
|
||||
NSEvent.ModifierFlags.shift,
|
||||
.control,
|
||||
.option,
|
||||
.command,
|
||||
] {
|
||||
if translatedMods.contains(flag) {
|
||||
finalModifiers.insert(flag)
|
||||
} else {
|
||||
finalModifiers.remove(flag)
|
||||
}
|
||||
}
|
||||
|
||||
guard finalModifiers != event.modifierFlags else {
|
||||
return event
|
||||
}
|
||||
|
||||
return NSEvent.keyEvent(
|
||||
with: event.type,
|
||||
location: event.locationInWindow,
|
||||
modifierFlags: finalModifiers,
|
||||
timestamp: event.timestamp,
|
||||
windowNumber: event.windowNumber,
|
||||
context: nil,
|
||||
characters: event.characters(byApplyingModifiers: finalModifiers) ?? "",
|
||||
charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "",
|
||||
isARepeat: event.isARepeat,
|
||||
keyCode: event.keyCode
|
||||
) ?? event
|
||||
}
|
||||
|
||||
private func eventModifierFlags(from mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags {
|
||||
var flags = NSEvent.ModifierFlags()
|
||||
if mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0 { flags.insert(.shift) }
|
||||
if mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0 { flags.insert(.control) }
|
||||
if mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0 { flags.insert(.option) }
|
||||
if mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0 { flags.insert(.command) }
|
||||
return flags
|
||||
}
|
||||
|
||||
func recordInterpretedCommand(_ selector: Selector) {
|
||||
interpretedCommandSelector = selector
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSEvent Terminal Input Helpers
|
||||
|
||||
extension NSEvent {
|
||||
func buildKeyInput(
|
||||
action: ghostty_input_action_e,
|
||||
translationModifiers: NSEvent.ModifierFlags? = nil
|
||||
) -> ghostty_input_key_s {
|
||||
var input = ghostty_input_key_s()
|
||||
input.action = action
|
||||
input.keycode = UInt32(keyCode)
|
||||
input.composing = false
|
||||
input.text = nil
|
||||
|
||||
input.mods = TerminalInputModifiers(from: modifierFlags).ghosttyMods
|
||||
|
||||
// Consumed modifiers: modifiers the key binding system should
|
||||
// treat as already handled by text generation. We pass through
|
||||
// all modifiers except control and command, which should remain
|
||||
// available for keybind matching.
|
||||
var consumedFlags = translationModifiers ?? modifierFlags
|
||||
consumedFlags.remove(.control)
|
||||
consumedFlags.remove(.command)
|
||||
input.consumed_mods = TerminalInputModifiers(from: consumedFlags).ghosttyMods
|
||||
|
||||
if type == .keyDown || type == .keyUp,
|
||||
let chars = characters(byApplyingModifiers: []),
|
||||
let codepoint = chars.unicodeScalars.first
|
||||
{
|
||||
input.unshifted_codepoint = codepoint.value
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
|
||||
var filteredCharacters: String? {
|
||||
guard let filtered = TerminalInputText.filteredFunctionKeyText(characters) else {
|
||||
return nil
|
||||
}
|
||||
guard filtered.count == 1,
|
||||
let scalar = filtered.unicodeScalars.first
|
||||
else {
|
||||
return filtered
|
||||
}
|
||||
|
||||
// macOS encodes function keys as Private Use Area scalars —
|
||||
// these have no printable representation.
|
||||
// When the control modifier produces a raw control character,
|
||||
// re-derive printable text without the control modifier so
|
||||
// Ghostty can map the physical key correctly.
|
||||
if scalar.isASCIIControl {
|
||||
var flags = modifierFlags
|
||||
flags.remove(.control)
|
||||
return TerminalInputText.filteredFunctionKeyText(
|
||||
characters(byApplyingModifiers: flags)
|
||||
)
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
extension UnicodeScalar {
|
||||
var isASCIIControl: Bool {
|
||||
value < 0x20
|
||||
}
|
||||
}
|
||||
#endif
|
||||
130
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalTextInputHandler@AppKit.swift
vendored
Normal file
130
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/AppKit/TerminalTextInputHandler@AppKit.swift
vendored
Normal file
@@ -0,0 +1,130 @@
|
||||
//
|
||||
// TerminalTextInputHandler@AppKit.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
// Reference:
|
||||
// - ghostty-org/ghostty
|
||||
// - macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift
|
||||
// IME text accumulation intentionally follows Ghostty's AppKit flow:
|
||||
// marked/preedit text is passed through as-is, without extra app-specific
|
||||
// filtering, so composition behavior stays consistent with upstream.
|
||||
//
|
||||
|
||||
#if canImport(AppKit) && !canImport(UIKit)
|
||||
import AppKit
|
||||
import GhosttyKit
|
||||
|
||||
@MainActor
|
||||
final class TerminalTextInputHandler: NSObject {
|
||||
private weak var view: AppTerminalView?
|
||||
private var markedTextState = TerminalMarkedTextState()
|
||||
private var accumulatedTexts: [String]?
|
||||
|
||||
var hasMarkedText: Bool {
|
||||
markedTextState.hasMarkedText
|
||||
}
|
||||
|
||||
init(view: AppTerminalView) {
|
||||
self.view = view
|
||||
super.init()
|
||||
}
|
||||
|
||||
func startCollectingText() {
|
||||
accumulatedTexts = []
|
||||
}
|
||||
|
||||
func finishCollectingText() -> [String]? {
|
||||
defer { accumulatedTexts = nil }
|
||||
guard let texts = accumulatedTexts, !texts.isEmpty else { return nil }
|
||||
return texts
|
||||
}
|
||||
|
||||
// MARK: - Text Input
|
||||
|
||||
func insertText(_ string: Any) {
|
||||
guard NSApp.currentEvent != nil else { return }
|
||||
|
||||
let text: String
|
||||
if let attrStr = string as? NSAttributedString {
|
||||
text = attrStr.string
|
||||
} else if let str = string as? String {
|
||||
text = str
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
unmarkText()
|
||||
|
||||
if accumulatedTexts != nil {
|
||||
accumulatedTexts?.append(text)
|
||||
} else {
|
||||
view?.surface?.sendText(text)
|
||||
}
|
||||
}
|
||||
|
||||
func setMarkedText(
|
||||
_ string: Any,
|
||||
selectedRange: NSRange
|
||||
) {
|
||||
let text: String
|
||||
if let attrStr = string as? NSAttributedString {
|
||||
text = attrStr.string
|
||||
} else if let str = string as? String {
|
||||
text = str
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
markedTextState.setMarkedText(text, selectedRange: selectedRange)
|
||||
|
||||
if accumulatedTexts == nil {
|
||||
syncPreedit()
|
||||
}
|
||||
}
|
||||
|
||||
func unmarkText() {
|
||||
guard markedTextState.hasMarkedText else { return }
|
||||
markedTextState.clear()
|
||||
syncPreedit()
|
||||
}
|
||||
|
||||
func currentSelectedRange() -> NSRange {
|
||||
markedTextState.currentSelectedRange
|
||||
}
|
||||
|
||||
func markedRange() -> NSRange {
|
||||
markedTextState.markedRange
|
||||
}
|
||||
|
||||
func attributedSubstring(
|
||||
forProposedRange range: NSRange,
|
||||
actualRange: NSRangePointer?
|
||||
) -> NSAttributedString? {
|
||||
guard markedTextState.hasMarkedText else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let length = markedTextState.documentLength
|
||||
let location = min(max(range.location, 0), length)
|
||||
let end = min(max(range.location + range.length, location), length)
|
||||
let clampedRange = NSRange(location: location, length: end - location)
|
||||
actualRange?.pointee = clampedRange
|
||||
|
||||
guard let text = markedTextState.text(in: clampedRange) else {
|
||||
return nil
|
||||
}
|
||||
return NSAttributedString(string: text)
|
||||
}
|
||||
|
||||
func syncPreedit(clearIfNeeded: Bool = true) {
|
||||
guard let text = markedTextState.text else {
|
||||
guard clearIfNeeded else { return }
|
||||
view?.surface?.preedit("")
|
||||
return
|
||||
}
|
||||
|
||||
view?.surface?.preedit(text)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
367
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalHardwareKeyRouter.swift
vendored
Normal file
367
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalHardwareKeyRouter.swift
vendored
Normal file
@@ -0,0 +1,367 @@
|
||||
//
|
||||
// TerminalHardwareKeyRouter.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import GhosttyKit
|
||||
|
||||
enum TerminalHardwareKeyDelivery: Equatable {
|
||||
case ghostty(ghostty_input_key_e)
|
||||
case data(Data)
|
||||
|
||||
var isDirectInput: Bool {
|
||||
if case .data = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum TerminalHardwareKeyRouter {
|
||||
static func routeUIKit(
|
||||
usage: UInt16,
|
||||
backend: TerminalSessionBackend
|
||||
) -> TerminalHardwareKeyDelivery {
|
||||
if case .inMemory = backend,
|
||||
let data = directControlInputForUIKit(usage: usage)
|
||||
{
|
||||
return .data(data)
|
||||
}
|
||||
|
||||
return .ghostty(ghosttyKeyForUIKit(usage: usage))
|
||||
}
|
||||
|
||||
static func routeUIKit(
|
||||
usage: UInt16,
|
||||
backend: TerminalSessionBackend,
|
||||
modifiers: TerminalInputModifiers
|
||||
) -> TerminalHardwareKeyDelivery {
|
||||
// Raw host-managed bytes only represent the unmodified control key.
|
||||
// Modified synthetic accessory keys need a real Ghostty key event so
|
||||
// the backend can emit the correct escape sequence for those modifiers.
|
||||
guard modifiers.isEmpty else {
|
||||
return .ghostty(ghosttyKeyForUIKit(usage: usage))
|
||||
}
|
||||
return routeUIKit(usage: usage, backend: backend)
|
||||
}
|
||||
|
||||
static func routeAppKit(
|
||||
keyCode: UInt16,
|
||||
backend: TerminalSessionBackend
|
||||
) -> TerminalHardwareKeyDelivery {
|
||||
if case .inMemory = backend,
|
||||
let data = directControlInputForAppKit(keyCode: keyCode)
|
||||
{
|
||||
return .data(data)
|
||||
}
|
||||
|
||||
return .ghostty(ghosttyKeyForAppKit(keyCode: keyCode))
|
||||
}
|
||||
|
||||
private static func directControlInputForUIKit(usage: UInt16) -> Data? {
|
||||
switch usage {
|
||||
case 0x2A:
|
||||
Data([0x7F])
|
||||
case 0x2B:
|
||||
Data([0x09])
|
||||
case 0x4C:
|
||||
Data("\u{1B}[3~".utf8)
|
||||
case 0x4A:
|
||||
Data("\u{1B}[H".utf8)
|
||||
case 0x4D:
|
||||
Data("\u{1B}[F".utf8)
|
||||
case 0x4B:
|
||||
Data("\u{1B}[5~".utf8)
|
||||
case 0x4E:
|
||||
Data("\u{1B}[6~".utf8)
|
||||
case 0x4F:
|
||||
Data("\u{1B}[C".utf8)
|
||||
case 0x50:
|
||||
Data("\u{1B}[D".utf8)
|
||||
case 0x51:
|
||||
Data("\u{1B}[B".utf8)
|
||||
case 0x52:
|
||||
Data("\u{1B}[A".utf8)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func directControlInputForAppKit(keyCode: UInt16) -> Data? {
|
||||
switch keyCode {
|
||||
case 0x33:
|
||||
Data([0x7F])
|
||||
case 0x30:
|
||||
Data([0x09])
|
||||
case 0x75:
|
||||
Data("\u{1B}[3~".utf8)
|
||||
case 0x73:
|
||||
Data("\u{1B}[H".utf8)
|
||||
case 0x77:
|
||||
Data("\u{1B}[F".utf8)
|
||||
case 0x74:
|
||||
Data("\u{1B}[5~".utf8)
|
||||
case 0x79:
|
||||
Data("\u{1B}[6~".utf8)
|
||||
case 0x7B:
|
||||
Data("\u{1B}[D".utf8)
|
||||
case 0x7C:
|
||||
Data("\u{1B}[C".utf8)
|
||||
case 0x7D:
|
||||
Data("\u{1B}[B".utf8)
|
||||
case 0x7E:
|
||||
Data("\u{1B}[A".utf8)
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func ghosttyKeyForUIKit(usage: UInt16) -> ghostty_input_key_e {
|
||||
uiKitMap[usage] ?? GHOSTTY_KEY_UNIDENTIFIED
|
||||
}
|
||||
|
||||
private static func ghosttyKeyForAppKit(keyCode: UInt16) -> ghostty_input_key_e {
|
||||
appKitMap[keyCode] ?? GHOSTTY_KEY_UNIDENTIFIED
|
||||
}
|
||||
|
||||
/// Sentinel `keycode` value for keys that have no macOS AppKit
|
||||
/// equivalent (e.g. CUT/COPY/PASTE, media keys, CONTEXT_MENU, INSERT on
|
||||
/// PC keyboards). Any value outside the 8-bit AppKit virtual keycode
|
||||
/// range falls out of libghostty's native-keycode lookup and resolves
|
||||
/// to `.unidentified`. The pinned Ghostty keycode table uses 8-bit macOS
|
||||
/// keycodes, so `0x1_0000` stays safely outside the native range. Using
|
||||
/// plain `0` would instead collide with AppKit's keycode for the `A` key.
|
||||
static let unidentifiedAppKitKeyCode: UInt32 = 0x10000
|
||||
|
||||
/// Translate a Ghostty key enum to the macOS AppKit virtual keycode
|
||||
/// for the same physical key. This is used by synthetic UIKit key
|
||||
/// events that already know the logical Ghostty key but still need to
|
||||
/// satisfy libghostty's native-keycode contract.
|
||||
static func appKitKeyCode(for ghosttyKey: ghostty_input_key_e) -> UInt32 {
|
||||
guard let macKeyCode = ghosttyKeyToAppKitCode[ghosttyKey.rawValue]
|
||||
else { return unidentifiedAppKitKeyCode }
|
||||
return UInt32(macKeyCode)
|
||||
}
|
||||
|
||||
/// Translate a UIKit (USB HID) usage code to the macOS AppKit virtual
|
||||
/// keycode for the same physical key. Libghostty's keycode lookup
|
||||
/// (`src/input/keycodes.zig`) uses macOS keycodes on both macOS and iOS
|
||||
/// builds, so UIKit callers need to translate HID → mac before handing
|
||||
/// the keycode to `ghostty_surface_key`. Returns
|
||||
/// `unidentifiedAppKitKeyCode` for HID usages with no AppKit counterpart
|
||||
/// (keys that do not exist on Mac keyboards).
|
||||
static func appKitKeyCodeForUIKit(usage: UInt16) -> UInt32 {
|
||||
// Prefer explicit HID -> AppKit overrides before falling back to the
|
||||
// shared Ghostty key mapping. Some physical keys do not share the same
|
||||
// logical Ghostty enum on UIKit and AppKit.
|
||||
if let macKeyCode = uiKitToAppKitKeyCodeOverrides[usage] {
|
||||
return UInt32(macKeyCode)
|
||||
}
|
||||
guard let ghosttyKey = uiKitMap[usage]
|
||||
else { return unidentifiedAppKitKeyCode }
|
||||
return appKitKeyCode(for: ghosttyKey)
|
||||
}
|
||||
|
||||
private static let ghosttyKeyToAppKitCode: [UInt32: UInt16] = {
|
||||
var result: [UInt32: UInt16] = [:]
|
||||
for (code, key) in appKitMap {
|
||||
result[key.rawValue] = code
|
||||
}
|
||||
return result
|
||||
}()
|
||||
|
||||
/// UIKit and AppKit do not always use the same logical Ghostty key for the
|
||||
/// same physical key. Prefer the AppKit keycode directly for those cases.
|
||||
private static let uiKitToAppKitKeyCodeOverrides: [UInt16: UInt16] = [
|
||||
// keyboardNumLock -> kVK_ANSI_KeypadClear
|
||||
0x53: 0x47,
|
||||
// keyboardNonUSBackslash -> kVK_ISO_Section
|
||||
0x64: 0x0A,
|
||||
]
|
||||
|
||||
private typealias Pair = (UInt16, ghostty_input_key_e)
|
||||
|
||||
private static let uiKitMap = buildMap(
|
||||
literalPairs: [
|
||||
(0x28, GHOSTTY_KEY_ENTER),
|
||||
(0x29, GHOSTTY_KEY_ESCAPE),
|
||||
(0x2A, GHOSTTY_KEY_BACKSPACE),
|
||||
(0x2B, GHOSTTY_KEY_TAB),
|
||||
(0x2C, GHOSTTY_KEY_SPACE),
|
||||
(0x2D, GHOSTTY_KEY_MINUS),
|
||||
(0x2E, GHOSTTY_KEY_EQUAL),
|
||||
(0x2F, GHOSTTY_KEY_BRACKET_LEFT),
|
||||
(0x30, GHOSTTY_KEY_BRACKET_RIGHT),
|
||||
(0x31, GHOSTTY_KEY_BACKSLASH),
|
||||
(0x33, GHOSTTY_KEY_SEMICOLON),
|
||||
(0x34, GHOSTTY_KEY_QUOTE),
|
||||
(0x35, GHOSTTY_KEY_BACKQUOTE),
|
||||
(0x36, GHOSTTY_KEY_COMMA),
|
||||
(0x37, GHOSTTY_KEY_PERIOD),
|
||||
(0x38, GHOSTTY_KEY_SLASH),
|
||||
(0x39, GHOSTTY_KEY_CAPS_LOCK),
|
||||
(0x46, GHOSTTY_KEY_PRINT_SCREEN),
|
||||
(0x47, GHOSTTY_KEY_SCROLL_LOCK),
|
||||
(0x48, GHOSTTY_KEY_PAUSE),
|
||||
(0x49, GHOSTTY_KEY_INSERT),
|
||||
(0x4A, GHOSTTY_KEY_HOME),
|
||||
(0x4B, GHOSTTY_KEY_PAGE_UP),
|
||||
(0x4C, GHOSTTY_KEY_DELETE),
|
||||
(0x4D, GHOSTTY_KEY_END),
|
||||
(0x4E, GHOSTTY_KEY_PAGE_DOWN),
|
||||
(0x4F, GHOSTTY_KEY_ARROW_RIGHT),
|
||||
(0x50, GHOSTTY_KEY_ARROW_LEFT),
|
||||
(0x51, GHOSTTY_KEY_ARROW_DOWN),
|
||||
(0x52, GHOSTTY_KEY_ARROW_UP),
|
||||
(0x53, GHOSTTY_KEY_NUM_LOCK),
|
||||
(0x54, GHOSTTY_KEY_NUMPAD_DIVIDE),
|
||||
(0x55, GHOSTTY_KEY_NUMPAD_MULTIPLY),
|
||||
(0x56, GHOSTTY_KEY_NUMPAD_SUBTRACT),
|
||||
(0x57, GHOSTTY_KEY_NUMPAD_ADD),
|
||||
(0x58, GHOSTTY_KEY_NUMPAD_ENTER),
|
||||
(0x64, GHOSTTY_KEY_INTL_BACKSLASH),
|
||||
(0x65, GHOSTTY_KEY_CONTEXT_MENU),
|
||||
(0x67, GHOSTTY_KEY_NUMPAD_EQUAL),
|
||||
(0x75, GHOSTTY_KEY_HELP),
|
||||
(0x7B, GHOSTTY_KEY_CUT),
|
||||
(0x7C, GHOSTTY_KEY_COPY),
|
||||
(0x7D, GHOSTTY_KEY_PASTE),
|
||||
(0x7F, GHOSTTY_KEY_AUDIO_VOLUME_MUTE),
|
||||
(0x80, GHOSTTY_KEY_AUDIO_VOLUME_UP),
|
||||
(0x81, GHOSTTY_KEY_AUDIO_VOLUME_DOWN),
|
||||
(0xE0, GHOSTTY_KEY_CONTROL_LEFT),
|
||||
(0xE1, GHOSTTY_KEY_SHIFT_LEFT),
|
||||
(0xE2, GHOSTTY_KEY_ALT_LEFT),
|
||||
(0xE3, GHOSTTY_KEY_META_LEFT),
|
||||
(0xE4, GHOSTTY_KEY_CONTROL_RIGHT),
|
||||
(0xE5, GHOSTTY_KEY_SHIFT_RIGHT),
|
||||
(0xE6, GHOSTTY_KEY_ALT_RIGHT),
|
||||
(0xE7, GHOSTTY_KEY_META_RIGHT),
|
||||
],
|
||||
groupedPairs: [
|
||||
makeRun(
|
||||
startingAt: 0x04,
|
||||
keys: [
|
||||
GHOSTTY_KEY_A, GHOSTTY_KEY_B, GHOSTTY_KEY_C, GHOSTTY_KEY_D,
|
||||
GHOSTTY_KEY_E, GHOSTTY_KEY_F, GHOSTTY_KEY_G, GHOSTTY_KEY_H,
|
||||
GHOSTTY_KEY_I, GHOSTTY_KEY_J, GHOSTTY_KEY_K, GHOSTTY_KEY_L,
|
||||
GHOSTTY_KEY_M, GHOSTTY_KEY_N, GHOSTTY_KEY_O, GHOSTTY_KEY_P,
|
||||
GHOSTTY_KEY_Q, GHOSTTY_KEY_R, GHOSTTY_KEY_S, GHOSTTY_KEY_T,
|
||||
GHOSTTY_KEY_U, GHOSTTY_KEY_V, GHOSTTY_KEY_W, GHOSTTY_KEY_X,
|
||||
GHOSTTY_KEY_Y, GHOSTTY_KEY_Z,
|
||||
]
|
||||
),
|
||||
makeRun(
|
||||
startingAt: 0x1E,
|
||||
keys: [
|
||||
GHOSTTY_KEY_DIGIT_1, GHOSTTY_KEY_DIGIT_2, GHOSTTY_KEY_DIGIT_3,
|
||||
GHOSTTY_KEY_DIGIT_4, GHOSTTY_KEY_DIGIT_5, GHOSTTY_KEY_DIGIT_6,
|
||||
GHOSTTY_KEY_DIGIT_7, GHOSTTY_KEY_DIGIT_8, GHOSTTY_KEY_DIGIT_9,
|
||||
GHOSTTY_KEY_DIGIT_0,
|
||||
]
|
||||
),
|
||||
makeRun(
|
||||
startingAt: 0x3A,
|
||||
keys: [
|
||||
GHOSTTY_KEY_F1, GHOSTTY_KEY_F2, GHOSTTY_KEY_F3, GHOSTTY_KEY_F4,
|
||||
GHOSTTY_KEY_F5, GHOSTTY_KEY_F6, GHOSTTY_KEY_F7, GHOSTTY_KEY_F8,
|
||||
GHOSTTY_KEY_F9, GHOSTTY_KEY_F10, GHOSTTY_KEY_F11, GHOSTTY_KEY_F12,
|
||||
]
|
||||
),
|
||||
makeRun(
|
||||
startingAt: 0x59,
|
||||
keys: [
|
||||
GHOSTTY_KEY_NUMPAD_1, GHOSTTY_KEY_NUMPAD_2, GHOSTTY_KEY_NUMPAD_3,
|
||||
GHOSTTY_KEY_NUMPAD_4, GHOSTTY_KEY_NUMPAD_5, GHOSTTY_KEY_NUMPAD_6,
|
||||
GHOSTTY_KEY_NUMPAD_7, GHOSTTY_KEY_NUMPAD_8, GHOSTTY_KEY_NUMPAD_9,
|
||||
GHOSTTY_KEY_NUMPAD_0, GHOSTTY_KEY_NUMPAD_DECIMAL,
|
||||
]
|
||||
),
|
||||
makeRun(
|
||||
startingAt: 0x68,
|
||||
keys: [
|
||||
GHOSTTY_KEY_F13, GHOSTTY_KEY_F14, GHOSTTY_KEY_F15, GHOSTTY_KEY_F16,
|
||||
GHOSTTY_KEY_F17, GHOSTTY_KEY_F18, GHOSTTY_KEY_F19, GHOSTTY_KEY_F20,
|
||||
GHOSTTY_KEY_F21, GHOSTTY_KEY_F22, GHOSTTY_KEY_F23, GHOSTTY_KEY_F24,
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
/// JIS keyboard entries are still absent from this table:
|
||||
/// (0x5D, GHOSTTY_KEY_INTL_YEN) // kVK_JIS_Yen
|
||||
/// (0x5E, GHOSTTY_KEY_INTL_RO) // kVK_JIS_Underscore
|
||||
private static let appKitMap = buildMap(
|
||||
literalPairs: [
|
||||
(0x00, GHOSTTY_KEY_A), (0x01, GHOSTTY_KEY_S), (0x02, GHOSTTY_KEY_D),
|
||||
(0x03, GHOSTTY_KEY_F), (0x04, GHOSTTY_KEY_H), (0x05, GHOSTTY_KEY_G),
|
||||
(0x06, GHOSTTY_KEY_Z), (0x07, GHOSTTY_KEY_X), (0x08, GHOSTTY_KEY_C),
|
||||
(0x09, GHOSTTY_KEY_V),
|
||||
(0x0B, GHOSTTY_KEY_B), (0x0C, GHOSTTY_KEY_Q),
|
||||
(0x0D, GHOSTTY_KEY_W), (0x0E, GHOSTTY_KEY_E), (0x0F, GHOSTTY_KEY_R),
|
||||
(0x10, GHOSTTY_KEY_Y), (0x11, GHOSTTY_KEY_T), (0x12, GHOSTTY_KEY_DIGIT_1),
|
||||
(0x13, GHOSTTY_KEY_DIGIT_2), (0x14, GHOSTTY_KEY_DIGIT_3), (0x15, GHOSTTY_KEY_DIGIT_4),
|
||||
(0x16, GHOSTTY_KEY_DIGIT_6), (0x17, GHOSTTY_KEY_DIGIT_5), (0x18, GHOSTTY_KEY_EQUAL),
|
||||
(0x19, GHOSTTY_KEY_DIGIT_9), (0x1A, GHOSTTY_KEY_DIGIT_7), (0x1B, GHOSTTY_KEY_MINUS),
|
||||
(0x1C, GHOSTTY_KEY_DIGIT_8), (0x1D, GHOSTTY_KEY_DIGIT_0), (0x1E, GHOSTTY_KEY_BRACKET_RIGHT),
|
||||
(0x1F, GHOSTTY_KEY_O), (0x20, GHOSTTY_KEY_U), (0x21, GHOSTTY_KEY_BRACKET_LEFT),
|
||||
(0x22, GHOSTTY_KEY_I), (0x23, GHOSTTY_KEY_P), (0x24, GHOSTTY_KEY_ENTER),
|
||||
(0x25, GHOSTTY_KEY_L), (0x26, GHOSTTY_KEY_J), (0x27, GHOSTTY_KEY_QUOTE),
|
||||
(0x28, GHOSTTY_KEY_K), (0x29, GHOSTTY_KEY_SEMICOLON), (0x2A, GHOSTTY_KEY_BACKSLASH),
|
||||
(0x2B, GHOSTTY_KEY_COMMA), (0x2C, GHOSTTY_KEY_SLASH), (0x2D, GHOSTTY_KEY_N),
|
||||
(0x2E, GHOSTTY_KEY_M), (0x2F, GHOSTTY_KEY_PERIOD), (0x30, GHOSTTY_KEY_TAB),
|
||||
(0x31, GHOSTTY_KEY_SPACE), (0x32, GHOSTTY_KEY_BACKQUOTE), (0x33, GHOSTTY_KEY_BACKSPACE),
|
||||
(0x35, GHOSTTY_KEY_ESCAPE), (0x36, GHOSTTY_KEY_META_RIGHT), (0x37, GHOSTTY_KEY_META_LEFT),
|
||||
(0x38, GHOSTTY_KEY_SHIFT_LEFT), (0x39, GHOSTTY_KEY_CAPS_LOCK), (0x3A, GHOSTTY_KEY_ALT_LEFT),
|
||||
(0x3B, GHOSTTY_KEY_CONTROL_LEFT), (0x3C, GHOSTTY_KEY_SHIFT_RIGHT), (0x3D, GHOSTTY_KEY_ALT_RIGHT),
|
||||
(0x3E, GHOSTTY_KEY_CONTROL_RIGHT), (0x3F, GHOSTTY_KEY_FN), (0x40, GHOSTTY_KEY_F17),
|
||||
(0x41, GHOSTTY_KEY_NUMPAD_DECIMAL),
|
||||
(0x43, GHOSTTY_KEY_NUMPAD_MULTIPLY), (0x45, GHOSTTY_KEY_NUMPAD_ADD), (0x47, GHOSTTY_KEY_NUMPAD_CLEAR),
|
||||
(0x48, GHOSTTY_KEY_AUDIO_VOLUME_UP), (0x49, GHOSTTY_KEY_AUDIO_VOLUME_DOWN),
|
||||
(0x4A, GHOSTTY_KEY_AUDIO_VOLUME_MUTE), (0x4B, GHOSTTY_KEY_NUMPAD_DIVIDE),
|
||||
(0x4C, GHOSTTY_KEY_NUMPAD_ENTER), (0x4E, GHOSTTY_KEY_NUMPAD_SUBTRACT),
|
||||
(0x4F, GHOSTTY_KEY_F18), (0x50, GHOSTTY_KEY_F19), (0x51, GHOSTTY_KEY_NUMPAD_EQUAL),
|
||||
(0x52, GHOSTTY_KEY_NUMPAD_0), (0x53, GHOSTTY_KEY_NUMPAD_1),
|
||||
(0x54, GHOSTTY_KEY_NUMPAD_2), (0x55, GHOSTTY_KEY_NUMPAD_3), (0x56, GHOSTTY_KEY_NUMPAD_4),
|
||||
(0x57, GHOSTTY_KEY_NUMPAD_5), (0x58, GHOSTTY_KEY_NUMPAD_6), (0x59, GHOSTTY_KEY_NUMPAD_7),
|
||||
(0x5A, GHOSTTY_KEY_F20),
|
||||
(0x5B, GHOSTTY_KEY_NUMPAD_8), (0x5C, GHOSTTY_KEY_NUMPAD_9), (0x60, GHOSTTY_KEY_F5),
|
||||
(0x61, GHOSTTY_KEY_F6), (0x62, GHOSTTY_KEY_F7), (0x63, GHOSTTY_KEY_F3),
|
||||
(0x64, GHOSTTY_KEY_F8), (0x65, GHOSTTY_KEY_F9), (0x67, GHOSTTY_KEY_F11),
|
||||
(0x69, GHOSTTY_KEY_F13), (0x6A, GHOSTTY_KEY_F16), (0x6B, GHOSTTY_KEY_F14),
|
||||
(0x6D, GHOSTTY_KEY_F10), (0x6F, GHOSTTY_KEY_F12), (0x71, GHOSTTY_KEY_F15),
|
||||
(0x72, GHOSTTY_KEY_HELP), (0x73, GHOSTTY_KEY_HOME), (0x74, GHOSTTY_KEY_PAGE_UP),
|
||||
(0x75, GHOSTTY_KEY_DELETE), (0x76, GHOSTTY_KEY_F4), (0x77, GHOSTTY_KEY_END),
|
||||
(0x78, GHOSTTY_KEY_F2), (0x79, GHOSTTY_KEY_PAGE_DOWN), (0x7A, GHOSTTY_KEY_F1),
|
||||
(0x7B, GHOSTTY_KEY_ARROW_LEFT), (0x7C, GHOSTTY_KEY_ARROW_RIGHT),
|
||||
(0x7D, GHOSTTY_KEY_ARROW_DOWN), (0x7E, GHOSTTY_KEY_ARROW_UP),
|
||||
],
|
||||
groupedPairs: []
|
||||
)
|
||||
|
||||
private static func buildMap(
|
||||
literalPairs: [Pair],
|
||||
groupedPairs: [[Pair]]
|
||||
) -> [UInt16: ghostty_input_key_e] {
|
||||
var map: [UInt16: ghostty_input_key_e] = [:]
|
||||
for (code, key) in literalPairs {
|
||||
map[code] = key
|
||||
}
|
||||
for group in groupedPairs {
|
||||
for (code, key) in group {
|
||||
map[code] = key
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
private static func makeRun(
|
||||
startingAt code: UInt16,
|
||||
keys: [ghostty_input_key_e]
|
||||
) -> [Pair] {
|
||||
keys.enumerated().map { offset, key in
|
||||
(code + UInt16(offset), key)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalInputText.swift
vendored
Normal file
45
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalInputText.swift
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// TerminalInputText.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Reference:
|
||||
// - ghostty-org/ghostty
|
||||
// - macos/Sources/Ghostty/NSEvent+Extension.swift
|
||||
// Keep the AppKit text filtering here aligned with Ghostty's native
|
||||
// `ghosttyCharacters` behavior so future upstream syncs stay mechanical.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum TerminalInputText {
|
||||
static func filteredFunctionKeyText(_ text: String?) -> String? {
|
||||
guard let text else { return nil }
|
||||
if isUIKitNamedFunctionKey(text) {
|
||||
return nil
|
||||
}
|
||||
guard text.count == 1, let scalar = text.unicodeScalars.first else {
|
||||
return text
|
||||
}
|
||||
|
||||
if isPrivateUseFunctionKey(scalar) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
static func lineCount(in text: String) -> Int {
|
||||
text.reduce(into: 0) { count, character in
|
||||
if character == "\n" {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func isPrivateUseFunctionKey(_ scalar: UnicodeScalar) -> Bool {
|
||||
scalar.value >= 0xF700 && scalar.value <= 0xF8FF
|
||||
}
|
||||
|
||||
static func isUIKitNamedFunctionKey(_ text: String) -> Bool {
|
||||
text.hasPrefix("UIKeyInput")
|
||||
}
|
||||
}
|
||||
19
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalMainActor.swift
vendored
Normal file
19
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalMainActor.swift
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
@inline(__always)
|
||||
func terminalRunOnMain(
|
||||
_ operation: @escaping @MainActor () -> Void
|
||||
) {
|
||||
if Thread.isMainThread {
|
||||
MainActor.assumeIsolated {
|
||||
operation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
MainActor.assumeIsolated {
|
||||
operation()
|
||||
}
|
||||
}
|
||||
}
|
||||
86
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalMarkedTextState.swift
vendored
Normal file
86
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalMarkedTextState.swift
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
|
||||
/// Owns the platform-agnostic state for IME marked text so AppKit and UIKit
|
||||
/// can share one editing model.
|
||||
struct TerminalMarkedTextState {
|
||||
private(set) var text: String?
|
||||
private(set) var selectedRange = NSRange(location: 0, length: 0)
|
||||
|
||||
var hasMarkedText: Bool {
|
||||
guard let text else { return false }
|
||||
return !text.isEmpty
|
||||
}
|
||||
|
||||
var documentLength: Int {
|
||||
text?.utf16.count ?? 0
|
||||
}
|
||||
|
||||
var markedRange: NSRange {
|
||||
guard hasMarkedText else {
|
||||
return NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
return NSRange(location: 0, length: documentLength)
|
||||
}
|
||||
|
||||
var currentSelectedRange: NSRange {
|
||||
guard hasMarkedText else {
|
||||
return NSRange(location: NSNotFound, length: 0)
|
||||
}
|
||||
return selectedRange
|
||||
}
|
||||
|
||||
mutating func setMarkedText(_ text: String?, selectedRange: NSRange) {
|
||||
let normalizedText = text.flatMap { $0.isEmpty ? nil : $0 }
|
||||
self.text = normalizedText
|
||||
self.selectedRange = clampedSelectedRange(selectedRange, in: normalizedText)
|
||||
}
|
||||
|
||||
mutating func clear() {
|
||||
text = nil
|
||||
selectedRange = NSRange(location: 0, length: 0)
|
||||
}
|
||||
|
||||
mutating func deleteBackward() -> Bool {
|
||||
guard let text, !text.isEmpty else { return false }
|
||||
|
||||
let mutableText = NSMutableString(string: text)
|
||||
if selectedRange.length > 0 {
|
||||
mutableText.deleteCharacters(in: selectedRange)
|
||||
selectedRange = NSRange(location: selectedRange.location, length: 0)
|
||||
} else if selectedRange.location > 0 {
|
||||
let deletionRange = NSRange(location: selectedRange.location - 1, length: 1)
|
||||
mutableText.deleteCharacters(in: deletionRange)
|
||||
selectedRange = NSRange(location: deletionRange.location, length: 0)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
||||
let updatedText = mutableText as String
|
||||
self.text = updatedText.isEmpty ? nil : updatedText
|
||||
if self.text == nil {
|
||||
selectedRange = NSRange(location: 0, length: 0)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func text(in range: NSRange) -> String? {
|
||||
guard let text else {
|
||||
return range.length == 0 ? "" : nil
|
||||
}
|
||||
|
||||
let nsText = text as NSString
|
||||
guard range.location >= 0, range.length >= 0 else { return nil }
|
||||
guard range.location + range.length <= nsText.length else { return nil }
|
||||
return nsText.substring(with: range)
|
||||
}
|
||||
|
||||
private func clampedSelectedRange(
|
||||
_ range: NSRange,
|
||||
in text: String?
|
||||
) -> NSRange {
|
||||
let length = text?.utf16.count ?? 0
|
||||
let location = min(max(range.location, 0), length)
|
||||
let end = min(max(range.location + range.length, location), length)
|
||||
return NSRange(location: location, length: end - location)
|
||||
}
|
||||
}
|
||||
27
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalView+Process.swift
vendored
Normal file
27
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/Shared/TerminalView+Process.swift
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
//
|
||||
// TerminalView+Process.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Public read access to the pty's foreground process, available on both
|
||||
// AppKit (`AppTerminalView`) and UIKit (`UITerminalView`) hosts via the
|
||||
// `TerminalView` typealias. Both concrete views expose the same internal
|
||||
// `surface` accessor, so a single extension covers every platform.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension TerminalView {
|
||||
/// PID of the pty's foreground process group (`tcgetpgrp(pty)`). When the
|
||||
/// user runs a program in the pty this is that program's pid, so hosts can
|
||||
/// correlate the surface with an external process list. Nil until the
|
||||
/// surface has a process.
|
||||
var foregroundPid: pid_t? {
|
||||
surface?.foregroundPid
|
||||
}
|
||||
|
||||
/// Name of the pty's controlling tty (e.g. `/dev/ttys004`), or nil until
|
||||
/// the surface has a process.
|
||||
var ttyName: String? {
|
||||
surface?.ttyName
|
||||
}
|
||||
}
|
||||
29
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputAccessoryStyle.swift
vendored
Normal file
29
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputAccessoryStyle.swift
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// TerminalInputAccessoryStyle.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
|
||||
public struct TerminalInputAccessoryStyle: Sendable {
|
||||
public var regularBackground: UIColor
|
||||
public var regularForeground: UIColor
|
||||
public var activeBackground: UIColor
|
||||
public var activeForeground: UIColor
|
||||
|
||||
public init(
|
||||
regularBackground: UIColor = UIColor.systemGray5.withAlphaComponent(0.92),
|
||||
regularForeground: UIColor = .label,
|
||||
activeBackground: UIColor = .systemBlue,
|
||||
activeForeground: UIColor = .white
|
||||
) {
|
||||
self.regularBackground = regularBackground
|
||||
self.regularForeground = regularForeground
|
||||
self.activeBackground = activeBackground
|
||||
self.activeForeground = activeForeground
|
||||
}
|
||||
|
||||
public static let `default` = TerminalInputAccessoryStyle()
|
||||
}
|
||||
#endif
|
||||
400
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputAccessoryView.swift
vendored
Normal file
400
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputAccessoryView.swift
vendored
Normal file
@@ -0,0 +1,400 @@
|
||||
//
|
||||
// TerminalInputAccessoryView.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class TerminalInputAccessoryView: UIView {
|
||||
weak var terminalView: UITerminalView?
|
||||
|
||||
var style: TerminalInputAccessoryStyle = .default {
|
||||
didSet { refreshContent() }
|
||||
}
|
||||
|
||||
private let barHeight: CGFloat = 52
|
||||
private let buttonSize: CGFloat = 36
|
||||
|
||||
private lazy var blurView = UIVisualEffectView(
|
||||
effect: makeBarEffect()
|
||||
)
|
||||
private let scrollView = UIScrollView()
|
||||
private let stackView = UIStackView()
|
||||
private var blurLeadingConstraint: NSLayoutConstraint?
|
||||
private var blurTrailingConstraint: NSLayoutConstraint?
|
||||
private var blurTopConstraint: NSLayoutConstraint?
|
||||
private var blurBottomConstraint: NSLayoutConstraint?
|
||||
private var keyButtons: [AccessoryButton] = []
|
||||
private var modifierButtons: [(TerminalStickyModifierState.Modifier, AccessoryButton)] = []
|
||||
|
||||
init(terminalView: UITerminalView) {
|
||||
self.terminalView = terminalView
|
||||
super.init(
|
||||
frame: CGRect(
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: Self.preferredHeight(for: barHeight)
|
||||
)
|
||||
)
|
||||
autoresizingMask = .flexibleWidth
|
||||
setupViews()
|
||||
applyBarChrome()
|
||||
refreshContent()
|
||||
terminalView.stickyModifiers.onChange = { [weak self] in
|
||||
self?.refreshContent()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: UIView.noIntrinsicMetric, height: preferredHeight)
|
||||
}
|
||||
|
||||
func refreshContent() {
|
||||
let hasMarkedText = terminalView?.inputHandler.hasMarkedText ?? false
|
||||
let ctrlActivation = terminalView?.stickyModifiers.ctrl ?? .inactive
|
||||
let altActivation = terminalView?.stickyModifiers.alt ?? .inactive
|
||||
let commandActivation = terminalView?.stickyModifiers.command ?? .inactive
|
||||
|
||||
keyButtons.forEach { $0.applyRegularStyle(style) }
|
||||
|
||||
for (modifier, button) in modifierButtons {
|
||||
let activation = switch modifier {
|
||||
case .ctrl: ctrlActivation
|
||||
case .alt: altActivation
|
||||
case .command: commandActivation
|
||||
}
|
||||
button.applyModifierStyle(activation, isDisabled: hasMarkedText, style: style)
|
||||
}
|
||||
}
|
||||
|
||||
func rebuildContent() {
|
||||
stackView.arrangedSubviews.forEach { view in
|
||||
stackView.removeArrangedSubview(view)
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
keyButtons.removeAll()
|
||||
modifierButtons.removeAll()
|
||||
|
||||
let items = terminalView?.inputAccessoryItems ?? TerminalInputAccessoryItem.defaultItems
|
||||
addArrangedViews(items.map(makeView(for:)))
|
||||
refreshContent()
|
||||
}
|
||||
|
||||
private func setupViews() {
|
||||
backgroundColor = .clear
|
||||
|
||||
blurView.translatesAutoresizingMaskIntoConstraints = false
|
||||
blurView.clipsToBounds = true
|
||||
addSubview(blurView)
|
||||
|
||||
let leading = blurView.leadingAnchor.constraint(equalTo: leadingAnchor)
|
||||
let trailing = blurView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
let top = blurView.topAnchor.constraint(equalTo: topAnchor)
|
||||
let bottom = blurView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
blurLeadingConstraint = leading
|
||||
blurTrailingConstraint = trailing
|
||||
blurTopConstraint = top
|
||||
blurBottomConstraint = bottom
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
leading,
|
||||
trailing,
|
||||
top,
|
||||
bottom,
|
||||
blurView.heightAnchor.constraint(equalToConstant: barHeight),
|
||||
])
|
||||
|
||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.alwaysBounceHorizontal = true
|
||||
scrollView.clipsToBounds = true
|
||||
blurView.contentView.addSubview(scrollView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scrollView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor),
|
||||
scrollView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor),
|
||||
scrollView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor),
|
||||
])
|
||||
|
||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||
stackView.axis = .horizontal
|
||||
stackView.alignment = .center
|
||||
stackView.spacing = 8
|
||||
scrollView.addSubview(stackView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 10),
|
||||
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -10),
|
||||
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
|
||||
stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
|
||||
stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor),
|
||||
])
|
||||
|
||||
rebuildContent()
|
||||
}
|
||||
|
||||
private func addArrangedViews(_ views: [UIView]) {
|
||||
views.forEach { stackView.addArrangedSubview($0) }
|
||||
}
|
||||
|
||||
private func makeDivider() -> UIView {
|
||||
let view = UIView()
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.backgroundColor = .secondaryLabel.withAlphaComponent(0.28)
|
||||
view.layer.cornerRadius = 3
|
||||
NSLayoutConstraint.activate([
|
||||
view.widthAnchor.constraint(equalToConstant: 6),
|
||||
view.heightAnchor.constraint(equalToConstant: 6),
|
||||
])
|
||||
return view
|
||||
}
|
||||
|
||||
private func makeView(for item: TerminalInputAccessoryItem) -> UIView {
|
||||
switch item {
|
||||
case .esc:
|
||||
makeTrackedKeyButton(title: "Escape", systemImage: "escape", key: .esc)
|
||||
|
||||
case .ctrl:
|
||||
makeTrackedModifierButton(title: "Control", systemImage: "control", modifier: .ctrl)
|
||||
|
||||
case .alt:
|
||||
makeTrackedModifierButton(title: "Option", systemImage: "option", modifier: .alt)
|
||||
|
||||
case .command:
|
||||
makeTrackedModifierButton(title: "Command", systemImage: "command", modifier: .command)
|
||||
|
||||
case .tab:
|
||||
makeTrackedKeyButton(title: "Tab", systemImage: "arrow.right.to.line", key: .tab)
|
||||
|
||||
case .arrowLeft:
|
||||
makeTrackedKeyButton(title: "Left", systemImage: "arrowtriangle.left.fill", key: .arrowLeft)
|
||||
|
||||
case .arrowUp:
|
||||
makeTrackedKeyButton(title: "Up", systemImage: "arrowtriangle.up.fill", key: .arrowUp)
|
||||
|
||||
case .arrowDown:
|
||||
makeTrackedKeyButton(title: "Down", systemImage: "arrowtriangle.down.fill", key: .arrowDown)
|
||||
|
||||
case .arrowRight:
|
||||
makeTrackedKeyButton(title: "Right", systemImage: "arrowtriangle.right.fill", key: .arrowRight)
|
||||
|
||||
case let .symbol(symbol):
|
||||
makeTrackedKeyButton(title: symbol, key: .symbol(symbol))
|
||||
|
||||
case .paste:
|
||||
makeTrackedKeyButton(title: "Paste", systemImage: "doc.on.clipboard", key: .paste)
|
||||
|
||||
case .divider:
|
||||
makeDivider()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTrackedModifierButton(
|
||||
title: String,
|
||||
systemImage: String,
|
||||
modifier: TerminalStickyModifierState.Modifier
|
||||
) -> AccessoryButton {
|
||||
let button = makeModifierButton(
|
||||
title: title,
|
||||
systemImage: systemImage,
|
||||
modifier: modifier
|
||||
)
|
||||
modifierButtons.append((modifier, button))
|
||||
return button
|
||||
}
|
||||
|
||||
private func makeTrackedKeyButton(
|
||||
title: String,
|
||||
systemImage: String? = nil,
|
||||
key: TerminalInputBarKey
|
||||
) -> AccessoryButton {
|
||||
let button = makeKeyButton(title: title, systemImage: systemImage, key: key)
|
||||
keyButtons.append(button)
|
||||
return button
|
||||
}
|
||||
|
||||
private func makeModifierButton(
|
||||
title: String,
|
||||
systemImage: String,
|
||||
modifier: TerminalStickyModifierState.Modifier
|
||||
) -> AccessoryButton {
|
||||
let button = AccessoryButton(size: buttonSize) { [weak terminalView] in
|
||||
terminalView?.stickyModifiers.toggle(modifier)
|
||||
}
|
||||
button.accessibilityLabel = title
|
||||
button.setImage(UIImage(systemName: systemImage), for: .normal)
|
||||
return button
|
||||
}
|
||||
|
||||
private func makeKeyButton(
|
||||
title: String,
|
||||
systemImage: String? = nil,
|
||||
key: TerminalInputBarKey
|
||||
) -> AccessoryButton {
|
||||
let button = AccessoryButton(size: buttonSize) { [weak terminalView] in
|
||||
terminalView?.handleInputBarKey(key)
|
||||
}
|
||||
button.accessibilityLabel = title
|
||||
|
||||
if let systemImage {
|
||||
button.setImage(UIImage(systemName: systemImage), for: .normal)
|
||||
} else {
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.baseForegroundColor = .label
|
||||
configuration.title = title
|
||||
configuration.contentInsets = .zero
|
||||
configuration.attributedTitle = AttributedString(
|
||||
title,
|
||||
attributes: AttributeContainer([
|
||||
.font: UIFont.monospacedSystemFont(ofSize: 13, weight: .semibold),
|
||||
])
|
||||
)
|
||||
button.configuration = configuration
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
private var preferredHeight: CGFloat {
|
||||
Self.preferredHeight(for: barHeight)
|
||||
}
|
||||
|
||||
private var currentOuterPadding: UIEdgeInsets {
|
||||
if #available(iOS 26, *) {
|
||||
UIEdgeInsets(top: 0, left: 8, bottom: 8, right: 8)
|
||||
} else {
|
||||
.zero
|
||||
}
|
||||
}
|
||||
|
||||
private func makeBarEffect() -> UIVisualEffect {
|
||||
if #available(iOS 26, *) {
|
||||
let effect = UIGlassEffect(style: .regular)
|
||||
effect.isInteractive = true
|
||||
return effect
|
||||
} else {
|
||||
return UIBlurEffect(style: .systemUltraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyBarChrome() {
|
||||
let padding = currentOuterPadding
|
||||
blurLeadingConstraint?.constant = padding.left
|
||||
blurTrailingConstraint?.constant = -padding.right
|
||||
blurTopConstraint?.constant = padding.top
|
||||
blurBottomConstraint?.isActive = !isFloatingBarLayout
|
||||
|
||||
blurView.effect = makeBarEffect()
|
||||
blurView.layer.cornerCurve = .continuous
|
||||
blurView.layer.cornerRadius = if #available(iOS 26, *) {
|
||||
barHeight / 2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
private var isFloatingBarLayout: Bool {
|
||||
if #available(iOS 26, *) {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func preferredHeight(for barHeight: CGFloat) -> CGFloat {
|
||||
if #available(iOS 26, *) {
|
||||
barHeight + 8
|
||||
} else {
|
||||
barHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class AccessoryButton: UIButton {
|
||||
private let size: CGFloat
|
||||
private let handler: () -> Void
|
||||
private let lockIndicator = UIView()
|
||||
|
||||
init(size: CGFloat, handler: @escaping () -> Void) {
|
||||
self.size = size
|
||||
self.handler = handler
|
||||
super.init(frame: .zero)
|
||||
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
layer.cornerRadius = size / 2
|
||||
layer.cornerCurve = .continuous
|
||||
clipsToBounds = true
|
||||
|
||||
tintColor = .label
|
||||
backgroundColor = UIColor.systemGray5.withAlphaComponent(0.92)
|
||||
|
||||
titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
imageView?.contentMode = .scaleAspectFit
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
widthAnchor.constraint(equalToConstant: size),
|
||||
heightAnchor.constraint(equalToConstant: size),
|
||||
])
|
||||
|
||||
lockIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
lockIndicator.backgroundColor = tintColor
|
||||
lockIndicator.layer.cornerRadius = 1.5
|
||||
lockIndicator.isHidden = true
|
||||
addSubview(lockIndicator)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
lockIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
lockIndicator.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -3),
|
||||
lockIndicator.widthAnchor.constraint(equalToConstant: 14),
|
||||
lockIndicator.heightAnchor.constraint(equalToConstant: 3),
|
||||
])
|
||||
|
||||
addAction(UIAction { [weak self] _ in
|
||||
self?.handler()
|
||||
}, for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func applyRegularStyle(_ style: TerminalInputAccessoryStyle) {
|
||||
isEnabled = true
|
||||
alpha = 1
|
||||
tintColor = style.regularForeground
|
||||
backgroundColor = style.regularBackground
|
||||
lockIndicator.isHidden = true
|
||||
lockIndicator.backgroundColor = tintColor
|
||||
configuration?.baseForegroundColor = tintColor
|
||||
}
|
||||
|
||||
func applyModifierStyle(
|
||||
_ activation: TerminalStickyModifierState.Activation,
|
||||
isDisabled: Bool,
|
||||
style: TerminalInputAccessoryStyle
|
||||
) {
|
||||
isEnabled = !isDisabled
|
||||
alpha = isDisabled && activation == .inactive ? 0.45 : 1
|
||||
|
||||
let isActive = activation != .inactive
|
||||
tintColor = isActive ? style.activeForeground : style.regularForeground
|
||||
backgroundColor = isActive ? style.activeBackground : style.regularBackground
|
||||
lockIndicator.isHidden = activation != .locked
|
||||
lockIndicator.backgroundColor = tintColor
|
||||
configuration?.baseForegroundColor = tintColor
|
||||
}
|
||||
}
|
||||
#endif
|
||||
55
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputBarKey.swift
vendored
Normal file
55
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalInputBarKey.swift
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// TerminalInputBarKey.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
|
||||
public enum TerminalInputAccessoryItem: Equatable, Sendable {
|
||||
case esc
|
||||
case ctrl
|
||||
case alt
|
||||
case command
|
||||
case tab
|
||||
case arrowLeft
|
||||
case arrowUp
|
||||
case arrowDown
|
||||
case arrowRight
|
||||
case symbol(String)
|
||||
case paste
|
||||
case divider
|
||||
|
||||
public static let defaultItems: [TerminalInputAccessoryItem] = [
|
||||
.esc,
|
||||
.tab,
|
||||
.ctrl,
|
||||
.alt,
|
||||
.command,
|
||||
.divider,
|
||||
.arrowLeft,
|
||||
.arrowUp,
|
||||
.arrowDown,
|
||||
.arrowRight,
|
||||
.divider,
|
||||
.symbol("|"),
|
||||
.symbol("/"),
|
||||
.symbol("~"),
|
||||
.symbol("-"),
|
||||
.symbol("_"),
|
||||
.symbol("`"),
|
||||
.symbol("'"),
|
||||
.symbol("\""),
|
||||
.paste,
|
||||
]
|
||||
}
|
||||
|
||||
enum TerminalInputBarKey {
|
||||
case esc
|
||||
case tab
|
||||
case arrowLeft
|
||||
case arrowUp
|
||||
case arrowDown
|
||||
case arrowRight
|
||||
case symbol(String)
|
||||
case paste
|
||||
}
|
||||
#endif
|
||||
81
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalStickyModifierState.swift
vendored
Normal file
81
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalStickyModifierState.swift
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// TerminalStickyModifierState.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class TerminalStickyModifierState {
|
||||
enum Activation { case inactive, armed, locked }
|
||||
enum Modifier { case ctrl, alt, command }
|
||||
|
||||
private(set) var ctrl: Activation = .inactive
|
||||
private(set) var alt: Activation = .inactive
|
||||
private(set) var command: Activation = .inactive
|
||||
|
||||
var onChange: (() -> Void)?
|
||||
|
||||
private var lastCtrlTap: Date = .distantPast
|
||||
private var lastAltTap: Date = .distantPast
|
||||
private var lastCommandTap: Date = .distantPast
|
||||
private let doubleTapInterval: TimeInterval = 0.3
|
||||
|
||||
func toggle(_ modifier: Modifier) {
|
||||
switch modifier {
|
||||
case .ctrl:
|
||||
ctrl = nextActivation(ctrl, lastTap: lastCtrlTap)
|
||||
lastCtrlTap = Date()
|
||||
case .alt:
|
||||
alt = nextActivation(alt, lastTap: lastAltTap)
|
||||
lastAltTap = Date()
|
||||
case .command:
|
||||
command = nextActivation(command, lastTap: lastCommandTap)
|
||||
lastCommandTap = Date()
|
||||
}
|
||||
onChange?()
|
||||
}
|
||||
|
||||
func consumeForNextKey() -> TerminalInputModifiers {
|
||||
var mods = TerminalInputModifiers()
|
||||
if ctrl != .inactive { mods.insert(.ctrl) }
|
||||
if alt != .inactive { mods.insert(.alt) }
|
||||
if command != .inactive { mods.insert(.super_) }
|
||||
if ctrl == .armed { ctrl = .inactive }
|
||||
if alt == .armed { alt = .inactive }
|
||||
if command == .armed { command = .inactive }
|
||||
onChange?()
|
||||
return mods
|
||||
}
|
||||
|
||||
var hasActiveModifiers: Bool {
|
||||
ctrl != .inactive || alt != .inactive || command != .inactive
|
||||
}
|
||||
|
||||
func reset() {
|
||||
guard hasActiveModifiers else { return }
|
||||
ctrl = .inactive
|
||||
alt = .inactive
|
||||
command = .inactive
|
||||
onChange?()
|
||||
}
|
||||
|
||||
private func nextActivation(
|
||||
_ current: Activation,
|
||||
lastTap: Date
|
||||
) -> Activation {
|
||||
switch current {
|
||||
case .inactive:
|
||||
return .armed
|
||||
case .armed:
|
||||
if Date().timeIntervalSince(lastTap) < doubleTapInterval {
|
||||
return .locked
|
||||
}
|
||||
return .inactive
|
||||
case .locked:
|
||||
return .inactive
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
254
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalTextInputHandler@UIKit.swift
vendored
Normal file
254
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalTextInputHandler@UIKit.swift
vendored
Normal file
@@ -0,0 +1,254 @@
|
||||
//
|
||||
// TerminalTextInputHandler@UIKit.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import GhosttyKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class TerminalTextInputHandler {
|
||||
private weak var view: UITerminalView?
|
||||
private var markedTextState = TerminalMarkedTextState()
|
||||
|
||||
var hasMarkedText: Bool {
|
||||
markedTextState.hasMarkedText
|
||||
}
|
||||
|
||||
var documentLength: Int {
|
||||
markedTextState.documentLength
|
||||
}
|
||||
|
||||
init(view: UITerminalView) {
|
||||
self.view = view
|
||||
}
|
||||
|
||||
// MARK: - Text Input
|
||||
|
||||
func insertText(
|
||||
_ text: String,
|
||||
applyingStickyModifiers: Bool = false
|
||||
) {
|
||||
guard let view else { return }
|
||||
let shouldNotifySelectionChange = shouldNotifySelectionChange
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"insertText text=\(TerminalDebugLog.describe(text)) marked=\(hasMarkedText)"
|
||||
)
|
||||
|
||||
view.inputDelegate?.textWillChange(view)
|
||||
if shouldNotifySelectionChange {
|
||||
view.inputDelegate?.selectionWillChange(view)
|
||||
}
|
||||
|
||||
markedTextState.clear()
|
||||
view.surface?.preedit("")
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if applyingStickyModifiers {
|
||||
_ = view.handleStickyCommittedText(text)
|
||||
} else {
|
||||
view.surface?.sendText(text)
|
||||
}
|
||||
#else
|
||||
view.surface?.sendText(text)
|
||||
#endif
|
||||
view.refreshInputAccessoryContent()
|
||||
|
||||
if shouldNotifySelectionChange {
|
||||
view.inputDelegate?.selectionDidChange(view)
|
||||
}
|
||||
view.inputDelegate?.textDidChange(view)
|
||||
}
|
||||
|
||||
func setMarkedText(_ text: String?, selectedRange: NSRange) {
|
||||
guard let view else { return }
|
||||
let shouldNotifySelectionChange = shouldNotifySelectionChange
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"setMarkedText text=\(TerminalDebugLog.describe(text)) selected=\(TerminalDebugLog.describe(selectedRange))"
|
||||
)
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if let text, !text.isEmpty {
|
||||
if view.stickyModifiers.hasActiveModifiers {
|
||||
view.inputDelegate?.textWillChange(view)
|
||||
if shouldNotifySelectionChange {
|
||||
view.inputDelegate?.selectionWillChange(view)
|
||||
}
|
||||
|
||||
markedTextState.clear()
|
||||
view.surface?.preedit("")
|
||||
_ = view.handleStickyMarkedText(text)
|
||||
view.refreshInputAccessoryContent()
|
||||
|
||||
if shouldNotifySelectionChange {
|
||||
view.inputDelegate?.selectionDidChange(view)
|
||||
}
|
||||
view.inputDelegate?.textDidChange(view)
|
||||
return
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
view.inputDelegate?.textWillChange(view)
|
||||
view.inputDelegate?.selectionWillChange(view)
|
||||
|
||||
markedTextState.setMarkedText(text, selectedRange: selectedRange)
|
||||
|
||||
if let text = markedTextState.text, !text.isEmpty {
|
||||
view.surface?.preedit(text)
|
||||
} else {
|
||||
view.surface?.preedit("")
|
||||
}
|
||||
view.refreshInputAccessoryContent()
|
||||
|
||||
view.inputDelegate?.selectionDidChange(view)
|
||||
view.inputDelegate?.textDidChange(view)
|
||||
}
|
||||
|
||||
func unmarkText(
|
||||
applyingStickyModifiers: Bool = false
|
||||
) {
|
||||
guard let view else { return }
|
||||
let shouldNotifySelectionChange = shouldNotifySelectionChange
|
||||
let committedText = markedTextState.text
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"unmarkText committed=\(TerminalDebugLog.describe(committedText))"
|
||||
)
|
||||
|
||||
view.inputDelegate?.textWillChange(view)
|
||||
if shouldNotifySelectionChange {
|
||||
view.inputDelegate?.selectionWillChange(view)
|
||||
}
|
||||
|
||||
markedTextState.clear()
|
||||
view.surface?.preedit("")
|
||||
if let committedText, !committedText.isEmpty {
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if applyingStickyModifiers {
|
||||
_ = view.handleStickyCommittedText(committedText)
|
||||
} else {
|
||||
view.surface?.sendText(committedText)
|
||||
}
|
||||
#else
|
||||
view.surface?.sendText(committedText)
|
||||
#endif
|
||||
}
|
||||
view.refreshInputAccessoryContent()
|
||||
|
||||
if shouldNotifySelectionChange {
|
||||
view.inputDelegate?.selectionDidChange(view)
|
||||
}
|
||||
view.inputDelegate?.textDidChange(view)
|
||||
}
|
||||
|
||||
func markedTextRange() -> TerminalTextRange? {
|
||||
guard markedTextState.hasMarkedText else { return nil }
|
||||
return TerminalTextRange(
|
||||
location: markedTextState.markedRange.location,
|
||||
length: markedTextState.markedRange.length
|
||||
)
|
||||
}
|
||||
|
||||
func selectedTextRange() -> TerminalTextRange {
|
||||
TerminalTextRange(
|
||||
location: markedTextState.selectedRange.location,
|
||||
length: markedTextState.selectedRange.length
|
||||
)
|
||||
}
|
||||
|
||||
func setSelectedTextRange(_ range: UITextRange?) {
|
||||
let updatedRange = if let range = range as? TerminalTextRange {
|
||||
NSRange(
|
||||
location: range.location,
|
||||
length: range.length
|
||||
)
|
||||
} else {
|
||||
NSRange(location: 0, length: 0)
|
||||
}
|
||||
let clampedRange = clampedSelectedRange(updatedRange)
|
||||
guard markedTextState.selectedRange != clampedRange else { return }
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"setSelectedTextRange range=\(TerminalDebugLog.describe(clampedRange))"
|
||||
)
|
||||
notifySelectionWillChange()
|
||||
markedTextState.setMarkedText(markedTextState.text, selectedRange: clampedRange)
|
||||
notifySelectionDidChange()
|
||||
}
|
||||
|
||||
func text(in range: TerminalTextRange) -> String? {
|
||||
markedTextState.text(in: NSRange(
|
||||
location: range.location,
|
||||
length: range.length
|
||||
))
|
||||
}
|
||||
|
||||
func deleteBackwardInMarkedText() -> Bool {
|
||||
guard let view else { return false }
|
||||
guard markedTextState.hasMarkedText else { return false }
|
||||
let shouldNotifySelectionChange = shouldNotifySelectionChange
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"deleteBackwardInMarkedText selected=\(TerminalDebugLog.describe(markedTextState.selectedRange))"
|
||||
)
|
||||
view.inputDelegate?.textWillChange(view)
|
||||
if shouldNotifySelectionChange {
|
||||
view.inputDelegate?.selectionWillChange(view)
|
||||
}
|
||||
|
||||
_ = markedTextState.deleteBackward()
|
||||
view.surface?.preedit(markedTextState.text ?? "")
|
||||
view.refreshInputAccessoryContent()
|
||||
|
||||
if shouldNotifySelectionChange {
|
||||
view.inputDelegate?.selectionDidChange(view)
|
||||
}
|
||||
view.inputDelegate?.textDidChange(view)
|
||||
return true
|
||||
}
|
||||
|
||||
func notifyGeometryDidChange(reason: String) {
|
||||
guard let view else { return }
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"notifyGeometryDidChange reason=\(reason) selected=\(TerminalDebugLog.describe(markedTextState.selectedRange)) documentLength=\(markedTextState.documentLength) marked=\(hasMarkedText)"
|
||||
)
|
||||
view.inputDelegate?.selectionWillChange(view)
|
||||
view.inputDelegate?.selectionDidChange(view)
|
||||
if view.isFirstResponder {
|
||||
view.reloadInputViews()
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldNotifySelectionChange: Bool {
|
||||
hasMarkedText
|
||||
|| markedTextState.selectedRange.location != 0
|
||||
|| markedTextState.selectedRange.length != 0
|
||||
}
|
||||
|
||||
private func clampedSelectedRange(_ range: NSRange) -> NSRange {
|
||||
let length = markedTextState.documentLength
|
||||
let location = min(max(range.location, 0), length)
|
||||
let end = min(max(range.location + range.length, location), length)
|
||||
return NSRange(location: location, length: end - location)
|
||||
}
|
||||
|
||||
private func notifySelectionWillChange() {
|
||||
if let view {
|
||||
view.inputDelegate?.selectionWillChange(view)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifySelectionDidChange() {
|
||||
if let view {
|
||||
view.inputDelegate?.selectionDidChange(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
63
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalTextPosition.swift
vendored
Normal file
63
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/TerminalTextPosition.swift
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
//
|
||||
// TerminalTextPosition.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
|
||||
final class TerminalTextPosition: UITextPosition {
|
||||
let index: Int
|
||||
|
||||
init(_ index: Int) {
|
||||
self.index = index
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
final class TerminalTextRange: UITextRange {
|
||||
private let _start: TerminalTextPosition
|
||||
private let _end: TerminalTextPosition
|
||||
|
||||
override var start: UITextPosition {
|
||||
_start
|
||||
}
|
||||
|
||||
override var end: UITextPosition {
|
||||
_end
|
||||
}
|
||||
|
||||
override var isEmpty: Bool {
|
||||
_start.index >= _end.index
|
||||
}
|
||||
|
||||
var startPosition: TerminalTextPosition {
|
||||
_start
|
||||
}
|
||||
|
||||
var endPosition: TerminalTextPosition {
|
||||
_end
|
||||
}
|
||||
|
||||
var location: Int {
|
||||
_start.index
|
||||
}
|
||||
|
||||
var length: Int {
|
||||
_end.index - _start.index
|
||||
}
|
||||
|
||||
init(start: TerminalTextPosition, end: TerminalTextPosition) {
|
||||
_start = start
|
||||
_end = end
|
||||
super.init()
|
||||
}
|
||||
|
||||
convenience init(location: Int, length: Int) {
|
||||
self.init(
|
||||
start: TerminalTextPosition(location),
|
||||
end: TerminalTextPosition(location + length)
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
313
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+InputAccessory.swift
vendored
Normal file
313
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+InputAccessory.swift
vendored
Normal file
@@ -0,0 +1,313 @@
|
||||
//
|
||||
// UITerminalView+InputAccessory.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
|
||||
import GhosttyKit
|
||||
import UIKit
|
||||
|
||||
extension UITerminalView {
|
||||
override open var inputAccessoryView: UIView? {
|
||||
inputAccessoryItems.isEmpty ? nil : terminalInputAccessory
|
||||
}
|
||||
|
||||
func handleInputBarKey(_ key: TerminalInputBarKey) {
|
||||
commitMarkedTextIfStickyModifiersAreActive()
|
||||
|
||||
switch key {
|
||||
case let .symbol(text):
|
||||
_ = handleStickyTextInput(text)
|
||||
|
||||
case .paste:
|
||||
_ = stickyModifiers.consumeForNextKey()
|
||||
if let text = UIPasteboard.general.string, !text.isEmpty {
|
||||
inputHandler.insertText(text)
|
||||
}
|
||||
|
||||
case .esc:
|
||||
let mods = stickyModifiers.consumeForNextKey()
|
||||
sendSyntheticKey(usage: 0x29, additionalMods: mods)
|
||||
|
||||
case .tab:
|
||||
let mods = stickyModifiers.consumeForNextKey()
|
||||
sendSyntheticKey(usage: 0x2B, additionalMods: mods)
|
||||
|
||||
case .arrowLeft:
|
||||
let mods = stickyModifiers.consumeForNextKey()
|
||||
sendSyntheticKey(usage: 0x50, additionalMods: mods)
|
||||
|
||||
case .arrowRight:
|
||||
let mods = stickyModifiers.consumeForNextKey()
|
||||
sendSyntheticKey(usage: 0x4F, additionalMods: mods)
|
||||
|
||||
case .arrowUp:
|
||||
let mods = stickyModifiers.consumeForNextKey()
|
||||
sendSyntheticKey(usage: 0x52, additionalMods: mods)
|
||||
|
||||
case .arrowDown:
|
||||
let mods = stickyModifiers.consumeForNextKey()
|
||||
sendSyntheticKey(usage: 0x51, additionalMods: mods)
|
||||
}
|
||||
}
|
||||
|
||||
private func commitMarkedTextIfStickyModifiersAreActive() {
|
||||
guard stickyModifiers.hasActiveModifiers, inputHandler.hasMarkedText else { return }
|
||||
inputHandler.unmarkText(applyingStickyModifiers: false)
|
||||
}
|
||||
|
||||
func sendSyntheticKey(
|
||||
usage: UInt16,
|
||||
additionalMods: TerminalInputModifiers = []
|
||||
) {
|
||||
guard let surface else { return }
|
||||
|
||||
if inputHandler.hasMarkedText {
|
||||
inputHandler.unmarkText()
|
||||
}
|
||||
|
||||
// Unmodified accessory arrows/Esc/Tab can still use the direct
|
||||
// in-memory byte path, but sticky modifiers must round-trip
|
||||
// through Ghostty so modifier-aware escape sequences are preserved.
|
||||
let delivery = TerminalHardwareKeyRouter.routeUIKit(
|
||||
usage: usage,
|
||||
backend: configuration.backend,
|
||||
modifiers: additionalMods
|
||||
)
|
||||
|
||||
if !additionalMods.isEmpty, let ghosttyKey = ghosttyKey(from: delivery) {
|
||||
var event = ghostty_input_key_s()
|
||||
event.action = GHOSTTY_ACTION_PRESS
|
||||
event.keycode = TerminalHardwareKeyRouter.appKitKeyCode(
|
||||
for: ghosttyKey
|
||||
)
|
||||
event.mods = additionalMods.ghosttyMods
|
||||
_ = surface.sendKeyEvent(event)
|
||||
return
|
||||
}
|
||||
|
||||
switch delivery {
|
||||
case let .data(data):
|
||||
guard case let .inMemory(session) = configuration.backend else { return }
|
||||
session.sendInput(data)
|
||||
|
||||
case let .ghostty(ghosttyKey):
|
||||
var event = ghostty_input_key_s()
|
||||
event.action = GHOSTTY_ACTION_PRESS
|
||||
event.keycode = TerminalHardwareKeyRouter.appKitKeyCode(
|
||||
for: ghosttyKey
|
||||
)
|
||||
event.mods = additionalMods.ghosttyMods
|
||||
_ = surface.sendKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleStickyTextInput(_ text: String) -> Bool {
|
||||
handleStickyTextInput(text) { [weak self] text in
|
||||
self?.inputHandler.insertText(text)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleStickyCommittedText(_ text: String) -> Bool {
|
||||
handleStickyTextInput(text) { [weak self] text in
|
||||
self?.surface?.sendText(text)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleStickyMarkedText(_ text: String) -> Bool {
|
||||
guard stickyModifiers.hasActiveModifiers else { return false }
|
||||
|
||||
let keyText = String(text.prefix(1))
|
||||
guard !keyText.isEmpty else {
|
||||
stickyModifiers.reset()
|
||||
return false
|
||||
}
|
||||
|
||||
let mods = stickyModifiers.consumeForNextKey()
|
||||
let handled: Bool
|
||||
if mods == .ctrl, let controlByte = controlByte(for: keyText) {
|
||||
sendControlByte(controlByte, modifiers: mods)
|
||||
handled = true
|
||||
} else {
|
||||
handled = sendModifiedTextKey(keyText, modifiers: mods)
|
||||
}
|
||||
|
||||
stickyModifiers.reset()
|
||||
return handled
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func handleStickyTextInput(
|
||||
_ text: String,
|
||||
fallback: (String) -> Void
|
||||
) -> Bool {
|
||||
commitMarkedTextIfStickyModifiersAreActive()
|
||||
|
||||
guard stickyModifiers.hasActiveModifiers else {
|
||||
fallback(text)
|
||||
return false
|
||||
}
|
||||
|
||||
let mods = stickyModifiers.consumeForNextKey()
|
||||
if mods == .ctrl, let controlByte = controlByte(for: text) {
|
||||
sendControlByte(controlByte, modifiers: mods)
|
||||
return true
|
||||
}
|
||||
|
||||
if sendModifiedTextKey(text, modifiers: mods) {
|
||||
return true
|
||||
}
|
||||
|
||||
fallback(text)
|
||||
return false
|
||||
}
|
||||
|
||||
func sendControlByte(
|
||||
_ byte: UInt8,
|
||||
modifiers: TerminalInputModifiers = .ctrl
|
||||
) {
|
||||
if inputHandler.hasMarkedText {
|
||||
inputHandler.unmarkText()
|
||||
}
|
||||
|
||||
if case let .inMemory(session) = configuration.backend {
|
||||
session.sendInput(Data([byte]))
|
||||
} else if let surface {
|
||||
var event = ghostty_input_key_s()
|
||||
event.action = GHOSTTY_ACTION_PRESS
|
||||
event.mods = modifiers.ghosttyMods
|
||||
let char = Character(UnicodeScalar(byte | 0x60))
|
||||
let ghosttyKey = ghosttyKeyForCharacter(char)
|
||||
event.keycode = TerminalHardwareKeyRouter.appKitKeyCode(
|
||||
for: ghosttyKey
|
||||
)
|
||||
_ = surface.sendKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func controlByte(for text: String) -> UInt8? {
|
||||
guard text.count == 1 else { return nil }
|
||||
guard let ascii = text.lowercased().utf8.first else { return nil }
|
||||
guard ascii >= 0x61, ascii <= 0x7A else { return nil }
|
||||
return ascii & 0x1F
|
||||
}
|
||||
|
||||
private func sendModifiedTextKey(
|
||||
_ text: String,
|
||||
modifiers: TerminalInputModifiers
|
||||
) -> Bool {
|
||||
guard let surface else { return false }
|
||||
|
||||
if inputHandler.hasMarkedText {
|
||||
inputHandler.unmarkText()
|
||||
}
|
||||
|
||||
guard let mapping = keyMapping(for: text) else { return false }
|
||||
|
||||
var event = ghostty_input_key_s()
|
||||
event.action = GHOSTTY_ACTION_PRESS
|
||||
event.keycode = TerminalHardwareKeyRouter.appKitKeyCode(
|
||||
for: mapping.key
|
||||
)
|
||||
event.mods = modifiers.union(mapping.extraModifiers).ghosttyMods
|
||||
|
||||
if !modifiers.contains(.super_) {
|
||||
text.withCString { ptr in
|
||||
event.text = ptr
|
||||
_ = surface.sendKeyEvent(event)
|
||||
}
|
||||
} else {
|
||||
_ = surface.sendKeyEvent(event)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func ghosttyKey(from delivery: TerminalHardwareKeyDelivery) -> ghostty_input_key_e? {
|
||||
guard case let .ghostty(ghosttyKey) = delivery else { return nil }
|
||||
return ghosttyKey
|
||||
}
|
||||
|
||||
private func keyMapping(
|
||||
for text: String
|
||||
) -> (key: ghostty_input_key_e, extraModifiers: TerminalInputModifiers)? {
|
||||
guard text.count == 1, let char = text.first else { return nil }
|
||||
switch char {
|
||||
case "a" ... "z":
|
||||
return (ghosttyKeyForCharacter(char), [])
|
||||
case "A" ... "Z":
|
||||
return (ghosttyKeyForCharacter(Character(char.lowercased())), [.shift])
|
||||
case "0": return (GHOSTTY_KEY_DIGIT_0, [])
|
||||
case "1": return (GHOSTTY_KEY_DIGIT_1, [])
|
||||
case "2": return (GHOSTTY_KEY_DIGIT_2, [])
|
||||
case "3": return (GHOSTTY_KEY_DIGIT_3, [])
|
||||
case "4": return (GHOSTTY_KEY_DIGIT_4, [])
|
||||
case "5": return (GHOSTTY_KEY_DIGIT_5, [])
|
||||
case "6": return (GHOSTTY_KEY_DIGIT_6, [])
|
||||
case "7": return (GHOSTTY_KEY_DIGIT_7, [])
|
||||
case "8": return (GHOSTTY_KEY_DIGIT_8, [])
|
||||
case "9": return (GHOSTTY_KEY_DIGIT_9, [])
|
||||
case "`": return (GHOSTTY_KEY_BACKQUOTE, [])
|
||||
case "~": return (GHOSTTY_KEY_BACKQUOTE, [.shift])
|
||||
case "-": return (GHOSTTY_KEY_MINUS, [])
|
||||
case "_": return (GHOSTTY_KEY_MINUS, [.shift])
|
||||
case "=": return (GHOSTTY_KEY_EQUAL, [])
|
||||
case "+": return (GHOSTTY_KEY_EQUAL, [.shift])
|
||||
case "[": return (GHOSTTY_KEY_BRACKET_LEFT, [])
|
||||
case "{": return (GHOSTTY_KEY_BRACKET_LEFT, [.shift])
|
||||
case "]": return (GHOSTTY_KEY_BRACKET_RIGHT, [])
|
||||
case "}": return (GHOSTTY_KEY_BRACKET_RIGHT, [.shift])
|
||||
case "\\": return (GHOSTTY_KEY_BACKSLASH, [])
|
||||
case "|": return (GHOSTTY_KEY_BACKSLASH, [.shift])
|
||||
case ";": return (GHOSTTY_KEY_SEMICOLON, [])
|
||||
case ":": return (GHOSTTY_KEY_SEMICOLON, [.shift])
|
||||
case "'": return (GHOSTTY_KEY_QUOTE, [])
|
||||
case "\"": return (GHOSTTY_KEY_QUOTE, [.shift])
|
||||
case ",": return (GHOSTTY_KEY_COMMA, [])
|
||||
case "<": return (GHOSTTY_KEY_COMMA, [.shift])
|
||||
case ".": return (GHOSTTY_KEY_PERIOD, [])
|
||||
case ">": return (GHOSTTY_KEY_PERIOD, [.shift])
|
||||
case "/": return (GHOSTTY_KEY_SLASH, [])
|
||||
case "?": return (GHOSTTY_KEY_SLASH, [.shift])
|
||||
case " ": return (GHOSTTY_KEY_SPACE, [])
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func ghosttyKeyForCharacter(_ char: Character) -> ghostty_input_key_e {
|
||||
switch char {
|
||||
case "a": GHOSTTY_KEY_A
|
||||
case "b": GHOSTTY_KEY_B
|
||||
case "c": GHOSTTY_KEY_C
|
||||
case "d": GHOSTTY_KEY_D
|
||||
case "e": GHOSTTY_KEY_E
|
||||
case "f": GHOSTTY_KEY_F
|
||||
case "g": GHOSTTY_KEY_G
|
||||
case "h": GHOSTTY_KEY_H
|
||||
case "i": GHOSTTY_KEY_I
|
||||
case "j": GHOSTTY_KEY_J
|
||||
case "k": GHOSTTY_KEY_K
|
||||
case "l": GHOSTTY_KEY_L
|
||||
case "m": GHOSTTY_KEY_M
|
||||
case "n": GHOSTTY_KEY_N
|
||||
case "o": GHOSTTY_KEY_O
|
||||
case "p": GHOSTTY_KEY_P
|
||||
case "q": GHOSTTY_KEY_Q
|
||||
case "r": GHOSTTY_KEY_R
|
||||
case "s": GHOSTTY_KEY_S
|
||||
case "t": GHOSTTY_KEY_T
|
||||
case "u": GHOSTTY_KEY_U
|
||||
case "v": GHOSTTY_KEY_V
|
||||
case "w": GHOSTTY_KEY_W
|
||||
case "x": GHOSTTY_KEY_X
|
||||
case "y": GHOSTTY_KEY_Y
|
||||
case "z": GHOSTTY_KEY_Z
|
||||
default: GHOSTTY_KEY_UNIDENTIFIED
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
652
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Interaction.swift
vendored
Normal file
652
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Interaction.swift
vendored
Normal file
@@ -0,0 +1,652 @@
|
||||
//
|
||||
// UITerminalView+Interaction.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import GhosttyKit
|
||||
import UIKit
|
||||
|
||||
extension UITerminalView {
|
||||
override open func touchesBegan(
|
||||
_ touches: Set<UITouch>,
|
||||
with event: UIEvent?
|
||||
) {
|
||||
if handleIndirectPointerTouches(touches, phase: .began, event: event) {
|
||||
return
|
||||
}
|
||||
super.touchesBegan(touches, with: event)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
becomeFirstResponder()
|
||||
#else
|
||||
pendingKeyboardDismissOnTouchEnd = false
|
||||
touchDidScrollDuringCurrentTouch = false
|
||||
if softwareKeyboardVisible {
|
||||
pendingKeyboardDismissOnTouchEnd = true
|
||||
} else {
|
||||
becomeFirstResponder()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
override open func touchesMoved(
|
||||
_ touches: Set<UITouch>,
|
||||
with event: UIEvent?
|
||||
) {
|
||||
if handleIndirectPointerTouches(touches, phase: .moved, event: event) {
|
||||
return
|
||||
}
|
||||
super.touchesMoved(touches, with: event)
|
||||
}
|
||||
|
||||
override open func touchesEnded(
|
||||
_ touches: Set<UITouch>,
|
||||
with event: UIEvent?
|
||||
) {
|
||||
if handleIndirectPointerTouches(touches, phase: .ended, event: event) {
|
||||
return
|
||||
}
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if pendingKeyboardDismissOnTouchEnd, !touchDidScrollDuringCurrentTouch {
|
||||
resignFirstResponder()
|
||||
}
|
||||
pendingKeyboardDismissOnTouchEnd = false
|
||||
touchDidScrollDuringCurrentTouch = false
|
||||
#endif
|
||||
super.touchesEnded(touches, with: event)
|
||||
}
|
||||
|
||||
override open func touchesCancelled(
|
||||
_ touches: Set<UITouch>,
|
||||
with event: UIEvent?
|
||||
) {
|
||||
if handleIndirectPointerTouches(touches, phase: .cancelled, event: event) {
|
||||
return
|
||||
}
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
pendingKeyboardDismissOnTouchEnd = false
|
||||
touchDidScrollDuringCurrentTouch = false
|
||||
#endif
|
||||
super.touchesCancelled(touches, with: event)
|
||||
}
|
||||
|
||||
func setupPlatformInput() {
|
||||
addInteraction(selectionContextMenuInteraction)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
setupCatalystScrollWheelInput()
|
||||
#else
|
||||
setupTouchScrollInput()
|
||||
#endif
|
||||
}
|
||||
|
||||
enum IndirectPointerPhase {
|
||||
case began
|
||||
case moved
|
||||
case ended
|
||||
case cancelled
|
||||
}
|
||||
|
||||
func handleIndirectPointerTouches(
|
||||
_ touches: Set<UITouch>,
|
||||
phase: IndirectPointerPhase,
|
||||
event: UIEvent?
|
||||
) -> Bool {
|
||||
let hasIndirectPointerTouch = touches.contains { $0.type == .indirectPointer }
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if suppressNextIndirectPointerTouchEnd, hasIndirectPointerTouch {
|
||||
if phase == .ended || phase == .cancelled {
|
||||
suppressNextIndirectPointerTouchEnd = false
|
||||
return true
|
||||
}
|
||||
suppressNextIndirectPointerTouchEnd = false
|
||||
}
|
||||
|
||||
if indirectPointerPanOwnsTouchSequence, hasIndirectPointerTouch {
|
||||
if phase == .began {
|
||||
indirectPointerPanOwnsTouchSequence = false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
guard hasIndirectPointerTouch,
|
||||
let touch = touches.first(where: { $0.type == .indirectPointer })
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
core.setFocus(true)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if phase == .began {
|
||||
becomeFirstResponder()
|
||||
}
|
||||
#endif
|
||||
stopMomentumScrolling()
|
||||
|
||||
let button = pointerButton(from: event)
|
||||
let mods = ghostty_input_mods_e(rawValue: 0)
|
||||
let location = touch.location(in: self)
|
||||
let suppressSurfacePositionForSelectionMenu =
|
||||
button == GHOSTTY_MOUSE_RIGHT &&
|
||||
(pendingSelectionMenuPoint != nil || pointIsInsidePointerSelection(location))
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"pointer touch phase=\(phase) type=\(touch.type.rawValue) button=\(button.rawValue) location=\(NSCoder.string(for: location)) mask=\(event?.buttonMask.rawValue ?? 0)"
|
||||
)
|
||||
if !suppressSurfacePositionForSelectionMenu {
|
||||
surface?.sendMousePos(
|
||||
x: location.x,
|
||||
y: location.y,
|
||||
mods: mods
|
||||
)
|
||||
}
|
||||
|
||||
switch phase {
|
||||
case .began:
|
||||
activePointerButton = button
|
||||
switch button {
|
||||
case GHOSTTY_MOUSE_LEFT:
|
||||
pointerSelectionStartPoint = location
|
||||
pendingSelectionMenuPoint = nil
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: button,
|
||||
mods: mods
|
||||
)
|
||||
|
||||
case GHOSTTY_MOUSE_RIGHT:
|
||||
if pointIsInsidePointerSelection(location) {
|
||||
pendingSelectionMenuPoint = location
|
||||
} else {
|
||||
pendingSelectionMenuPoint = selectionMenuPoint(at: location)
|
||||
}
|
||||
|
||||
default:
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: button,
|
||||
mods: mods
|
||||
)
|
||||
}
|
||||
|
||||
case .moved:
|
||||
updatePointerSelectionRect(to: location)
|
||||
|
||||
case .ended:
|
||||
let releasedButton = activePointerButton ?? button
|
||||
activePointerButton = nil
|
||||
|
||||
if releasedButton == GHOSTTY_MOUSE_RIGHT,
|
||||
pendingSelectionMenuPoint != nil
|
||||
{
|
||||
if selectionMenuPoint(at: location) != nil {
|
||||
showSelectionCopyMenu(at: location)
|
||||
}
|
||||
pendingSelectionMenuPoint = nil
|
||||
return true
|
||||
}
|
||||
|
||||
if releasedButton == GHOSTTY_MOUSE_RIGHT {
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: releasedButton,
|
||||
mods: mods
|
||||
)
|
||||
}
|
||||
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: releasedButton,
|
||||
mods: mods
|
||||
)
|
||||
|
||||
if releasedButton == GHOSTTY_MOUSE_LEFT {
|
||||
finishPointerSelection(at: location)
|
||||
}
|
||||
pendingSelectionMenuPoint = nil
|
||||
|
||||
case .cancelled:
|
||||
let releasedButton = activePointerButton ?? button
|
||||
activePointerButton = nil
|
||||
pendingSelectionMenuPoint = nil
|
||||
pointerSelectionStartPoint = nil
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: releasedButton,
|
||||
mods: mods
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func pointerButton(from event: UIEvent?) -> ghostty_input_mouse_button_e {
|
||||
guard let event else { return GHOSTTY_MOUSE_LEFT }
|
||||
if event.buttonMask.contains(.secondary) {
|
||||
return GHOSTTY_MOUSE_RIGHT
|
||||
}
|
||||
if event.buttonMask.contains(.primary) {
|
||||
return GHOSTTY_MOUSE_LEFT
|
||||
}
|
||||
return GHOSTTY_MOUSE_LEFT
|
||||
}
|
||||
|
||||
func updatePointerSelectionRect(to point: CGPoint) {
|
||||
guard activePointerButton == GHOSTTY_MOUSE_LEFT,
|
||||
let start = pointerSelectionStartPoint
|
||||
else { return }
|
||||
|
||||
lastPointerSelectionRect = CGRect(
|
||||
x: min(start.x, point.x),
|
||||
y: min(start.y, point.y),
|
||||
width: abs(start.x - point.x),
|
||||
height: abs(start.y - point.y)
|
||||
).insetBy(dx: -2, dy: -2)
|
||||
logPointerSelectionDiagnostics(
|
||||
context: "updatePointerSelectionRect",
|
||||
point: point
|
||||
)
|
||||
}
|
||||
|
||||
func finishPointerSelection(at point: CGPoint) {
|
||||
defer { pointerSelectionStartPoint = nil }
|
||||
guard let start = pointerSelectionStartPoint else { return }
|
||||
let dragDistance = hypot(point.x - start.x, point.y - start.y)
|
||||
if dragDistance < 2 {
|
||||
lastPointerSelectionRect = nil
|
||||
} else {
|
||||
updatePointerSelectionRect(to: point)
|
||||
}
|
||||
logPointerSelectionDiagnostics(
|
||||
context: "finishPointerSelection",
|
||||
point: point
|
||||
)
|
||||
}
|
||||
|
||||
func logPointerSelectionDiagnostics(context: String, point: CGPoint) {
|
||||
guard TerminalDebugLog.isEnabled,
|
||||
TerminalDebugLog.categories.contains(.input)
|
||||
else { return }
|
||||
|
||||
let rectDescription = lastPointerSelectionRect.map {
|
||||
NSCoder.string(for: $0)
|
||||
} ?? "nil"
|
||||
let metricsDescription = surface?.size().map(\.debugSummary) ?? "nil"
|
||||
let selection = surface?.readSelectionResult()
|
||||
let selectionDescription = selection.map {
|
||||
"text=\(TerminalDebugLog.describe($0.text)) offset=\($0.offsetStart)+\($0.offsetLength)"
|
||||
} ?? "nil"
|
||||
let word = surface?.quicklookWord()
|
||||
let wordDescription = word.map {
|
||||
"word=\(TerminalDebugLog.describe($0.word)) offset=\($0.offsetStart)+\($0.offsetLength) point=\(String(format: "%.2f", $0.pointX))x\(String(format: "%.2f", $0.pointY))"
|
||||
} ?? "nil"
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"pointer selection \(context) viewBounds=\(NSCoder.string(for: bounds)) point=\(NSCoder.string(for: point)) rect=\(rectDescription) metrics=\(metricsDescription) selection=\(selectionDescription) quicklook=\(wordDescription)"
|
||||
)
|
||||
}
|
||||
|
||||
@IBAction override open func copy(_: Any?) {
|
||||
guard copySelectedTextToPasteboard() else { return }
|
||||
}
|
||||
|
||||
override open func canPerformAction(
|
||||
_ action: Selector,
|
||||
withSender sender: Any?
|
||||
) -> Bool {
|
||||
if action == #selector(copy(_:)) {
|
||||
return surface?.hasSelection() == true
|
||||
}
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
}
|
||||
|
||||
func pointIsInsidePointerSelection(_ point: CGPoint) -> Bool {
|
||||
lastPointerSelectionRect.map {
|
||||
$0.insetBy(dx: -4, dy: -4).contains(point)
|
||||
} ?? false
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func setupCatalystScrollWheelInput() {
|
||||
let gesture = UIPanGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handleCatalystScrollWheelGesture(_:))
|
||||
)
|
||||
gesture.allowedScrollTypesMask = [.continuous, .discrete]
|
||||
gesture.cancelsTouchesInView = false
|
||||
gesture.delaysTouchesBegan = false
|
||||
gesture.delaysTouchesEnded = false
|
||||
addGestureRecognizer(gesture)
|
||||
}
|
||||
|
||||
@objc func handleCatalystScrollWheelGesture(
|
||||
_ gesture: UIPanGestureRecognizer
|
||||
) {
|
||||
guard activePointerButton == nil else { return }
|
||||
|
||||
let translation = gesture.translation(in: self)
|
||||
gesture.setTranslation(.zero, in: self)
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"catalyst scroll translation=\(String(format: "%.2f", translation.x))x\(String(format: "%.2f", translation.y))"
|
||||
)
|
||||
|
||||
let scrollMods = TerminalScrollModifiers(precision: true)
|
||||
surface?.sendMouseScroll(
|
||||
x: Double(translation.x),
|
||||
y: Double(translation.y),
|
||||
mods: scrollMods.rawValue
|
||||
)
|
||||
}
|
||||
#else
|
||||
func setupTouchScrollInput() {
|
||||
let gesture = UIPanGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handleTouchScrollGesture(_:))
|
||||
)
|
||||
gesture.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)]
|
||||
gesture.maximumNumberOfTouches = 1
|
||||
addGestureRecognizer(gesture)
|
||||
|
||||
let longPress = UILongPressGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handleLongPressForSelection(_:))
|
||||
)
|
||||
longPress.minimumPressDuration = 0.5
|
||||
longPress.allowableMovement = 10
|
||||
longPress.numberOfTouchesRequired = 1
|
||||
longPress.numberOfTapsRequired = 0
|
||||
longPress.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)]
|
||||
longPress.cancelsTouchesInView = false
|
||||
longPress.delegate = self
|
||||
addGestureRecognizer(longPress)
|
||||
|
||||
setupIndirectPointerSelectionGesture()
|
||||
currentFontSize = configuration.fontSize ?? 14
|
||||
setupPinchZoomGesture()
|
||||
}
|
||||
|
||||
func setupIndirectPointerSelectionGesture() {
|
||||
let gesture = UIPanGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handleIndirectPointerSelectionGesture(_:))
|
||||
)
|
||||
gesture.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)]
|
||||
gesture.minimumNumberOfTouches = 1
|
||||
gesture.maximumNumberOfTouches = 1
|
||||
gesture.cancelsTouchesInView = false
|
||||
gesture.delaysTouchesBegan = false
|
||||
gesture.delaysTouchesEnded = false
|
||||
addGestureRecognizer(gesture)
|
||||
}
|
||||
|
||||
@objc func handleIndirectPointerSelectionGesture(
|
||||
_ gesture: UIPanGestureRecognizer
|
||||
) {
|
||||
let location = gesture.location(in: self)
|
||||
let mods = ghostty_input_mods_e(rawValue: 0)
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"indirect pointer gesture state=\(gesture.state.rawValue) location=\(NSCoder.string(for: location)) translation=\(NSCoder.string(for: gesture.translation(in: self)))"
|
||||
)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
core.setFocus(true)
|
||||
stopMomentumScrolling()
|
||||
indirectPointerPanOwnsTouchSequence = true
|
||||
if activePointerButton != GHOSTTY_MOUSE_LEFT {
|
||||
activePointerButton = GHOSTTY_MOUSE_LEFT
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_PRESS,
|
||||
button: GHOSTTY_MOUSE_LEFT,
|
||||
mods: mods
|
||||
)
|
||||
}
|
||||
if pointerSelectionStartPoint == nil {
|
||||
pointerSelectionStartPoint = location
|
||||
}
|
||||
pendingSelectionMenuPoint = nil
|
||||
surface?.sendMousePos(x: location.x, y: location.y, mods: mods)
|
||||
|
||||
case .changed:
|
||||
updatePointerSelectionRect(to: location)
|
||||
surface?.sendMousePos(x: location.x, y: location.y, mods: mods)
|
||||
|
||||
case .ended:
|
||||
activePointerButton = nil
|
||||
updatePointerSelectionRect(to: location)
|
||||
surface?.sendMousePos(x: location.x, y: location.y, mods: mods)
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_LEFT,
|
||||
mods: mods
|
||||
)
|
||||
finishPointerSelection(at: location)
|
||||
indirectPointerPanOwnsTouchSequence = false
|
||||
suppressNextIndirectPointerTouchEnd = true
|
||||
|
||||
case .cancelled, .failed:
|
||||
activePointerButton = nil
|
||||
indirectPointerPanOwnsTouchSequence = false
|
||||
suppressNextIndirectPointerTouchEnd = true
|
||||
pointerSelectionStartPoint = nil
|
||||
pendingSelectionMenuPoint = nil
|
||||
lastPointerSelectionRect = nil
|
||||
surface?.sendMouseButton(
|
||||
state: GHOSTTY_MOUSE_RELEASE,
|
||||
button: GHOSTTY_MOUSE_LEFT,
|
||||
mods: mods
|
||||
)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleLongPressForSelection(
|
||||
_ gesture: UILongPressGestureRecognizer
|
||||
) {
|
||||
guard gesture.state == .began else { return }
|
||||
guard let delegate = delegate as? any TerminalSurfaceTextSelectionRequestDelegate else { return }
|
||||
guard let surface else { return }
|
||||
guard case let .inMemory(session) = configuration.backend else {
|
||||
TerminalDebugLog.log(.input, "long-press selection ignored: backend not inMemory")
|
||||
return
|
||||
}
|
||||
|
||||
stopMomentumScrolling()
|
||||
|
||||
let viewPoint = gesture.location(in: self)
|
||||
surface.sendMousePos(
|
||||
x: Double(viewPoint.x),
|
||||
y: Double(viewPoint.y),
|
||||
mods: ghostty_input_mods_e(rawValue: 0)
|
||||
)
|
||||
|
||||
let wordResult = surface.quicklookWord()
|
||||
|
||||
guard let text = session.readViewportText() else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"long-press selection aborted: readViewportText returned nil"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var anchorRange: NSRange?
|
||||
if let w = wordResult, !text.isEmpty, let size = surface.size() {
|
||||
let scale = Double(resolvedDisplayScale())
|
||||
// cellWidth/HeightPixels are surface pixels; ghostty's
|
||||
// tl_px_x/y are host points. Convert to points before
|
||||
// dividing so units match inside resolveRange.
|
||||
let cellWidthPoints = scale > 0 ? Double(size.cellWidthPixels) / scale : 0
|
||||
let cellHeightPoints = scale > 0 ? Double(size.cellHeightPixels) / scale : 0
|
||||
anchorRange = TerminalSelectionAnchor.resolveRange(
|
||||
in: text,
|
||||
word: w.word,
|
||||
pointX: w.pointX,
|
||||
pointY: w.pointY,
|
||||
cellWidthPoints: cellWidthPoints,
|
||||
cellHeightPoints: cellHeightPoints
|
||||
)
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"long-press selection dispatch viewPoint=\(NSCoder.string(for: viewPoint)) word=\(TerminalDebugLog.describe(wordResult?.word ?? "nil")) anchor=\(anchorRange.map { NSStringFromRange($0) } ?? "nil")"
|
||||
)
|
||||
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
|
||||
delegate.terminalDidRequestTextSelection(.init(
|
||||
text: text,
|
||||
anchorRange: anchorRange,
|
||||
sourcePoint: viewPoint
|
||||
))
|
||||
}
|
||||
#endif
|
||||
|
||||
@objc func handleTouchScrollGesture(
|
||||
_ gesture: UIPanGestureRecognizer
|
||||
) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
guard activePointerButton == nil else { return }
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
touchDidScrollDuringCurrentTouch = true
|
||||
#endif
|
||||
TerminalDebugLog.log(.input, "touch scroll began")
|
||||
stopMomentumScrolling()
|
||||
|
||||
case .changed:
|
||||
guard activePointerButton == nil else { return }
|
||||
let translation = gesture.translation(in: self)
|
||||
gesture.setTranslation(.zero, in: self)
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"touch scroll changed translation=\(String(format: "%.2f", translation.x))x\(String(format: "%.2f", translation.y))"
|
||||
)
|
||||
|
||||
let scrollMods = TerminalScrollModifiers(precision: true)
|
||||
surface?.sendMouseScroll(
|
||||
x: Double(translation.x * touchScrollMultiplier),
|
||||
y: Double(translation.y * touchScrollMultiplier),
|
||||
mods: scrollMods.rawValue
|
||||
)
|
||||
|
||||
case .ended:
|
||||
guard activePointerButton == nil else { return }
|
||||
let velocity = gesture.velocity(in: self)
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"touch scroll ended velocity=\(String(format: "%.2f", velocity.x))x\(String(format: "%.2f", velocity.y))"
|
||||
)
|
||||
startMomentumScrolling(velocity: velocity)
|
||||
|
||||
case .cancelled, .failed:
|
||||
TerminalDebugLog.log(.input, "touch scroll cancelled")
|
||||
stopMomentumScrolling()
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func startMomentumScrolling(velocity: CGPoint) {
|
||||
guard abs(velocity.x) > 50 || abs(velocity.y) > 50 else { return }
|
||||
|
||||
momentumVelocity = velocity
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"momentum start velocity=\(String(format: "%.2f", velocity.x))x\(String(format: "%.2f", velocity.y))"
|
||||
)
|
||||
|
||||
let mods = TerminalScrollModifiers(precision: true, momentum: .began)
|
||||
surface?.sendMouseScroll(x: 0, y: 0, mods: mods.rawValue)
|
||||
|
||||
let link = CADisplayLink(
|
||||
target: self,
|
||||
selector: #selector(momentumScrollFrame(_:))
|
||||
)
|
||||
link.add(to: .main, forMode: .common)
|
||||
momentumDisplayLink = link
|
||||
}
|
||||
|
||||
@objc func momentumScrollFrame(_ link: CADisplayLink) {
|
||||
let dt = link.targetTimestamp - link.timestamp
|
||||
let deceleration: CGFloat = 0.92
|
||||
|
||||
momentumVelocity.x *= deceleration
|
||||
momentumVelocity.y *= deceleration
|
||||
|
||||
let deltaX = momentumVelocity.x * dt * touchScrollMultiplier
|
||||
let deltaY = momentumVelocity.y * dt * touchScrollMultiplier
|
||||
|
||||
if abs(momentumVelocity.x) < 50, abs(momentumVelocity.y) < 50 {
|
||||
stopMomentumScrolling()
|
||||
return
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"momentum frame velocity=\(String(format: "%.2f", momentumVelocity.x))x\(String(format: "%.2f", momentumVelocity.y)) delta=\(String(format: "%.2f", deltaX))x\(String(format: "%.2f", deltaY))"
|
||||
)
|
||||
|
||||
let mods = TerminalScrollModifiers(precision: true, momentum: .changed)
|
||||
surface?.sendMouseScroll(
|
||||
x: Double(deltaX),
|
||||
y: Double(deltaY),
|
||||
mods: mods.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
func stopMomentumScrolling(sendTerminalEndEvent: Bool = true) {
|
||||
guard momentumDisplayLink != nil else { return }
|
||||
TerminalDebugLog.log(.input, "momentum stop")
|
||||
|
||||
if sendTerminalEndEvent {
|
||||
let mods = TerminalScrollModifiers(precision: true, momentum: .none)
|
||||
surface?.sendMouseScroll(x: 0, y: 0, mods: mods.rawValue)
|
||||
}
|
||||
|
||||
momentumDisplayLink?.invalidate()
|
||||
momentumDisplayLink = nil
|
||||
momentumVelocity = .zero
|
||||
}
|
||||
}
|
||||
|
||||
extension UITerminalView: UIGestureRecognizerDelegate, UIContextMenuInteractionDelegate {
|
||||
/// Gate the long-press recognizer at the gesture layer when no host
|
||||
/// has opted into selection delegate. Without this, the recognizer
|
||||
/// still enters the touch arena for 0.5s and can subtly delay pan
|
||||
/// recognition for hosts that don't want the feature at all.
|
||||
override open func gestureRecognizerShouldBegin(
|
||||
_ gestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
if gestureRecognizer is UILongPressGestureRecognizer {
|
||||
return (delegate as? any TerminalSurfaceTextSelectionRequestDelegate) != nil
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
open func contextMenuInteraction(
|
||||
_: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation location: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
surface?.sendMousePos(
|
||||
x: location.x,
|
||||
y: location.y,
|
||||
mods: ghostty_input_mods_e(rawValue: 0)
|
||||
)
|
||||
guard selectionMenuPoint(at: location) != nil else { return nil }
|
||||
|
||||
return selectionContextMenuConfiguration(at: location)
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
246
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Keyboard.swift
vendored
Normal file
246
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Keyboard.swift
vendored
Normal file
@@ -0,0 +1,246 @@
|
||||
//
|
||||
// UITerminalView+Keyboard.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import GhosttyKit
|
||||
import UIKit
|
||||
|
||||
extension UITerminalView {
|
||||
override open func pressesBegan(
|
||||
_ presses: Set<UIPress>,
|
||||
with _: UIPressesEvent?
|
||||
) {
|
||||
for press in presses {
|
||||
guard let key = press.key else { continue }
|
||||
handleKeyPress(key, action: GHOSTTY_ACTION_PRESS)
|
||||
}
|
||||
}
|
||||
|
||||
override open func pressesEnded(
|
||||
_ presses: Set<UIPress>,
|
||||
with _: UIPressesEvent?
|
||||
) {
|
||||
for press in presses {
|
||||
guard let key = press.key else { continue }
|
||||
handleKeyPress(key, action: GHOSTTY_ACTION_RELEASE)
|
||||
}
|
||||
hardwareKeyHandled = false
|
||||
}
|
||||
|
||||
override open func pressesCancelled(
|
||||
_ presses: Set<UIPress>,
|
||||
with event: UIPressesEvent?
|
||||
) {
|
||||
hardwareKeyHandled = false
|
||||
super.pressesCancelled(presses, with: event)
|
||||
}
|
||||
|
||||
func handleKeyPress(
|
||||
_ key: UIKey,
|
||||
action: ghostty_input_action_e
|
||||
) {
|
||||
guard let surface else {
|
||||
TerminalDebugLog.log(.input, "uikit key ignored: missing surface")
|
||||
return
|
||||
}
|
||||
|
||||
let filteredModifierFlags = filteredModifierFlags(for: key)
|
||||
let isCommandModified = filteredModifierFlags.contains(.command)
|
||||
let mods = TerminalInputModifiers(from: filteredModifierFlags)
|
||||
let keyboardZoomDirection = commandZoomDirection(
|
||||
for: key,
|
||||
action: action,
|
||||
filteredModifierFlags: filteredModifierFlags
|
||||
)
|
||||
|
||||
if action == GHOSTTY_ACTION_PRESS,
|
||||
shouldSuppressUIKeyInput(for: key, isCommandModified: isCommandModified)
|
||||
{
|
||||
hardwareKeyHandled = true
|
||||
}
|
||||
|
||||
let delivery = TerminalHardwareKeyRouter.routeUIKit(
|
||||
usage: UInt16(key.keyCode.rawValue),
|
||||
backend: configuration.backend,
|
||||
modifiers: mods
|
||||
)
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"uikit key action=\(TerminalDebugLog.describe(action)) code=\(key.keyCode.rawValue) chars=\(TerminalDebugLog.describe(key.characters)) ignoring=\(TerminalDebugLog.describe(key.charactersIgnoringModifiers)) mods=0x\(String(filteredModifierFlags.rawValue, radix: 16)) delivery=\(delivery.debugSummary) marked=\(inputHandler.hasMarkedText)"
|
||||
)
|
||||
|
||||
if action == GHOSTTY_ACTION_RELEASE, delivery.isDirectInput {
|
||||
return
|
||||
}
|
||||
|
||||
if handleDirectInputIfNeeded(
|
||||
delivery,
|
||||
action: action,
|
||||
isCommandModified: isCommandModified,
|
||||
filteredModifierFlags: filteredModifierFlags
|
||||
) {
|
||||
if let keyboardZoomDirection {
|
||||
scheduleViewportRefreshAfterKeyboardZoom(keyboardZoomDirection)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var keyEvent = ghostty_input_key_s()
|
||||
keyEvent.action = action
|
||||
keyEvent.mods = mods.ghosttyMods
|
||||
// Ghostty expects a platform-native keycode, which it resolves
|
||||
// to its internal Key enum via src/input/keycodes.zig. On iOS
|
||||
// that table uses macOS virtual keycodes (native_idx = 4), so
|
||||
// translate the documented HID usage value from UIKey into the
|
||||
// corresponding AppKit keycode here.
|
||||
keyEvent.keycode = TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(
|
||||
usage: UInt16(key.keyCode.rawValue)
|
||||
)
|
||||
keyEvent.composing = inputHandler.hasMarkedText
|
||||
|
||||
var consumedFlags = filteredModifierFlags
|
||||
consumedFlags.remove(.control)
|
||||
consumedFlags.remove(.command)
|
||||
keyEvent.consumed_mods = TerminalInputModifiers(from: consumedFlags).ghosttyMods
|
||||
|
||||
guard action == GHOSTTY_ACTION_PRESS || action == GHOSTTY_ACTION_REPEAT else {
|
||||
_ = surface.sendKeyEvent(keyEvent)
|
||||
return
|
||||
}
|
||||
|
||||
let filteredIgnoringModifiers = TerminalInputText.filteredFunctionKeyText(
|
||||
key.charactersIgnoringModifiers
|
||||
)
|
||||
|
||||
if let codepoint = filteredIgnoringModifiers?.unicodeScalars.first {
|
||||
keyEvent.unshifted_codepoint = codepoint.value
|
||||
}
|
||||
|
||||
guard !isCommandModified else {
|
||||
_ = surface.sendKeyEvent(keyEvent)
|
||||
if let keyboardZoomDirection {
|
||||
scheduleViewportRefreshAfterKeyboardZoom(keyboardZoomDirection)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let text = TerminalInputText.filteredFunctionKeyText(key.characters),
|
||||
!text.isEmpty
|
||||
else {
|
||||
_ = surface.sendKeyEvent(keyEvent)
|
||||
return
|
||||
}
|
||||
|
||||
text.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
_ = surface.sendKeyEvent(keyEvent)
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSuppressUIKeyInput(
|
||||
for key: UIKey,
|
||||
isCommandModified: Bool
|
||||
) -> Bool {
|
||||
guard !isCommandModified else { return false }
|
||||
guard key.modifierFlags.intersection([.alternate, .control]).isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard !key.characters.isEmpty else {
|
||||
return key.keyCode == .keyboardDeleteOrBackspace
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func handleDirectInputIfNeeded(
|
||||
_ delivery: TerminalHardwareKeyDelivery,
|
||||
action: ghostty_input_action_e,
|
||||
isCommandModified: Bool,
|
||||
filteredModifierFlags: UIKeyModifierFlags
|
||||
) -> Bool {
|
||||
// When IME composition is active, UIKit must own editing keys such as
|
||||
// backspace and arrows so candidate text stays in sync.
|
||||
guard !inputHandler.hasMarkedText else { return false }
|
||||
guard !isCommandModified else { return false }
|
||||
guard filteredModifierFlags.intersection([.alternate, .control]).isEmpty else {
|
||||
return false
|
||||
}
|
||||
guard action == GHOSTTY_ACTION_PRESS || action == GHOSTTY_ACTION_REPEAT else {
|
||||
return false
|
||||
}
|
||||
guard case let .data(sequence) = delivery else { return false }
|
||||
guard case let .inMemory(session) = configuration.backend else { return false }
|
||||
|
||||
session.sendInput(sequence)
|
||||
return true
|
||||
}
|
||||
|
||||
private func filteredModifierFlags(for key: UIKey) -> UIKeyModifierFlags {
|
||||
var flags = key.modifierFlags
|
||||
let isFunctionKey =
|
||||
TerminalInputText.filteredFunctionKeyText(key.characters) == nil ||
|
||||
TerminalInputText.filteredFunctionKeyText(key.charactersIgnoringModifiers) == nil
|
||||
if isFunctionKey {
|
||||
flags.remove(.numericPad)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
private func commandZoomDirection(
|
||||
for key: UIKey,
|
||||
action: ghostty_input_action_e,
|
||||
filteredModifierFlags: UIKeyModifierFlags
|
||||
) -> KeyboardZoomDirection? {
|
||||
guard action == GHOSTTY_ACTION_PRESS || action == GHOSTTY_ACTION_REPEAT else {
|
||||
return nil
|
||||
}
|
||||
guard filteredModifierFlags.contains(.command) else { return nil }
|
||||
|
||||
let candidates = [
|
||||
key.characters,
|
||||
key.charactersIgnoringModifiers,
|
||||
]
|
||||
if candidates.contains(where: { $0 == "+" || $0 == "=" }) {
|
||||
return .increase
|
||||
}
|
||||
if candidates.contains(where: { $0 == "-" || $0 == "_" }) {
|
||||
return .decrease
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func scheduleViewportRefreshAfterKeyboardZoom(
|
||||
_ direction: KeyboardZoomDirection
|
||||
) {
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"keyboard zoom shortcut direction=\(direction.rawValue)"
|
||||
)
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
switch direction {
|
||||
case .increase:
|
||||
currentFontSize = min(currentFontSize + 1, Self.maxFontSize)
|
||||
case .decrease:
|
||||
currentFontSize = max(currentFontSize - 1, Self.minFontSize)
|
||||
}
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
core.synchronizeMetrics()
|
||||
refreshTextInputGeometry(
|
||||
reason: "keyboard-zoom-\(direction.rawValue)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum KeyboardZoomDirection: String {
|
||||
case increase
|
||||
case decrease
|
||||
}
|
||||
}
|
||||
#endif
|
||||
172
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Lifecycle.swift
vendored
Normal file
172
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+Lifecycle.swift
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// UITerminalView+Lifecycle.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/17.
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
|
||||
extension UITerminalView {
|
||||
func setupApplicationLifecycleObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidEnterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidBecomeActive),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
func syncApplicationActiveState() {
|
||||
core.setApplicationActive(
|
||||
UIApplication.shared.applicationState == .active
|
||||
)
|
||||
}
|
||||
|
||||
@objc func applicationDidEnterBackground(_: Notification) {
|
||||
TerminalDebugLog.log(.lifecycle, "application did enter background")
|
||||
stopMomentumScrolling(sendTerminalEndEvent: false)
|
||||
core.setApplicationActive(false)
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_: Notification) {
|
||||
TerminalDebugLog.log(.lifecycle, "application did become active")
|
||||
updateDisplayScale()
|
||||
updateColorScheme()
|
||||
core.setApplicationActive(true)
|
||||
}
|
||||
|
||||
override open func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
TerminalDebugLog.log(
|
||||
.lifecycle,
|
||||
"didMoveToWindow attached=\(window != nil)"
|
||||
)
|
||||
updateDisplayScale()
|
||||
if window != nil {
|
||||
core.rebuildIfReady()
|
||||
updateColorScheme()
|
||||
core.startDisplayLink()
|
||||
// Defer sublayer frame and metrics sync to the next runloop
|
||||
// so that AutoLayout has resolved final bounds.
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, window != nil else { return }
|
||||
updateSublayerFrames()
|
||||
core.fitToSize()
|
||||
}
|
||||
} else {
|
||||
core.stopDisplayLink()
|
||||
core.freeSurface()
|
||||
}
|
||||
}
|
||||
|
||||
override open func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"layoutSubviews bounds=\(NSCoder.string(for: bounds))"
|
||||
)
|
||||
updateSublayerFrames()
|
||||
core.fitToSize()
|
||||
}
|
||||
|
||||
func resolvedDisplayScale() -> CGFloat {
|
||||
if let screen = window?.screen {
|
||||
return screen.nativeScale
|
||||
}
|
||||
if traitCollection.displayScale > 0 {
|
||||
return traitCollection.displayScale
|
||||
}
|
||||
return UIScreen.main.nativeScale
|
||||
}
|
||||
|
||||
func updateDisplayScale() {
|
||||
let scale = resolvedDisplayScale()
|
||||
TerminalDebugLog.log(
|
||||
.metrics,
|
||||
"updateDisplayScale scale=\(String(format: "%.2f", scale))"
|
||||
)
|
||||
contentScaleFactor = scale
|
||||
layer.contentsScale = scale
|
||||
updateSublayerFrames()
|
||||
}
|
||||
|
||||
func updateSublayerFrames() {
|
||||
let scale = resolvedDisplayScale()
|
||||
contentScaleFactor = scale
|
||||
layer.contentsScale = scale
|
||||
guard let sublayers = layer.sublayers else { return }
|
||||
for sublayer in sublayers {
|
||||
sublayer.frame = bounds
|
||||
sublayer.contentsScale = scale
|
||||
}
|
||||
}
|
||||
|
||||
func enforceSublayerScale() {
|
||||
let scale = resolvedDisplayScale()
|
||||
guard let sublayers = layer.sublayers else { return }
|
||||
for sublayer in sublayers {
|
||||
if sublayer.contentsScale != scale {
|
||||
sublayer.contentsScale = scale
|
||||
}
|
||||
if sublayer.frame != bounds {
|
||||
sublayer.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func fitToSize() {
|
||||
core.fitToSize()
|
||||
}
|
||||
|
||||
override open func traitCollectionDidChange(
|
||||
_ previousTraitCollection: UITraitCollection?
|
||||
) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
updateDisplayScale()
|
||||
if traitCollection.hasDifferentColorAppearance(
|
||||
comparedTo: previousTraitCollection
|
||||
) {
|
||||
updateColorScheme()
|
||||
}
|
||||
}
|
||||
|
||||
func updateColorScheme() {
|
||||
let style = traitCollection.userInterfaceStyle
|
||||
let scheme: TerminalColorScheme = style == .dark ? .dark : .light
|
||||
TerminalDebugLog.log(.lifecycle, "updateColorScheme scheme=\(scheme)")
|
||||
surface?.setColorScheme(scheme.ghosttyValue)
|
||||
if let controller,
|
||||
let viewState = delegate as? TerminalViewState,
|
||||
viewState.controller === controller
|
||||
{
|
||||
viewState.adopt(terminalColorScheme: scheme)
|
||||
} else {
|
||||
controller?.setColorScheme(scheme)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
override open func becomeFirstResponder() -> Bool {
|
||||
let result = super.becomeFirstResponder()
|
||||
core.setFocus(true)
|
||||
onFocusChange?(true)
|
||||
return result
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
override open func resignFirstResponder() -> Bool {
|
||||
let result = super.resignFirstResponder()
|
||||
core.setFocus(false)
|
||||
onFocusChange?(false)
|
||||
return result
|
||||
}
|
||||
}
|
||||
#endif
|
||||
79
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PinchZoom.swift
vendored
Normal file
79
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PinchZoom.swift
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// UITerminalView+PinchZoom.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
|
||||
import UIKit
|
||||
|
||||
extension UITerminalView {
|
||||
private static let scaleStepThreshold: CGFloat = 0.1
|
||||
|
||||
func setupPinchZoomGesture() {
|
||||
let pinch = UIPinchGestureRecognizer(
|
||||
target: self,
|
||||
action: #selector(handlePinchGesture(_:))
|
||||
)
|
||||
addGestureRecognizer(pinch)
|
||||
}
|
||||
|
||||
@objc func handlePinchGesture(_ gesture: UIPinchGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
lastPinchScale = gesture.scale
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"pinch began scale=\(String(format: "%.3f", gesture.scale)) fontSize=\(currentFontSize)"
|
||||
)
|
||||
|
||||
case .changed:
|
||||
let delta = gesture.scale - lastPinchScale
|
||||
|
||||
let steps = Int(delta / Self.scaleStepThreshold)
|
||||
guard steps != 0 else { return }
|
||||
|
||||
lastPinchScale += CGFloat(steps) * Self.scaleStepThreshold
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"pinch changed scale=\(String(format: "%.3f", gesture.scale)) delta=\(String(format: "%.3f", delta)) steps=\(steps)"
|
||||
)
|
||||
|
||||
var changed = false
|
||||
if steps > 0 {
|
||||
for _ in 0 ..< steps {
|
||||
guard currentFontSize < Self.maxFontSize else { break }
|
||||
surface?.performBindingAction("increase_font_size:1")
|
||||
currentFontSize += 1
|
||||
changed = true
|
||||
}
|
||||
} else {
|
||||
for _ in 0 ..< abs(steps) {
|
||||
guard currentFontSize > Self.minFontSize else { break }
|
||||
surface?.performBindingAction("decrease_font_size:1")
|
||||
currentFontSize -= 1
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
core.synchronizeMetrics()
|
||||
refreshTextInputGeometry(reason: "pinch-zoom")
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"pinch applied fontSize=\(currentFontSize)"
|
||||
)
|
||||
}
|
||||
|
||||
case .ended, .cancelled, .failed:
|
||||
lastPinchScale = 1.0
|
||||
TerminalDebugLog.log(
|
||||
.actions,
|
||||
"pinch ended state=\(gesture.state.rawValue) fontSize=\(currentFontSize)"
|
||||
)
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
34
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PublicInput.swift
vendored
Normal file
34
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PublicInput.swift
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// UITerminalView+PublicInput.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Public wrappers around TerminalSurface input and navigation actions.
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
|
||||
extension UITerminalView {
|
||||
/// Invoke a named Ghostty binding action (e.g. "copy_to_clipboard",
|
||||
/// "clear_screen"). Returns true when the action dispatched.
|
||||
@discardableResult
|
||||
public func performBindingAction(_ action: String) -> Bool {
|
||||
surface?.performBindingAction(action) ?? false
|
||||
}
|
||||
|
||||
/// Jump the viewport by a number of shell prompts.
|
||||
///
|
||||
/// Negative offsets move toward older prompts and positive offsets move
|
||||
/// toward newer prompts. Prompt navigation requires shell integration.
|
||||
@discardableResult
|
||||
public func jumpToPrompt(by offset: Int16) -> Bool {
|
||||
surface?.jumpToPrompt(by: offset) ?? false
|
||||
}
|
||||
|
||||
/// Reveal an absolute scrollback row, where zero is the first row.
|
||||
@discardableResult
|
||||
public func scrollToRow(_ row: UInt) -> Bool {
|
||||
surface?.scrollToRow(row) ?? false
|
||||
}
|
||||
}
|
||||
#endif
|
||||
107
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PublicSticky.swift
vendored
Normal file
107
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+PublicSticky.swift
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// UITerminalView+PublicSticky.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Public wrappers around the iOS sticky-modifier state machine so
|
||||
// hosts that suppress `inputAccessoryView` (and supply their own chip
|
||||
// pill UI) can still drive the same Ctrl/Alt/Cmd sticky path that the
|
||||
// bundled `TerminalInputAccessoryView` uses.
|
||||
//
|
||||
// Without this surface a host with a custom keyboard accessory has to
|
||||
// either:
|
||||
// * reimplement the full sticky state machine + IME compose handshake
|
||||
// in app code (fragile, has to mirror libghostty internals), OR
|
||||
// * intercept the outbound surface byte stream and try to transform
|
||||
// bytes after-the-fact (breaks because libghostty wraps every
|
||||
// `surface.sendText` call in bracketed-paste markers when the
|
||||
// remote shell enabled mode 2004).
|
||||
//
|
||||
// Forwarding to the existing internal `stickyModifiers` keeps a
|
||||
// single source of truth — the bundled accessory and the host's
|
||||
// custom chip UI both end up calling the same `toggle(_:)` /
|
||||
// `consumeForNextKey()` codepath that `insertText` already respects.
|
||||
//
|
||||
|
||||
#if canImport(UIKit) && !targetEnvironment(macCatalyst)
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// Public mirror of the internal `TerminalStickyModifierState.Modifier`
|
||||
/// enum. Decoupled so the internal type stays free to evolve.
|
||||
public enum TerminalPublicStickyModifier: String, Sendable {
|
||||
case ctrl
|
||||
case alt
|
||||
case command
|
||||
}
|
||||
|
||||
/// Public mirror of `TerminalStickyModifierState.Activation`.
|
||||
public enum TerminalPublicStickyActivation: String, Sendable {
|
||||
case inactive
|
||||
case armed
|
||||
case locked
|
||||
}
|
||||
|
||||
@MainActor
|
||||
extension UITerminalView {
|
||||
/// Toggle the sticky activation for `modifier`. Tap-to-arm,
|
||||
/// double-tap-to-lock semantics match the bundled accessory.
|
||||
/// Safe to call regardless of whether `inputAccessoryView` is
|
||||
/// currently shown — only mutates the internal state machine,
|
||||
/// which `insertText` consults on every keystroke.
|
||||
public func toggleStickyModifier(_ modifier: TerminalPublicStickyModifier) {
|
||||
stickyModifiers.toggle(internalModifier(for: modifier))
|
||||
}
|
||||
|
||||
/// Read current activation for inspection / sync (e.g. host UI
|
||||
/// reflecting state changes triggered by the bundled accessory).
|
||||
public func stickyActivation(
|
||||
for modifier: TerminalPublicStickyModifier
|
||||
) -> TerminalPublicStickyActivation {
|
||||
switch modifier {
|
||||
case .ctrl: publicActivation(stickyModifiers.ctrl)
|
||||
case .alt: publicActivation(stickyModifiers.alt)
|
||||
case .command: publicActivation(stickyModifiers.command)
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff any modifier is `.armed` or `.locked`.
|
||||
public var hasActiveStickyModifiers: Bool {
|
||||
stickyModifiers.hasActiveModifiers
|
||||
}
|
||||
|
||||
/// Clear all sticky activation. No-op when nothing is active.
|
||||
public func resetStickyModifiers() {
|
||||
stickyModifiers.reset()
|
||||
}
|
||||
|
||||
/// Subscribe to sticky-state changes. Called on every transition
|
||||
/// (toggle / consume / reset). Replaces any prior closure — pass
|
||||
/// `nil` to detach. Useful for host UIs that mirror the activation
|
||||
/// in their own chip pill.
|
||||
public func setStickyModifierChangeHandler(_ handler: (() -> Void)?) {
|
||||
stickyModifiers.onChange = handler
|
||||
}
|
||||
|
||||
// MARK: - Internal mappers
|
||||
|
||||
private func internalModifier(
|
||||
for modifier: TerminalPublicStickyModifier
|
||||
) -> TerminalStickyModifierState.Modifier {
|
||||
switch modifier {
|
||||
case .ctrl: .ctrl
|
||||
case .alt: .alt
|
||||
case .command: .command
|
||||
}
|
||||
}
|
||||
|
||||
private func publicActivation(
|
||||
_ activation: TerminalStickyModifierState.Activation
|
||||
) -> TerminalPublicStickyActivation {
|
||||
switch activation {
|
||||
case .inactive: .inactive
|
||||
case .armed: .armed
|
||||
case .locked: .locked
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
449
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+UITextInput.swift
vendored
Normal file
449
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView+UITextInput.swift
vendored
Normal file
@@ -0,0 +1,449 @@
|
||||
//
|
||||
// UITerminalView+UITextInput.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import GhosttyKit
|
||||
import UIKit
|
||||
|
||||
extension UITerminalView: UITextInput, UITextInputTraits {
|
||||
// MARK: - UITextInputTraits
|
||||
|
||||
open var autocorrectionType: UITextAutocorrectionType {
|
||||
get { .no }
|
||||
set {}
|
||||
}
|
||||
|
||||
open var autocapitalizationType: UITextAutocapitalizationType {
|
||||
get { .none }
|
||||
set {}
|
||||
}
|
||||
|
||||
open var smartQuotesType: UITextSmartQuotesType {
|
||||
get { .no }
|
||||
set {}
|
||||
}
|
||||
|
||||
open var smartDashesType: UITextSmartDashesType {
|
||||
get { .no }
|
||||
set {}
|
||||
}
|
||||
|
||||
open var smartInsertDeleteType: UITextSmartInsertDeleteType {
|
||||
get { .no }
|
||||
set {}
|
||||
}
|
||||
|
||||
open var spellCheckingType: UITextSpellCheckingType {
|
||||
get { .no }
|
||||
set {}
|
||||
}
|
||||
|
||||
open var keyboardType: UIKeyboardType {
|
||||
get { .default }
|
||||
set {}
|
||||
}
|
||||
|
||||
// MARK: - UIKeyInput
|
||||
|
||||
open func insertText(_ text: String) {
|
||||
guard !hardwareKeyHandled else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"insertText suppressed text=\(TerminalDebugLog.describe(text))"
|
||||
)
|
||||
hardwareKeyHandled = false
|
||||
return
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if inputHandler.hasMarkedText {
|
||||
inputHandler.insertText(text)
|
||||
return
|
||||
}
|
||||
|
||||
if stickyModifiers.hasActiveModifiers {
|
||||
_ = handleStickyTextInput(text)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
inputHandler.insertText(text)
|
||||
}
|
||||
|
||||
open func deleteBackward() {
|
||||
if inputHandler.deleteBackwardInMarkedText() {
|
||||
TerminalDebugLog.log(.input, "deleteBackward handled by marked text")
|
||||
hardwareKeyHandled = false
|
||||
return
|
||||
}
|
||||
|
||||
guard !hardwareKeyHandled else {
|
||||
TerminalDebugLog.log(.input, "deleteBackward suppressed")
|
||||
hardwareKeyHandled = false
|
||||
return
|
||||
}
|
||||
|
||||
let usage = UInt16(UIKeyboardHIDUsage.keyboardDeleteOrBackspace.rawValue)
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if stickyModifiers.hasActiveModifiers {
|
||||
let mods = stickyModifiers.consumeForNextKey()
|
||||
sendSyntheticKey(usage: usage, additionalMods: mods)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
let delivery = TerminalHardwareKeyRouter.routeUIKit(
|
||||
usage: usage,
|
||||
backend: configuration.backend
|
||||
)
|
||||
if case let .data(sequence) = delivery,
|
||||
case let .inMemory(session) = configuration.backend
|
||||
{
|
||||
session.sendInput(sequence)
|
||||
return
|
||||
}
|
||||
|
||||
var keyEvent = ghostty_input_key_s()
|
||||
keyEvent.action = GHOSTTY_ACTION_PRESS
|
||||
keyEvent.mods = ghostty_input_mods_e(rawValue: 0)
|
||||
keyEvent.keycode = TerminalHardwareKeyRouter.appKitKeyCodeForUIKit(
|
||||
usage: usage
|
||||
)
|
||||
keyEvent.composing = false
|
||||
|
||||
let delete = "\u{7F}"
|
||||
delete.withCString { ptr in
|
||||
keyEvent.text = ptr
|
||||
surface?.sendKeyEvent(keyEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UITextInput Marked Text
|
||||
|
||||
open func setMarkedText(
|
||||
_ markedText: String?,
|
||||
selectedRange: NSRange
|
||||
) {
|
||||
inputHandler.setMarkedText(markedText, selectedRange: selectedRange)
|
||||
}
|
||||
|
||||
open func unmarkText() {
|
||||
inputHandler.unmarkText(applyingStickyModifiers: false)
|
||||
}
|
||||
|
||||
open var markedTextRange: UITextRange? {
|
||||
inputHandler.markedTextRange()
|
||||
}
|
||||
|
||||
open var markedTextStyle: [NSAttributedString.Key: Any]? {
|
||||
get { nil }
|
||||
set {}
|
||||
}
|
||||
|
||||
// MARK: - UITextInput Selection
|
||||
|
||||
open var selectedTextRange: UITextRange? {
|
||||
get { inputHandler.selectedTextRange() }
|
||||
set { inputHandler.setSelectedTextRange(newValue) }
|
||||
}
|
||||
|
||||
// MARK: - UITextInput Positions
|
||||
|
||||
open var beginningOfDocument: UITextPosition {
|
||||
TerminalTextPosition(0)
|
||||
}
|
||||
|
||||
open var endOfDocument: UITextPosition {
|
||||
TerminalTextPosition(inputHandler.documentLength)
|
||||
}
|
||||
|
||||
open func textRange(
|
||||
from fromPosition: UITextPosition,
|
||||
to toPosition: UITextPosition
|
||||
) -> UITextRange? {
|
||||
guard
|
||||
let from = fromPosition as? TerminalTextPosition,
|
||||
let to = toPosition as? TerminalTextPosition
|
||||
else { return nil }
|
||||
return TerminalTextRange(start: from, end: to)
|
||||
}
|
||||
|
||||
open func position(
|
||||
from position: UITextPosition,
|
||||
offset: Int
|
||||
) -> UITextPosition? {
|
||||
guard let pos = position as? TerminalTextPosition else { return nil }
|
||||
let newIndex = pos.index + offset
|
||||
guard newIndex >= 0, newIndex <= inputHandler.documentLength else { return nil }
|
||||
return TerminalTextPosition(newIndex)
|
||||
}
|
||||
|
||||
open func position(
|
||||
from position: UITextPosition,
|
||||
in _: UITextLayoutDirection,
|
||||
offset: Int
|
||||
) -> UITextPosition? {
|
||||
self.position(from: position, offset: offset)
|
||||
}
|
||||
|
||||
open func compare(
|
||||
_ position: UITextPosition,
|
||||
to other: UITextPosition
|
||||
) -> ComparisonResult {
|
||||
guard
|
||||
let lhs = position as? TerminalTextPosition,
|
||||
let rhs = other as? TerminalTextPosition
|
||||
else { return .orderedSame }
|
||||
|
||||
if lhs.index < rhs.index { return .orderedAscending }
|
||||
if lhs.index > rhs.index { return .orderedDescending }
|
||||
return .orderedSame
|
||||
}
|
||||
|
||||
open func offset(
|
||||
from: UITextPosition,
|
||||
to toPosition: UITextPosition
|
||||
) -> Int {
|
||||
guard
|
||||
let f = from as? TerminalTextPosition,
|
||||
let t = toPosition as? TerminalTextPosition
|
||||
else { return 0 }
|
||||
return t.index - f.index
|
||||
}
|
||||
|
||||
// MARK: - UITextInput Text
|
||||
|
||||
open func text(in range: UITextRange) -> String? {
|
||||
guard let range = range as? TerminalTextRange else { return nil }
|
||||
return inputHandler.text(in: range)
|
||||
}
|
||||
|
||||
open func replace(_: UITextRange, withText text: String) {
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if inputHandler.hasMarkedText {
|
||||
inputHandler.insertText(text)
|
||||
return
|
||||
}
|
||||
|
||||
if stickyModifiers.hasActiveModifiers {
|
||||
_ = handleStickyTextInput(text)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
inputHandler.insertText(text)
|
||||
}
|
||||
|
||||
// MARK: - UITextInput Delegate
|
||||
|
||||
open var inputDelegate: (any UITextInputDelegate)? {
|
||||
get { _inputDelegate }
|
||||
set { _inputDelegate = newValue }
|
||||
}
|
||||
|
||||
// MARK: - UITextInput Tokenizer
|
||||
|
||||
open var tokenizer: any UITextInputTokenizer {
|
||||
UITextInputStringTokenizer(textInput: self)
|
||||
}
|
||||
|
||||
open var textInputView: UIView {
|
||||
self
|
||||
}
|
||||
|
||||
// MARK: - UITextInput Geometry
|
||||
|
||||
open func firstRect(for range: UITextRange) -> CGRect {
|
||||
if let range = range as? TerminalTextRange {
|
||||
return rectForRange(range)
|
||||
}
|
||||
return markedTextRect()
|
||||
}
|
||||
|
||||
open func caretRect(for position: UITextPosition) -> CGRect {
|
||||
caretRectForPosition(position)
|
||||
}
|
||||
|
||||
open func selectionRects(
|
||||
for _: UITextRange
|
||||
) -> [UITextSelectionRect] {
|
||||
[]
|
||||
}
|
||||
|
||||
open func closestPosition(to point: CGPoint) -> UITextPosition? {
|
||||
TerminalTextPosition(textIndex(for: point))
|
||||
}
|
||||
|
||||
open func closestPosition(
|
||||
to point: CGPoint,
|
||||
within _: UITextRange
|
||||
) -> UITextPosition? {
|
||||
closestPosition(to: point)
|
||||
}
|
||||
|
||||
open func characterRange(at point: CGPoint) -> UITextRange? {
|
||||
let index = textIndex(for: point)
|
||||
return TerminalTextRange(location: index, length: 0)
|
||||
}
|
||||
|
||||
open func position(
|
||||
within range: UITextRange,
|
||||
farthestIn direction: UITextLayoutDirection
|
||||
) -> UITextPosition? {
|
||||
switch direction {
|
||||
case .left, .up: return range.start
|
||||
case .right, .down: return range.end
|
||||
@unknown default: return range.start
|
||||
}
|
||||
}
|
||||
|
||||
open func characterRange(
|
||||
byExtending position: UITextPosition,
|
||||
in _: UITextLayoutDirection
|
||||
) -> UITextRange? {
|
||||
guard inputHandler.documentLength > 0,
|
||||
let position = position as? TerminalTextPosition
|
||||
else {
|
||||
return TerminalTextRange(location: 0, length: 0)
|
||||
}
|
||||
|
||||
let location = min(max(position.index, 0), inputHandler.documentLength - 1)
|
||||
return TerminalTextRange(location: location, length: 1)
|
||||
}
|
||||
|
||||
open func baseWritingDirection(
|
||||
for _: UITextPosition,
|
||||
in _: UITextStorageDirection
|
||||
) -> NSWritingDirection {
|
||||
.leftToRight
|
||||
}
|
||||
|
||||
open func setBaseWritingDirection(
|
||||
_: NSWritingDirection,
|
||||
for _: UITextRange
|
||||
) {}
|
||||
|
||||
private func imeRect() -> CGRect {
|
||||
guard let surface else { return .zero }
|
||||
let point = surface.imePoint()
|
||||
return CGRect(
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
width: point.width,
|
||||
height: point.height
|
||||
)
|
||||
}
|
||||
|
||||
private func markedTextRect() -> CGRect {
|
||||
let baseRect = imeRect()
|
||||
guard
|
||||
inputHandler.documentLength > 0,
|
||||
let range = markedTextRange as? TerminalTextRange
|
||||
else {
|
||||
return baseRect
|
||||
}
|
||||
|
||||
return rect(for: range, in: baseRect, fallbackWidth: baseRect.width)
|
||||
}
|
||||
|
||||
private func rectForRange(_ range: TerminalTextRange) -> CGRect {
|
||||
rect(for: range, in: imeRect(), fallbackWidth: 2)
|
||||
}
|
||||
|
||||
private func caretRectForPosition(_ position: UITextPosition) -> CGRect {
|
||||
let baseRect = imeRect()
|
||||
let cellWidth = compositionCellWidth(in: baseRect)
|
||||
guard inputHandler.documentLength > 0 else {
|
||||
let rect = CGRect(
|
||||
x: baseRect.minX,
|
||||
y: baseRect.minY,
|
||||
width: cellWidth,
|
||||
height: baseRect.height
|
||||
)
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"caretRect empty position base=\(NSCoder.string(for: baseRect)) rect=\(NSCoder.string(for: rect))"
|
||||
)
|
||||
return rect
|
||||
}
|
||||
|
||||
guard let position = position as? TerminalTextPosition else {
|
||||
let rect = CGRect(
|
||||
x: baseRect.maxX,
|
||||
y: baseRect.minY,
|
||||
width: cellWidth,
|
||||
height: baseRect.height
|
||||
)
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"caretRect fallback base=\(NSCoder.string(for: baseRect)) rect=\(NSCoder.string(for: rect))"
|
||||
)
|
||||
return rect
|
||||
}
|
||||
|
||||
let clampedIndex = min(max(position.index, 0), inputHandler.documentLength)
|
||||
let x = baseRect.minX + CGFloat(clampedIndex) * cellWidth
|
||||
let rect = CGRect(
|
||||
x: x,
|
||||
y: baseRect.minY,
|
||||
width: cellWidth,
|
||||
height: baseRect.height
|
||||
)
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"caretRect index=\(clampedIndex) base=\(NSCoder.string(for: baseRect)) cellWidth=\(String(format: "%.2f", cellWidth)) rect=\(NSCoder.string(for: rect))"
|
||||
)
|
||||
return rect
|
||||
}
|
||||
|
||||
private func rect(
|
||||
for range: TerminalTextRange,
|
||||
in baseRect: CGRect,
|
||||
fallbackWidth: CGFloat
|
||||
) -> CGRect {
|
||||
let documentLength = max(inputHandler.documentLength, 1)
|
||||
let cellWidth = compositionCellWidth(in: baseRect)
|
||||
let location = min(max(range.location, 0), documentLength)
|
||||
let length = max(range.length, 0)
|
||||
let x = baseRect.minX + CGFloat(location) * cellWidth
|
||||
let width = max(CGFloat(length) * cellWidth, fallbackWidth)
|
||||
return CGRect(
|
||||
x: x,
|
||||
y: baseRect.minY,
|
||||
width: width,
|
||||
height: baseRect.height
|
||||
)
|
||||
}
|
||||
|
||||
private func compositionCellWidth(in baseRect: CGRect) -> CGFloat {
|
||||
if baseRect.width > 0 {
|
||||
return max(baseRect.width, 2)
|
||||
}
|
||||
|
||||
guard let size = surface?.size() else { return 2 }
|
||||
let scale = resolvedDisplayScale()
|
||||
guard scale > 0 else { return CGFloat(max(size.cellWidthPixels, 2)) }
|
||||
return max(CGFloat(size.cellWidthPixels) / scale, 2)
|
||||
}
|
||||
|
||||
private func textIndex(for point: CGPoint) -> Int {
|
||||
let baseRect = imeRect()
|
||||
guard inputHandler.documentLength > 0 else { return 0 }
|
||||
|
||||
let cellWidth = compositionCellWidth(in: baseRect)
|
||||
guard cellWidth > 0 else { return 0 }
|
||||
|
||||
let relativeX = point.x - baseRect.minX
|
||||
let rawIndex = Int((relativeX / cellWidth).rounded(.down))
|
||||
let index = min(max(rawIndex, 0), inputHandler.documentLength)
|
||||
TerminalDebugLog.log(
|
||||
.ime,
|
||||
"textIndex point=\(NSCoder.string(for: point)) base=\(NSCoder.string(for: baseRect)) cellWidth=\(String(format: "%.2f", cellWidth)) index=\(index)"
|
||||
)
|
||||
return index
|
||||
}
|
||||
}
|
||||
#endif
|
||||
285
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView.swift
vendored
Normal file
285
vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView.swift
vendored
Normal file
@@ -0,0 +1,285 @@
|
||||
//
|
||||
// UITerminalView.swift
|
||||
// libghostty-spm
|
||||
//
|
||||
// Created by Lakr233 on 2026/3/16.
|
||||
//
|
||||
|
||||
#if canImport(UIKit)
|
||||
import GhosttyKit
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
open class UITerminalView: UIView {
|
||||
let core = TerminalSurfaceCoordinator()
|
||||
var momentumDisplayLink: CADisplayLink?
|
||||
var momentumVelocity: CGPoint = .zero
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
static let minFontSize: Float = 4
|
||||
static let maxFontSize: Float = 64
|
||||
#endif
|
||||
var activePointerButton: ghostty_input_mouse_button_e?
|
||||
var pointerSelectionStartPoint: CGPoint?
|
||||
var lastPointerSelectionRect: CGRect?
|
||||
var pendingSelectionMenuPoint: CGPoint?
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
var indirectPointerPanOwnsTouchSequence = false
|
||||
var suppressNextIndirectPointerTouchEnd = false
|
||||
#endif
|
||||
lazy var selectionContextMenuInteraction = UIContextMenuInteraction(delegate: self)
|
||||
var hardwareKeyHandled = false
|
||||
let touchScrollMultiplier: CGFloat = 3.0
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
var currentFontSize: Float = 14
|
||||
var lastPinchScale: CGFloat = 1.0
|
||||
#endif
|
||||
lazy var inputHandler = TerminalTextInputHandler(view: self)
|
||||
weak var _inputDelegate: (any UITextInputDelegate)?
|
||||
var onFocusChange: ((Bool) -> Void)?
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
lazy var terminalInputAccessory = TerminalInputAccessoryView(terminalView: self)
|
||||
let stickyModifiers = TerminalStickyModifierState()
|
||||
var softwareKeyboardVisible = false
|
||||
var pendingKeyboardDismissOnTouchEnd = false
|
||||
var touchDidScrollDuringCurrentTouch = false
|
||||
#endif
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
open var inputAccessoryStyle: TerminalInputAccessoryStyle {
|
||||
get { terminalInputAccessory.style }
|
||||
set { terminalInputAccessory.style = newValue }
|
||||
}
|
||||
|
||||
open var inputAccessoryItems: [TerminalInputAccessoryItem] = TerminalInputAccessoryItem.defaultItems {
|
||||
didSet {
|
||||
terminalInputAccessory.rebuildContent()
|
||||
reloadInputViews()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
open weak var delegate: (any TerminalSurfaceViewDelegate)? {
|
||||
get { core.delegate }
|
||||
set { core.delegate = newValue }
|
||||
}
|
||||
|
||||
open var controller: TerminalController? {
|
||||
get { core.controller }
|
||||
set { core.controller = newValue }
|
||||
}
|
||||
|
||||
open var configuration: TerminalSurfaceOptions {
|
||||
get { core.configuration }
|
||||
set { core.configuration = newValue }
|
||||
}
|
||||
|
||||
var surface: TerminalSurface? {
|
||||
core.surface
|
||||
}
|
||||
|
||||
open var hasText: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override open var canBecomeFirstResponder: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override public init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
public required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func commonInit() {
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
isUserInteractionEnabled = true
|
||||
updateDisplayScale()
|
||||
|
||||
core.isAttached = { [weak self] in self?.window != nil }
|
||||
core.scaleFactor = { [weak self] in
|
||||
Double(self?.resolvedDisplayScale() ?? UIScreen.main.nativeScale)
|
||||
}
|
||||
core.viewSize = { [weak self] in
|
||||
guard let self else { return (0, 0) }
|
||||
return (bounds.width, bounds.height)
|
||||
}
|
||||
core.platformSetup = { [weak self] config in
|
||||
guard let self else { return }
|
||||
config.platform_tag = GHOSTTY_PLATFORM_IOS
|
||||
config.platform = ghostty_platform_u(
|
||||
ios: ghostty_platform_ios_s(
|
||||
uiview: Unmanaged.passUnretained(self).toOpaque()
|
||||
)
|
||||
)
|
||||
}
|
||||
core.onMetricsUpdate = { [weak self] in
|
||||
self?.updateSublayerFrames()
|
||||
}
|
||||
core.onCellSizeDidChange = { [weak self] in
|
||||
self?.refreshTextInputGeometry(reason: "cell-size-action")
|
||||
}
|
||||
core.onPostRender = { [weak self] in
|
||||
self?.enforceSublayerScale()
|
||||
}
|
||||
|
||||
setupApplicationLifecycleObservers()
|
||||
syncApplicationActiveState()
|
||||
setupPlatformInput()
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
setupKeyboardObservers()
|
||||
#endif
|
||||
}
|
||||
|
||||
open func selectionMenuPoint(at point: CGPoint) -> CGPoint? {
|
||||
logPointerSelectionDiagnostics(
|
||||
context: "selectionMenuPoint",
|
||||
point: point
|
||||
)
|
||||
if let rect = lastPointerSelectionRect {
|
||||
let pointIsInsidePointerSelection = rect.insetBy(dx: -4, dy: -4).contains(point)
|
||||
guard pointIsInsidePointerSelection else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(NSCoder.string(for: point)) outside pointer selection"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
guard surface?.hasSelection() == true else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(NSCoder.string(for: point)) inside pointer selection without active selection"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu hit point=\(NSCoder.string(for: point)) inside pointer selection"
|
||||
)
|
||||
return point
|
||||
}
|
||||
|
||||
guard surface?.hasSelection() == true else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(NSCoder.string(for: point))"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard surface?.selectionContainsQuicklookWord() == true else {
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu miss point=\(NSCoder.string(for: point)) outside quicklook word"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection menu hit point=\(NSCoder.string(for: point))"
|
||||
)
|
||||
return point
|
||||
}
|
||||
|
||||
open func showSelectionCopyMenu(at point: CGPoint) {
|
||||
becomeFirstResponder()
|
||||
let menu = UIMenuController.shared
|
||||
menu.menuItems = nil
|
||||
menu.showMenu(
|
||||
from: self,
|
||||
rect: CGRect(x: point.x, y: point.y, width: 1, height: 1)
|
||||
)
|
||||
menu.update()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
open func copySelectedTextToPasteboard() -> Bool {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-testing") {
|
||||
accessibilityValue = nil
|
||||
}
|
||||
#endif
|
||||
guard let text = surface?.readSelection(), !text.isEmpty else {
|
||||
return false
|
||||
}
|
||||
UIPasteboard.general.string = text
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--ui-testing") {
|
||||
accessibilityValue = text
|
||||
}
|
||||
#endif
|
||||
TerminalDebugLog.log(
|
||||
.input,
|
||||
"selection copied bytes=\(text.utf8.count) lines=\(TerminalInputText.lineCount(in: text))"
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
open func selectionContextMenuConfiguration(
|
||||
at _: CGPoint
|
||||
) -> UIContextMenuConfiguration {
|
||||
UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
|
||||
UIMenu(children: self?.selectionContextMenuElements() ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
open func selectionContextMenuElements() -> [UIMenuElement] {
|
||||
let copy = UIAction(
|
||||
title: "Copy",
|
||||
image: UIImage(systemName: "doc.on.doc")
|
||||
) { [weak self] _ in
|
||||
self?.copySelectedTextToPasteboard()
|
||||
}
|
||||
return [copy]
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
func setupKeyboardObservers() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardDidShow),
|
||||
name: UIResponder.keyboardDidShowNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardDidHide),
|
||||
name: UIResponder.keyboardDidHideNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc func keyboardDidShow(_: Notification) {
|
||||
guard isFirstResponder else { return }
|
||||
softwareKeyboardVisible = true
|
||||
}
|
||||
|
||||
@objc func keyboardDidHide(_: Notification) {
|
||||
softwareKeyboardVisible = false
|
||||
}
|
||||
#endif
|
||||
|
||||
func refreshTextInputGeometry(reason: String) {
|
||||
guard isFirstResponder || inputHandler.hasMarkedText else { return }
|
||||
TerminalDebugLog.log(.ime, "refresh text geometry reason=\(reason)")
|
||||
inputHandler.notifyGeometryDidChange(reason: reason)
|
||||
}
|
||||
|
||||
func refreshInputAccessoryContent() {
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
terminalInputAccessory.refreshContent()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user