From 2d5aec9dbca6f187ecb7dfc9d3856e03d60f53b3 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 12 Sep 2026 06:38:58 +0800 Subject: [PATCH] Add mobile terminal text selection --- Frameworks/Resources/Localizable.xcstrings | 10 ++ .../MobileTerminalTextSelectionView.swift | 124 ++++++++++++++++++ TFI/Features/Workspaces/MobileWorkspace.swift | 8 ++ .../Workspaces/MobileWorkspaceView.swift | 10 ++ .../MobileTerminalTextSelectionTests.swift | 43 ++++++ 5 files changed, 195 insertions(+) create mode 100644 TFI/Features/Terminal/MobileTerminalTextSelectionView.swift create mode 100644 TFITests/MobileTerminalTextSelectionTests.swift diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 0ea466a0..10436886 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -12340,6 +12340,16 @@ } } }, + "Select Text" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择文本" + } + } + } + }, "Select at least two hosts. A Workspace can contain one or more hosts." : { "localizations" : { "zh-Hans" : { diff --git a/TFI/Features/Terminal/MobileTerminalTextSelectionView.swift b/TFI/Features/Terminal/MobileTerminalTextSelectionView.swift new file mode 100644 index 00000000..5aab0e4c --- /dev/null +++ b/TFI/Features/Terminal/MobileTerminalTextSelectionView.swift @@ -0,0 +1,124 @@ +import GhosttyTerminal +import SwiftUI +import UIKit + +struct MobileTerminalTextSelection: Identifiable { + let id = UUID() + let text: String + let anchorRange: NSRange? + + init(text: String, anchorRange: NSRange?) { + self.text = text + self.anchorRange = anchorRange + } + + init(_ request: TerminalTextSelectionRequest) { + self.init(text: request.text, anchorRange: request.anchorRange) + } +} + +struct MobileTerminalTextSelectionView: UIViewControllerRepresentable { + let selection: MobileTerminalTextSelection + @Environment(\.dismiss) private var dismiss + + func makeCoordinator() -> Coordinator { + Coordinator(dismiss: dismiss) + } + + func makeUIViewController(context: Context) -> UINavigationController { + let controller = MobileTerminalTextSelectionViewController(selection: selection) + controller.onDone = context.coordinator.dismiss + return UINavigationController(rootViewController: controller) + } + + func updateUIViewController(_: UINavigationController, context _: Context) {} + + final class Coordinator { + let dismiss: () -> Void + + init(dismiss: DismissAction) { + self.dismiss = { dismiss() } + } + } +} + +@MainActor +final class MobileTerminalTextSelectionViewController: UIViewController { + let selection: MobileTerminalTextSelection + var onDone: (() -> Void)? + + private(set) lazy var textView: UITextView = { + let view = UITextView() + view.isEditable = false + view.isSelectable = true + view.alwaysBounceVertical = true + view.font = .monospacedSystemFont(ofSize: 14, weight: .regular) + view.backgroundColor = .clear + view.textColor = .label + view.textContainerInset = .init(top: 12, left: 12, bottom: 12, right: 12) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + init(selection: MobileTerminalTextSelection) { + self.selection = selection + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = String(localized: "Select Text") + view.backgroundColor = .clear + textView.text = selection.text + view.addSubview(textView) + NSLayoutConstraint.activate([ + textView.topAnchor.constraint(equalTo: view.topAnchor), + textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + textView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(done) + ) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.view.backgroundColor = .clear + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + navigationController?.navigationBar.standardAppearance = appearance + navigationController?.navigationBar.scrollEdgeAppearance = appearance + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + textView.becomeFirstResponder() + let range = Self.resolvedSelectionRange(text: selection.text, anchorRange: selection.anchorRange) + textView.selectedRange = range + textView.scrollRangeToVisible(range) + } + + static func resolvedSelectionRange(text: String, anchorRange: NSRange?) -> NSRange { + let length = (text as NSString).length + guard let anchorRange, + anchorRange.location != NSNotFound, + anchorRange.location <= length, + anchorRange.length <= length - anchorRange.location + else { + return NSRange(location: 0, length: length) + } + return anchorRange + } + + @objc private func done() { + onDone?() + } +} diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index b0ddcedb..2e182873 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -73,6 +73,7 @@ final class MobileWorkspace: Identifiable { private var uploadNoticeTask: Task? let terminalSettings: TerminalSettingsModel let commandHistory: CommandHistoryModel + var terminalTextSelection: MobileTerminalTextSelection? @ObservationIgnored lazy var find = TerminalFindController( perform: { [weak self] in _ = self?.terminal.performBindingAction($0) }, focus: { [weak self] in self?.terminal.requestFocus() } @@ -175,6 +176,9 @@ final class MobileWorkspace: Identifiable { terminal.makePlatformView = { owner.view } terminal.inputAccessoryItems = [] relay.owner = self + terminal.onTextSelectionRequest = { [weak relay] request in + relay?.owner?.terminalTextSelection = MobileTerminalTextSelection(request) + } workingDirectoryObservation = terminal.$workingDirectory.sink { [weak relay] path in Task { @MainActor in relay?.owner?.receiveWorkingDirectory(path) @@ -182,6 +186,10 @@ final class MobileWorkspace: Identifiable { } } + func dismissTerminalTextSelection() { + terminalTextSelection = nil + } + private func makeAuthenticationPrompt() -> SSHAuthenticationPrompt? { guard authenticationRequired, let host = hosts.host(authenticationHostID ?? descriptor.hostID) else { return nil diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index cdf36cc3..92836c85 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -165,6 +165,16 @@ struct MobileWorkspaceView: View { } } .sheet(isPresented: $showingHistory) { MobileCommandHistoryView(workspace: workspace) } + .sheet( + item: Binding( + get: { workspace.terminalTextSelection }, + set: { if $0 == nil { workspace.dismissTerminalTextSelection() } } + ), + onDismiss: { workspace.terminal.requestFocus() } + ) { selection in + MobileTerminalTextSelectionView(selection: selection) + .mobileSheetPresentation(compact: true) + } .sheet(item: $renaming) { MobileRemoteFileEditor(workspace: workspace, file: $0, mode: .rename) } .sheet(item: $editingPermissions) { MobileRemoteFileEditor(workspace: workspace, file: $0, mode: .permissions) } .sheet(isPresented: $showingTransfers) { diff --git a/TFITests/MobileTerminalTextSelectionTests.swift b/TFITests/MobileTerminalTextSelectionTests.swift new file mode 100644 index 00000000..9d1dc450 --- /dev/null +++ b/TFITests/MobileTerminalTextSelectionTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +import UIKit + +@testable import TermindIOS + +@Suite @MainActor +struct MobileTerminalTextSelectionTests { + @Test func validAnchorRangeSelectsTheRequestedUTF16Text() { + let text = "prompt 你好 world" + let range = (text as NSString).range(of: "你好") + let controller = MobileTerminalTextSelectionViewController( + selection: MobileTerminalTextSelection(text: text, anchorRange: range) + ) + + controller.loadViewIfNeeded() + controller.viewDidAppear(false) + + #expect(controller.textView.isSelectable) + #expect(!controller.textView.isEditable) + #expect(controller.textView.selectedRange == range) + } + + @Test func missingAnchorRangeSelectsAllViewportText() { + let text = "first line\nsecond line" + let range = MobileTerminalTextSelectionViewController.resolvedSelectionRange( + text: text, + anchorRange: nil + ) + + #expect(range == NSRange(location: 0, length: (text as NSString).length)) + } + + @Test func invalidAnchorRangeSelectsAllViewportText() { + let text = "short" + let range = MobileTerminalTextSelectionViewController.resolvedSelectionRange( + text: text, + anchorRange: NSRange(location: 4, length: 20) + ) + + #expect(range == NSRange(location: 0, length: (text as NSString).length)) + } +}