渲染/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>
302 lines
11 KiB
Swift
302 lines
11 KiB
Swift
//
|
||
// 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
|