diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 0ae146bb1241a..ca20045e0135b 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -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)? @@ -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) @@ -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) @@ -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 } diff --git a/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift b/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift new file mode 100644 index 0000000000000..9f3fe8422ad1d --- /dev/null +++ b/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift @@ -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) + } +}