Skip to content
57 changes: 48 additions & 9 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
private let inputField = TerminalInputField()
private let focusTapGesture = UITapGestureRecognizer()
private let scrollPanGesture = UIPanGestureRecognizer()
private let selectTextGesture = UILongPressGestureRecognizer()
private var lastViewportSize: CGSize = .zero
private var lastContentScale: CGFloat = 0
private var lastReportedGrid: (cols: Int, rows: Int)?
Expand All @@ -211,15 +212,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
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 @@ -369,6 +362,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
scrollPanGesture.maximumNumberOfTouches = 1
scrollPanGesture.cancelsTouchesInView = false
terminalViewport.addGestureRecognizer(scrollPanGesture)
selectTextGesture.addTarget(self, action: #selector(handleSelectText(_:)))
terminalViewport.addGestureRecognizer(selectTextGesture)
focusTapGesture.require(toFail: selectTextGesture)

addSubview(terminalViewport)
addSubview(inputField)
Expand Down Expand Up @@ -440,6 +436,49 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate {
requestKeyboardFocus()
}

@objc
private func handleSelectText(_ gesture: UILongPressGestureRecognizer) {
guard gesture.state == .began, 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,
let text = readViewportText(),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return }

// Capture before dismissing the keyboard: doing so can resize the remote PTY.
inputField.resignFirstResponder()
let controller = TerminalTextSelectionViewController(text: text, fontSize: fontSize)
let navigation = UINavigationController(rootViewController: controller)
navigation.modalPresentationStyle = .pageSheet
presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled)
}

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import UIKit

final class TerminalTextSelectionViewController: UIViewController {
private let text: String
private let fontSize: CGFloat

init(text: String, fontSize: CGFloat) {
self.text = text
self.fontSize = fontSize
super.init(nibName: nil, bundle: nil)
}

required init?(coder: NSCoder) {
return nil
}

override func viewDidLoad() {
super.viewDidLoad()
title = "Terminal text"
view.backgroundColor = .systemBackground

navigationItem.leftBarButtonItem = UIBarButtonItem(
title: "Copy All", style: .plain, target: self, action: #selector(copyAll))
navigationItem.rightBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .done, target: self, action: #selector(close))

let textView = UITextView()
textView.text = text
textView.font = .monospacedSystemFont(ofSize: max(fontSize, 14), weight: .regular)
textView.textColor = .label
textView.backgroundColor = .systemBackground
textView.isEditable = false
textView.isSelectable = true
textView.accessibilityLabel = "Terminal output"
textView.textContainerInset = UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12)
textView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(textView)

NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
])
}

@objc
private func copyAll() {
UIPasteboard.general.string = text
close()
}

@objc
private func close() {
dismiss(animated: !UIAccessibility.isReduceMotionEnabled)
}
}
Loading