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