Add mobile terminal text selection

This commit is contained in:
Kaede Akino committed 2026-09-12 10:36:22 +08:00
1 parent adec407a13
commit 2d5aec9dbc
5 files changed
+195

No files matched your search

@@ -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" : {
@@ -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?()
}
}
@@ -73,6 +73,7 @@ final class MobileWorkspace: Identifiable {
private var uploadNoticeTask: Task<Void, Never>?
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
@@ -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) {
@@ -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))
}
}