Add mobile terminal text selection
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user