Files
terminalX/vendor/libghostty-spm/Sources/GhosttyTerminal/Platform/UIKit/UITerminalView.swift
kid 6dd23275ff feat: 修复 3 个渲染/tmux 显示 bug + UI 产品化第一波(对标 Moshi)
渲染/tmux 修复(e2e 复现+回归,模拟器 idb 点击+截图取证):
- 终端底部残影:ghostty iOS 释放 surface 不摘 IOSurfaceLayer(上游缺陷),
  platformSetup 清现存子层 + TerminalSurfaceCoordinator.onSurfaceFreed 钩子摘孤儿层
- tmux 切 tab 切回旧 tab 内容消失:改所有窗口常驻挂载(ZStack+opacity+hitTesting),
  surface 不释放、内容保留、后台窗口持续收 %output
- tmux pane 内容钉底/顶部残行:pane grid 由容器几何定(tab 条吃高度),75 行快照喂 73 行
  溢出上滚;几何测量上移 TmuxTabbedView + attach kickoff 双条件(attachAcked∧containerKnown)
  + capture-pane 铺快照前剔尾空行

UI 产品化(对标 Moshi getmoshi.app:深蓝黑 + 终端绿 + Catppuccin):
- 设计系统 Theme.swift:TXPalette(色角色 struct)+TXThemeManager(环境注入),颜色封装供主题系统
- 统一主题:抽离 4 套 Catppuccin(Mocha/Macchiato/Frappé/Latte),一套同时驱动 app+终端配色,
  设置里选主题 app 与终端一起变色 + UserDefaults 持久化
- 主屏/主机管理页 HomeView:HostCard 主机卡 + FAB 加主机(AddHostSheet) + 设置(主题选择器),
  HostStore 本地 JSON 持久化(密码暂存/TODO Keychain),点卡片 connect(to:)
- 终端外壳 chrome TerminalTopBar:圆形返回钮 + 会话标题 + SSH/mosh 传输徽章 + 状态提示,
  tmux tab 绿 pill 化,底部工具栏深色自适应,终端默认 Catppuccin Mocha

iPad mini(A17 Pro) live 端到端验证:加主机→连接→终端→切 tab→分屏点焦点→主题切换同步。
详见 docs/HANDOFF.md §10。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:43:00 +08:00

302 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 }
// ghostty `ghostty_surface_new` IOSurfaceLayer
// view.layer surface free detach/reattach
// rebuild
// surface free
// ghostty view ghostty
if let stale = self.layer.sublayers, !stale.isEmpty {
TerminalDebugLog.log(.lifecycle, "removing stale sublayers count=\(stale.count)")
for sub in stale { sub.removeFromSuperlayer() }
}
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()
}
core.onSurfaceFreed = { [weak self] in
// ghostty(iOS) surface teardown
// createSurface teardown/ view ghostty
guard let sublayers = self?.layer.sublayers, !sublayers.isEmpty else { return }
TerminalDebugLog.log(.lifecycle, "onSurfaceFreed removing sublayers count=\(sublayers.count)")
for sub in sublayers { sub.removeFromSuperlayer() }
}
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