Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public class T3TerminalModule: Module {
// Bumped when native hardware-keyboard handling changes; surfaced in the JS debug
// logs so a stale native binary is distinguishable from a broken key pipeline.
Constants([
"hardwareKeyRevision": 3,
"hardwareKeyRevision": 4,
"outputRevision": 1,
])

Expand Down Expand Up @@ -71,7 +71,7 @@ public class T3TerminalModule: Module {
Prop("sharedGrid") { (view: T3TerminalView, sharedGrid: [Int]) in
view.sharedGrid = sharedGrid
}
Events("onInput", "onResize", "onCapture")
Events("onInput", "onResize", "onCapture", "onPaste")

OnViewDidUpdateProps { (view: T3TerminalView) in
view.applyPendingBuffer()
Expand Down
177 changes: 153 additions & 24 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ private enum TerminalHardwareKeyEncoder {
}
}

/// Ghostty callbacks can fire during deinit and hop to main after it, so they must not retain the view.
private final class TerminalViewRef {
weak var view: T3TerminalView?
}

private enum TerminalInputSequence {
/// Terminal Enter is carriage return. Sending line feed instead is Ctrl+J,
/// which raw-mode TUIs may interpret as the literal J key.
Expand All @@ -128,6 +133,7 @@ private enum TerminalInputSequence {
private final class TerminalInputField: UITextField {
var onDeleteBackward: (() -> Void)?
var onInsert: ((String) -> Void)?
var onPaste: (() -> Void)?

private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands(
action: #selector(handleHardwareKeyCommand(_:))
Expand All @@ -146,6 +152,16 @@ private final class TerminalInputField: UITextField {
super.deleteBackward()
}

// System pastes must bypass typed input to preserve paste encoding and chunking.
override func paste(_ sender: Any?) {
// Commit an in-progress composition first so it reaches the PTY ahead of the paste.
if markedTextRange != nil {
unmarkText()
sendActions(for: .editingChanged)
}
onPaste?()
}

@objc
private func handleHardwareKeyCommand(_ command: UIKeyCommand) {
guard let input = command.input else { return }
Expand Down Expand Up @@ -188,22 +204,26 @@ private extension UIColor {
}
}

public final class T3TerminalView: ExpoView, UITextFieldDelegate {
public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInteractionDelegate {
private static let minimumVerticalScrollStepPoints: CGFloat = 18
private static let verticalScrollStepMultiplier: CGFloat = 1.15

private let terminalViewport = UIView()
private let inputField = TerminalInputField()
private let focusTapGesture = UITapGestureRecognizer()
private let scrollPanGesture = UIPanGestureRecognizer()
private let editMenuGesture = UILongPressGestureRecognizer()
private lazy var editMenuInteraction = UIEditMenuInteraction(delegate: self)
private var lastViewportSize: CGSize = .zero
private var lastContentScale: CGFloat = 0
private var lastReportedGrid: (cols: Int, rows: Int)?
private var lastAppliedBuffer = ""
private var streamedOutput: TerminalOutputBuffer?
private var pendingVerticalScrollPoints: CGFloat = 0
private var menuText: String?
private var sentInput = ""
private var app: ghostty_app_t?
private let callbackRef = TerminalViewRef()
private var surface: ghostty_surface_t?
private var isCreatingSurface = false
private var surfaceCreationFailed = false
Expand All @@ -213,18 +233,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
let onInput = EventDispatcher()
let onResize = EventDispatcher()
let onCapture = EventDispatcher()
let onPaste = EventDispatcher()
var captureRequest: Double = 0 {
didSet {
guard captureRequest > 0, captureRequest != oldValue else { return }
guard let surface else { onCapture(["text": ""]); return }
let selection = ghostty_selection_s(
top_left: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0),
bottom_right: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0),
rectangle: false)
var captured = ghostty_text_s()
guard ghostty_surface_read_text(surface, selection, &captured) else { onCapture(["text": ""]); return }
defer { ghostty_surface_free_text(surface, &captured) }
let text = captured.text.flatMap { String(bytes: UnsafeBufferPointer(start: UnsafeRawPointer($0).assumingMemoryBound(to: UInt8.self), count: Int(captured.text_len)), encoding: .utf8) } ?? ""
let text = readViewportText() ?? ""
// Android joins snapshot rows with "\n" and trims each row's trailing whitespace, so do
// the same here: identical terminal content must capture identically on both platforms.
let normalized = text.split(separator: "\n", omittingEmptySubsequences: false)
Expand Down Expand Up @@ -272,7 +285,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
didSet {
guard oldValue != fontSize else { return }
inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular)
refreshSurface()
resetSurface()
}
}

Expand Down Expand Up @@ -308,14 +321,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
didSet {
guard oldValue != appearanceScheme else { return }
appearance = TerminalAppearanceScheme(value: appearanceScheme)
refreshSurface()
resetSurface()
}
}

var themeConfig: String = "" {
didSet {
guard oldValue != themeConfig else { return }
refreshSurface()
resetSurface()
}
}

Expand All @@ -331,6 +344,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {

public required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
callbackRef.view = self

applyTheme()
clipsToBounds = true
Expand Down Expand Up @@ -370,13 +384,20 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
self?.emitInput(data)
self?.resetInputField()
}
inputField.onPaste = { [weak self] in
self?.emitPaste()
}

focusTapGesture.addTarget(self, action: #selector(handleViewportTap))
terminalViewport.addGestureRecognizer(focusTapGesture)
scrollPanGesture.addTarget(self, action: #selector(handleViewportPan(_:)))
scrollPanGesture.maximumNumberOfTouches = 1
scrollPanGesture.cancelsTouchesInView = false
terminalViewport.addGestureRecognizer(scrollPanGesture)
editMenuGesture.addTarget(self, action: #selector(handleViewportLongPress(_:)))
terminalViewport.addGestureRecognizer(editMenuGesture)
terminalViewport.addInteraction(editMenuInteraction)
focusTapGesture.require(toFail: editMenuGesture)

addSubview(terminalViewport)
addSubview(inputField)
Expand Down Expand Up @@ -438,6 +459,23 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
requestKeyboardFocus()
}

private func readViewportText() -> String? {
guard let surface else { return nil }
let selection = ghostty_selection_s(
top_left: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0),
bottom_right: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0),
rectangle: false)
var captured = ghostty_text_s()
guard ghostty_surface_read_text(surface, selection, &captured) else { return nil }
defer { ghostty_surface_free_text(surface, &captured) }
guard let text = captured.text else { return nil }
return String(
bytes: UnsafeBufferPointer(
start: UnsafeRawPointer(text).assumingMemoryBound(to: UInt8.self),
count: Int(captured.text_len)),
encoding: .utf8)
}

@objc
private func handleViewportPan(_ gesture: UIPanGestureRecognizer) {
guard let surface else { return }
Expand Down Expand Up @@ -504,6 +542,83 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
}
}

@objc
private func handleViewportLongPress(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began else { return }
// Freeze the text the user pressed on; output keeps streaming while the menu is open.
menuText = readViewportText().flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 }
guard menuText != nil || canPaste else { return }
editMenuInteraction.presentEditMenu(
with: UIEditMenuConfiguration(identifier: nil, sourcePoint: gesture.location(in: terminalViewport))
)
}

public func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
menuFor configuration: UIEditMenuConfiguration,
suggestedActions: [UIMenuElement]
) -> UIMenu? {
// The hidden input field would otherwise add actions such as Scan Text or Writing Tools.
// Keep the system Paste command: unlike a custom action, it reads without a paste prompt.
var children: [UIMenuElement] = Self.pasteCommand(in: suggestedActions).map { [$0] } ?? []
if let text = menuText {
children.append(
UIAction(title: "Select Text", image: UIImage(systemName: "text.cursor")) { [weak self] _ in
self?.presentTextSelection(text)
})
}
return children.isEmpty ? nil : UIMenu(children: children)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

private func presentTextSelection(_ text: String) {
guard let window else { return }
var responder: UIResponder? = self
while let current = responder, !(current is UIViewController) {
responder = current.next
}
guard let presenter = responder as? UIViewController,
presenter.viewIfLoaded?.window === window,
presenter.presentedViewController == nil,
!presenter.isBeingPresented,
!presenter.isBeingDismissed,
presenter.transitionCoordinator == nil
else { return }

inputField.resignFirstResponder()
let controller = TerminalTextSelectionViewController(text: text, fontSize: fontSize)
let navigation = UINavigationController(rootViewController: controller)
navigation.modalPresentationStyle = .pageSheet
presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled)
}

private static func pasteCommand(in elements: [UIMenuElement]) -> UIMenuElement? {
for element in elements {
if let command = element as? UICommand,
command.action == #selector(UIResponderStandardEditActions.paste(_:)) {
return command
}
if let menu = element as? UIMenu, let command = pasteCommand(in: menu.children) {
return command
}
}
return nil
}

public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(UIResponderStandardEditActions.paste(_:)) {
return canPaste
}
return super.canPerformAction(action, withSender: sender)
}

public override func paste(_ sender: Any?) {
emitPaste()
}

private var canPaste: Bool {
!readOnly && UIPasteboard.general.hasStrings
}

@objc
private func handleInputEditingDidBegin() {
// A composition abandoned by losing focus must not reach the PTY with the next keystroke.
Expand All @@ -528,9 +643,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
defer { isCreatingSurface = false }

var runtimeConfig = ghostty_runtime_config_s(
userdata: Unmanaged.passUnretained(self).toOpaque(),
userdata: Unmanaged.passUnretained(callbackRef).toOpaque(),
supports_selection_clipboard: false,
wakeup_cb: { _ in },
// The io thread queues app messages (titles, bells) in a bounded mailbox. Unless the main
// thread drains it, the io thread blocks and the next feed or surface free hangs the app.
wakeup_cb: { userdata in
guard let userdata else { return }
let ref = Unmanaged<TerminalViewRef>.fromOpaque(userdata).takeUnretainedValue()
DispatchQueue.main.async { ref.view?.tickApp() }
},
action_cb: { _, _, _ in false },
read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED },
confirm_read_clipboard_cb: { _, _, _, _ in },
Expand Down Expand Up @@ -589,12 +710,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
setNeedsLayout()
}

private func refreshSurface() {
resetSurface()
createSurfaceIfPossible()
}

private func destroySurface() {
// Unblock the io thread before ghostty_surface_free joins it.
tickApp()
if let surface {
ghostty_surface_set_write_callback(surface, nil, nil)
ghostty_surface_free(surface)
Expand All @@ -619,6 +737,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
applyRemoteBuffer(initialBuffer)
}

private func tickApp() {
guard let app else { return }
ghostty_app_tick(app)
}

private func applyRemoteBuffer(_ buffer: String) {
guard streamedOutput == nil else { return }
guard surface != nil else {
Expand Down Expand Up @@ -649,7 +772,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
output.write(bytes, reset: reset)
streamedOutput = output
if reset {
refreshSurface()
resetSurface()
} else if surface == nil {
createSurfaceIfPossible()
} else {
Expand All @@ -666,6 +789,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
private func feedData(_ data: Data) {
guard let surface, !data.isEmpty else { return }

tickApp()
data.withUnsafeBytes { buffer in
guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
return
Expand All @@ -679,15 +803,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
private func setupWriteCallback() {
guard let surface else { return }

let userdata = Unmanaged.passUnretained(self).toOpaque()
let userdata = Unmanaged.passUnretained(callbackRef).toOpaque()
ghostty_surface_set_write_callback(surface, { userdata, data, len in
guard let userdata, let data, len > 0 else { return }
let view = Unmanaged<T3TerminalView>.fromOpaque(userdata).takeUnretainedValue()
let ref = Unmanaged<TerminalViewRef>.fromOpaque(userdata).takeUnretainedValue()
let bytes = Data(bytes: data, count: len)
guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return }

DispatchQueue.main.async {
view.emitInput(input)
ref.view?.emitInput(input)
}
}, userdata)
}
Expand Down Expand Up @@ -776,6 +900,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
onInput(["data": data])
}

private func emitPaste() {
guard let text = UIPasteboard.general.string, !text.isEmpty else { return }
onPaste(["text": text])
}

private func textInputModeDidChange() {
guard let app else { return }
ghostty_app_keyboard_changed(app)
Expand Down
Loading
Loading