Normalize newlines in single-line fields
This commit is contained in:
1 parent
9793471302
commit
581300362a
57 files changed
+802
-168
No files matched your search
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@Suite("Single-line text editing")
|
||||
struct SingleLineTextInputTests {
|
||||
@Test func attributedPasteKeepsSurroundingAttributesAndUnicodeOffsets() {
|
||||
let key = NSAttributedString.Key("testStyle")
|
||||
let original = NSMutableAttributedString(string: "👩🏽💻中文\r\n\nName", attributes: [key: "first"])
|
||||
original.addAttribute(key, value: "second", range: NSRange(location: original.length - 4, length: 4))
|
||||
let normalized = SingleLineTextInput.normalized(original)
|
||||
#expect(normalized.string == "👩🏽💻中文 Name")
|
||||
#expect(normalized.attribute(key, at: 0, effectiveRange: nil) as? String == "first")
|
||||
#expect(normalized.attribute(key, at: normalized.length - 1, effectiveRange: nil) as? String == "second")
|
||||
#expect(original.string == "👩🏽💻中文\r\n\nName")
|
||||
}
|
||||
|
||||
@Test(arguments: ["\n", "\r", "\r\n", "\n\r\n", "\u{000B}", "\u{000C}", "\u{0085}", "\u{2028}", "\u{2029}"])
|
||||
func consecutiveLineBreaksBecomeOneSpace(_ lineBreak: String) {
|
||||
#expect(SingleLineTextInput.normalized("first\(lineBreak)second") == "first second")
|
||||
#expect(SingleLineTextInput.normalized("\(lineBreak)first\(lineBreak)") == " first ")
|
||||
}
|
||||
|
||||
@Test(arguments: ["", "example.invalid", "中文主机", " two spaces \t remain ", "👩🏽💻服务器"])
|
||||
func singleLineContentIsUnchanged(_ text: String) {
|
||||
#expect(SingleLineTextInput.normalized(text) == text)
|
||||
}
|
||||
|
||||
@Test func separatedLineBreakRunsKeepExistingSpacesAndTabs() {
|
||||
#expect(SingleLineTextInput.normalized("a\r\n\n b\t\nc") == "a b\t c")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func bindingReadsLegacyValuesUnchangedAndNormalizesOnlyWrites() {
|
||||
var stored = "legacy\nvalue"
|
||||
var receivedTransaction: Transaction?
|
||||
let source = Binding<String>(
|
||||
get: { stored },
|
||||
set: { value, transaction in
|
||||
stored = value
|
||||
receivedTransaction = transaction
|
||||
}
|
||||
)
|
||||
let field = source.singleLineText
|
||||
#expect(field.wrappedValue == "legacy\nvalue")
|
||||
#expect(stored == "legacy\nvalue")
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
field.transaction(transaction).wrappedValue = "新的\r\n\n名称"
|
||||
#expect(stored == "新的 名称")
|
||||
#expect(receivedTransaction?.disablesAnimations == true)
|
||||
source.wrappedValue = "script\nbody"
|
||||
#expect(stored == "script\nbody")
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,8 @@ public struct SSHAuthenticationForm: View {
|
||||
if identityID == nil {
|
||||
GridRow {
|
||||
Text("Username").foregroundStyle(.secondary)
|
||||
TextField("Username", text: usernameInput)
|
||||
TextField("Username", text: usernameInput.singleLineText, axis: .horizontal)
|
||||
.singleLinePaste().lineLimit(1)
|
||||
.focused($focusedField, equals: .username)
|
||||
}
|
||||
GridRow {
|
||||
|
||||
@@ -12,11 +12,15 @@ public struct HostConnectionFields: View {
|
||||
public var body: some View {
|
||||
Section("Connection") {
|
||||
LabeledContent("Name") {
|
||||
TextField("Name", text: $host.name, prompt: Text(host.host)).labelsHidden().multilineTextAlignment(
|
||||
.trailing)
|
||||
TextField("Name", text: $host.name.singleLineText, prompt: Text(host.host), axis: .horizontal)
|
||||
.singleLinePaste()
|
||||
.lineLimit(1).labelsHidden().multilineTextAlignment(
|
||||
.trailing)
|
||||
}
|
||||
LabeledContent("Host") {
|
||||
TextField("Host", text: $host.host, prompt: Text("IP address or hostname"))
|
||||
TextField(
|
||||
"Host", text: $host.host.singleLineText, prompt: Text("IP address or hostname"), axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
.labelsHidden().multilineTextAlignment(.trailing)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -25,7 +29,7 @@ public struct HostConnectionFields: View {
|
||||
#endif
|
||||
}
|
||||
LabeledContent("Port") {
|
||||
TextField("Port", value: $host.port, format: .number.grouping(.never))
|
||||
TextField("Port", value: $host.port, format: .number.grouping(.never)).lineLimit(1)
|
||||
.labelsHidden().multilineTextAlignment(.trailing)
|
||||
#if os(iOS)
|
||||
.keyboardType(.numberPad)
|
||||
@@ -34,7 +38,10 @@ public struct HostConnectionFields: View {
|
||||
}
|
||||
Section("SFTP") {
|
||||
LabeledContent("Initial Path") {
|
||||
TextField("Initial Path", text: $host.sftpSessionInitialPath, prompt: Text("~"))
|
||||
TextField(
|
||||
"Initial Path", text: $host.sftpSessionInitialPath.singleLineText, prompt: Text("~"),
|
||||
axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
.labelsHidden().multilineTextAlignment(.trailing)
|
||||
.font(.body.monospaced())
|
||||
#if os(iOS)
|
||||
|
||||
@@ -16,7 +16,9 @@ public struct ModelProviderConnectionFields: View {
|
||||
public var body: some View {
|
||||
Section {
|
||||
LabeledContent("Name") {
|
||||
TextField("Name", text: $name).labelsHidden().multilineTextAlignment(.trailing)
|
||||
TextField("Name", text: $name.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.labelsHidden()
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
Toggle("Enabled", isOn: $isEnabled)
|
||||
Picker("API Type", selection: $endpoint.apiType) {
|
||||
@@ -25,7 +27,9 @@ public struct ModelProviderConnectionFields: View {
|
||||
}
|
||||
Section("Endpoint") {
|
||||
LabeledContent("URL") {
|
||||
TextField("URL", text: $endpoint.url).labelsHidden().autocorrectionDisabled()
|
||||
TextField("URL", text: $endpoint.url.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.labelsHidden()
|
||||
.autocorrectionDisabled()
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
if endpoint.isConfigured {
|
||||
|
||||
@@ -7,14 +7,20 @@ public struct PortForwardingFields: View {
|
||||
|
||||
public var body: some View {
|
||||
Section("Port Forwarding") {
|
||||
TextField("Name", text: $rule.label).textFieldStyle(.automatic).multilineTextAlignment(.leading)
|
||||
TextField("Name", text: $rule.label.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(
|
||||
.automatic
|
||||
).multilineTextAlignment(.leading)
|
||||
PortForwardingDirectionPicker(kind: $rule.kind)
|
||||
Text(directionDescription).font(.caption).foregroundStyle(.secondary)
|
||||
LabeledContent("Bind Address") {
|
||||
TextField("Bind Address", text: $rule.bindHost).labelsHidden().multilineTextAlignment(.trailing)
|
||||
TextField("Bind Address", text: $rule.bindHost.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.labelsHidden().multilineTextAlignment(.trailing)
|
||||
}
|
||||
LabeledContent("Bind Port") {
|
||||
TextField("Bind Port", value: $rule.bindPort, format: .number.grouping(.never)).labelsHidden()
|
||||
TextField("Bind Port", value: $rule.bindPort, format: .number.grouping(.never)).lineLimit(1)
|
||||
.labelsHidden()
|
||||
.multilineTextAlignment(.trailing)
|
||||
#if os(iOS)
|
||||
.keyboardType(.numberPad)
|
||||
@@ -25,13 +31,14 @@ public struct PortForwardingFields: View {
|
||||
TextField(
|
||||
"Target Host",
|
||||
text: Binding(get: { rule.targetHost ?? "localhost" }, set: { rule.targetHost = $0 })
|
||||
).labelsHidden().multilineTextAlignment(.trailing)
|
||||
.singleLineText, axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1).labelsHidden().multilineTextAlignment(.trailing)
|
||||
}
|
||||
LabeledContent("Target Port") {
|
||||
TextField(
|
||||
"Target Port", value: Binding(get: { rule.targetPort ?? 80 }, set: { rule.targetPort = $0 }),
|
||||
format: .number.grouping(.never)
|
||||
).labelsHidden().multilineTextAlignment(.trailing)
|
||||
).lineLimit(1).labelsHidden().multilineTextAlignment(.trailing)
|
||||
#if os(iOS)
|
||||
.keyboardType(.numberPad)
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Editing policy for single-line UI fields, not a transformation of stored data or secrets.
|
||||
public nonisolated enum SingleLineTextInput {
|
||||
/// Preserve pasted attributes while replacing only newline runs.
|
||||
public static func normalized(_ text: NSAttributedString) -> NSAttributedString {
|
||||
let string = text.string
|
||||
guard string.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else { return text }
|
||||
var ranges: [NSRange] = []
|
||||
var offset = 0
|
||||
var start: Int?
|
||||
for scalar in string.unicodeScalars {
|
||||
if CharacterSet.newlines.contains(scalar) {
|
||||
if start == nil { start = offset }
|
||||
} else if let runStart = start {
|
||||
ranges.append(NSRange(location: runStart, length: offset - runStart))
|
||||
start = nil
|
||||
}
|
||||
offset += scalar.utf16.count
|
||||
}
|
||||
if let start { ranges.append(NSRange(location: start, length: offset - start)) }
|
||||
let result = NSMutableAttributedString(attributedString: text)
|
||||
for range in ranges.reversed() { result.replaceCharacters(in: range, with: " ") }
|
||||
return result
|
||||
}
|
||||
|
||||
public static func normalized(_ text: String) -> String {
|
||||
guard text.unicodeScalars.contains(where: { CharacterSet.newlines.contains($0) }) else { return text }
|
||||
var result = ""
|
||||
result.reserveCapacity(text.utf8.count)
|
||||
var isInLineBreak = false
|
||||
for scalar in text.unicodeScalars {
|
||||
if CharacterSet.newlines.contains(scalar) {
|
||||
if !isInLineBreak { result.append(" ") }
|
||||
isInLineBreak = true
|
||||
} else {
|
||||
result.unicodeScalars.append(scalar)
|
||||
isInLineBreak = false
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Binding where Value == String {
|
||||
/// Read the original value unchanged; normalize only user edits, preserving the transaction.
|
||||
public var singleLineText: Binding<String> {
|
||||
Binding(
|
||||
get: { wrappedValue },
|
||||
set: { value, transaction in
|
||||
self.transaction(transaction).wrappedValue = SingleLineTextInput.normalized(value)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SingleLinePasteActionKey: EnvironmentKey {
|
||||
static let defaultValue: (@MainActor @Sendable () -> Void)? = nil
|
||||
}
|
||||
|
||||
private struct SingleLineInputAdapterKey: EnvironmentKey {
|
||||
static let defaultValue: (@MainActor @Sendable () -> AnyView)? = nil
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
/// An app-owned adapter for native input that transforms text before native filtering.
|
||||
public var singleLineInputAdapter: (@MainActor @Sendable () -> AnyView)? {
|
||||
get { self[SingleLineInputAdapterKey.self] }
|
||||
set { self[SingleLineInputAdapterKey.self] = newValue }
|
||||
}
|
||||
|
||||
/// Platforms insert normalized clipboard text at the focused field's selection.
|
||||
public var singleLinePasteAction: (@MainActor @Sendable () -> Void)? {
|
||||
get { self[SingleLinePasteActionKey.self] }
|
||||
set { self[SingleLinePasteActionKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SingleLinePasteModifier: ViewModifier {
|
||||
@Environment(\.singleLinePasteAction) private var paste
|
||||
@Environment(\.singleLineInputAdapter) private var adapter
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
#if os(macOS)
|
||||
if let paste {
|
||||
content.onPasteCommand(of: [.text]) { _ in paste() }
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#else
|
||||
content.background {
|
||||
if let adapter { adapter() }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Normalize before native single-line controls flatten each newline separately.
|
||||
public func singleLinePaste() -> some View { modifier(SingleLinePasteModifier()) }
|
||||
}
|
||||
@@ -46,6 +46,14 @@ empty.
|
||||
|
||||
## State, navigation, and ownership
|
||||
|
||||
Single-line UI fields share Presentation's newline-to-space editing policy and
|
||||
horizontal layout. Binding reads preserve stored values; only edits are normalized.
|
||||
App-owned native rename editors apply the same policy without interrupting marked
|
||||
text. Platforms inject app-owned single-line paste adapters so normalization happens
|
||||
before native controls flatten individual line breaks. Multiline content and secrets
|
||||
are excluded. Existing validation is unchanged. No persistence migration or bulk
|
||||
cleanup is performed.
|
||||
|
||||
- Repository snapshots and preference models are application-wide. A window owns
|
||||
its shell, quick-open, search, navigation, and context-panel state.
|
||||
- Desktop navigation starts with Vault and Assistant, places Metrics above Vault
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
|
||||
/// iPhone has four destinations. iPad retains the sidebar with individual library routes.
|
||||
struct MobileRootView: View {
|
||||
@@ -51,6 +52,7 @@ struct MobileRootView: View {
|
||||
.id(scene.selectedDestination ?? .vault)
|
||||
}
|
||||
}
|
||||
.environment(\.singleLineInputAdapter, { AnyView(MobileSingleLineFieldInput()) })
|
||||
.sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) {
|
||||
MobileProView(pro: app.pro)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AgentKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindPresentation
|
||||
|
||||
enum MobileAssistantLibraryRoute: String, Identifiable, Hashable {
|
||||
case conversations
|
||||
@@ -101,7 +102,8 @@ struct MobileAssistantView: View {
|
||||
"Rename",
|
||||
isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } })
|
||||
) {
|
||||
TextField("Conversation Name", text: $title)
|
||||
TextField("Conversation Name", text: $title.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
Button("Cancel", role: .cancel) { renaming = nil }
|
||||
Button("Save") {
|
||||
if let renaming { Task { await model.rename(renaming, title: title) } }
|
||||
@@ -295,9 +297,10 @@ struct MobileAssistantLibrary: View {
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $query, prompt: "Search")
|
||||
.searchable(text: $query.singleLineText, prompt: "Search")
|
||||
.alert("Rename", isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } })) {
|
||||
TextField("Conversation Name", text: $title)
|
||||
TextField("Conversation Name", text: $title.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(
|
||||
1)
|
||||
Button("Cancel", role: .cancel) { renaming = nil }
|
||||
Button("Save") {
|
||||
if let renaming { Task { await model.rename(renaming, title: title) } }
|
||||
|
||||
@@ -170,7 +170,7 @@ struct MobileHostPicker<Header: View>: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $model.query, prompt: "Search hosts")
|
||||
.searchable(text: $model.query.singleLineText, prompt: "Search hosts")
|
||||
.navigationTitle(Text(title))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
@@ -54,7 +54,9 @@ struct MobileHostEditor: View {
|
||||
}
|
||||
if draft.proxy != nil {
|
||||
LabeledContent("Hostname") {
|
||||
TextField("Hostname", text: proxyString(\.hostname))
|
||||
TextField("Hostname", text: proxyString(\.hostname).singleLineText, axis: .horizontal)
|
||||
.singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
@@ -62,7 +64,7 @@ struct MobileHostEditor: View {
|
||||
}
|
||||
.accessibilityLabel("Proxy Hostname")
|
||||
LabeledContent("Port") {
|
||||
TextField("Port", value: proxyInt(\.port), format: .number.grouping(.never))
|
||||
TextField("Port", value: proxyInt(\.port), format: .number.grouping(.never)).lineLimit(1)
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
@@ -76,7 +78,9 @@ struct MobileHostEditor: View {
|
||||
.accessibilityLabel("Proxy Identity")
|
||||
if draft.proxy?.identityID != nil {
|
||||
LabeledContent("Username") {
|
||||
TextField("Username", text: proxyString(\.username))
|
||||
TextField("Username", text: proxyString(\.username).singleLineText, axis: .horizontal)
|
||||
.singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
@@ -71,7 +71,7 @@ struct MobileCredentialsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $query)
|
||||
.searchable(text: $query.singleLineText)
|
||||
.navigationTitle("Keychain")
|
||||
.toolbar {
|
||||
Menu {
|
||||
@@ -304,11 +304,15 @@ private struct MobileIdentityEditor: View {
|
||||
content: {
|
||||
Section("Identity") {
|
||||
LabeledContent("Name") {
|
||||
TextField("Name", text: $draft.name, prompt: Text(draft.username))
|
||||
TextField(
|
||||
"Name", text: $draft.name.singleLineText, prompt: Text(draft.username), axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
LabeledContent("Username") {
|
||||
TextField("Username", text: $draft.username).textInputAutocapitalization(.never)
|
||||
TextField("Username", text: $draft.username.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled().multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ struct MobileKeyEditor: View {
|
||||
content: {
|
||||
Section("Key") {
|
||||
LabeledContent("Name") {
|
||||
TextField("Name", text: $model.label, prompt: Text(model.defaultName))
|
||||
TextField(
|
||||
"Name", text: $model.label.singleLineText, prompt: Text(model.defaultName),
|
||||
axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
if !model.isEnclave {
|
||||
@@ -65,7 +68,7 @@ struct MobileKeyEditor: View {
|
||||
ForEach(SSHKeyMaterial.Cipher.allCases) { Text($0.displayName).tag($0) }
|
||||
}
|
||||
LabeledContent("Rounds") {
|
||||
TextField("Rounds", value: $model.rounds, format: .number)
|
||||
TextField("Rounds", value: $model.rounds, format: .number).lineLimit(1)
|
||||
.multilineTextAlignment(.trailing).keyboardType(.numberPad)
|
||||
}
|
||||
if model.passphrase != model.confirmation {
|
||||
|
||||
@@ -62,7 +62,7 @@ struct MobileKnownHostsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $query)
|
||||
.searchable(text: $query.singleLineText)
|
||||
.navigationTitle("Known Hosts")
|
||||
.sheet(item: $editing) { MobileKnownHostEditor(app: app, entry: $0) }
|
||||
.confirmationDialog(
|
||||
@@ -101,14 +101,15 @@ struct MobileKnownHostEditor: View {
|
||||
content: {
|
||||
Section {
|
||||
LabeledContent("Hostname") {
|
||||
TextField("Hostname", text: $draft.hostname)
|
||||
TextField("Hostname", text: $draft.hostname.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
if !draft.isHashed {
|
||||
LabeledContent("Port") {
|
||||
TextField("Port", value: $draft.port, format: .number.grouping(.never))
|
||||
TextField("Port", value: $draft.port, format: .number.grouping(.never)).lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
@@ -117,11 +118,13 @@ struct MobileKnownHostEditor: View {
|
||||
ForEach(SSHKeyAlgorithm.allCases) { Text($0.displayName).tag($0) }
|
||||
}
|
||||
LabeledContent("Fingerprint") {
|
||||
TextField("Fingerprint", text: $draft.fingerprint)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.font(.body.monospaced())
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Fingerprint", text: $draft.fingerprint.singleLineText, axis: .horizontal).lineLimit(
|
||||
1
|
||||
)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.font(.body.monospaced())
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
}
|
||||
if draft.isHashed {
|
||||
|
||||
@@ -35,7 +35,8 @@ struct MobileRemoteFileEditor: View {
|
||||
content: {
|
||||
Section { Text(file.path).font(.caption.monospaced()).textSelection(.enabled) }
|
||||
if mode == .rename {
|
||||
TextField("Name", text: $name).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
TextField("Name", text: $name.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.multilineTextAlignment(.leading)
|
||||
} else {
|
||||
Section("Permissions") {
|
||||
@@ -44,10 +45,10 @@ struct MobileRemoteFileEditor: View {
|
||||
text: Binding(
|
||||
get: { permissions.text },
|
||||
set: { _ = permissions.accept($0) }
|
||||
)
|
||||
)
|
||||
.font(.body.monospaced())
|
||||
.keyboardType(.numberPad)
|
||||
).singleLineText, axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
.font(.body.monospaced())
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
Section("File Access") { UnixPermissionGrid(draft: $permissions) }
|
||||
Section("Ownership") {
|
||||
|
||||
@@ -155,7 +155,8 @@ private struct MobileSFTPPathBar: View {
|
||||
var body: some View {
|
||||
Group {
|
||||
if editsPath {
|
||||
TextField("Remote Path", text: $session.pathText)
|
||||
TextField("Remote Path", text: $session.pathText.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.font(.body.monospaced())
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import UIKit
|
||||
|
||||
struct MobileSFTPHistoryView: View {
|
||||
@@ -65,7 +66,7 @@ struct MobileSFTPHistoryView: View {
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, prompt: "Search History")
|
||||
.searchable(text: $query.singleLineText, prompt: "Search History")
|
||||
.navigationTitle("History")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
@@ -239,7 +239,7 @@ struct MobileProviderEditor: View {
|
||||
|
||||
private var modelSection: some View {
|
||||
Section("Models") {
|
||||
TextField("Filter", text: $query)
|
||||
TextField("Filter", text: $query.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(.plain)
|
||||
.multilineTextAlignment(.leading)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -411,7 +411,7 @@ struct MobileModelFetchSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, prompt: "Filter")
|
||||
.searchable(text: $query.singleLineText, prompt: "Filter")
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
@@ -451,10 +451,12 @@ struct MobileAddModelSheet: View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Model ID") {
|
||||
TextField("Model ID", text: $modelID)
|
||||
.textFieldStyle(.plain).multilineTextAlignment(.leading)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.focused($focused).onSubmit(submit)
|
||||
TextField("Model ID", text: $modelID.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(
|
||||
1
|
||||
)
|
||||
.textFieldStyle(.plain).multilineTextAlignment(.leading)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
.focused($focused).onSubmit(submit)
|
||||
if existing.contains(trimmedID) { Text("Added").foregroundStyle(.secondary) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import AgentKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindInfrastructure
|
||||
import TermindPresentation
|
||||
|
||||
struct MobileMCPSettings: View {
|
||||
let ai: AIConfigurationModel
|
||||
@@ -205,7 +206,9 @@ struct MobileMCPEditor: View {
|
||||
Form {
|
||||
Section("Connection") {
|
||||
LabeledContent("Name") {
|
||||
TextField("Name", text: $model.draft.name).multilineTextAlignment(.trailing)
|
||||
TextField("Name", text: $model.draft.name.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
Toggle("Enabled", isOn: $model.draft.isEnabled)
|
||||
LabeledContent("ID") {
|
||||
@@ -222,7 +225,8 @@ struct MobileMCPEditor: View {
|
||||
ForEach(MCPServer.Transport.allCases) { Text($0.displayName).tag($0) }
|
||||
}
|
||||
LabeledContent("URL") {
|
||||
TextField("MCP endpoint URL", text: $model.draft.url)
|
||||
TextField("MCP endpoint URL", text: $model.draft.url.singleLineText, axis: .horizontal)
|
||||
.singleLinePaste().lineLimit(1)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled().keyboardType(.URL)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
@@ -397,12 +401,14 @@ private struct MobileMCPHeaderEditor: View {
|
||||
Form {
|
||||
Section {
|
||||
LabeledContent("Name") {
|
||||
TextField("Header name", text: $draft.name)
|
||||
TextField("Header name", text: $draft.name.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.focused($focusedField, equals: .name)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
LabeledContent("Value") {
|
||||
TextField("Header value", text: $draft.value)
|
||||
TextField("Header value", text: $draft.value.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.focused($focusedField, equals: .value)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AgentKit
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
struct MobileModelFields: View {
|
||||
@Binding var model: AIModel
|
||||
@@ -9,7 +10,9 @@ struct MobileModelFields: View {
|
||||
var body: some View {
|
||||
Section {
|
||||
LabeledContent("Name") {
|
||||
TextField("Name", text: $model.displayName).multilineTextAlignment(.trailing)
|
||||
TextField("Name", text: $model.displayName.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
Picker("Type", selection: $model.kind) {
|
||||
ForEach(AIModel.Kind.allCases) { Text($0.displayName).tag($0) }
|
||||
@@ -54,11 +57,11 @@ struct MobileModelFields: View {
|
||||
|
||||
Section("Limits") {
|
||||
LabeledContent("Context Window") {
|
||||
TextField("Context Window", value: $model.contextLength, format: .number)
|
||||
TextField("Context Window", value: $model.contextLength, format: .number).lineLimit(1)
|
||||
.multilineTextAlignment(.trailing).keyboardType(.numberPad)
|
||||
}
|
||||
LabeledContent("Max Output") {
|
||||
TextField("Max Output", value: $model.maxOutputTokens, format: .number)
|
||||
TextField("Max Output", value: $model.maxOutputTokens, format: .number).lineLimit(1)
|
||||
.multilineTextAlignment(.trailing).keyboardType(.numberPad)
|
||||
}
|
||||
}
|
||||
@@ -118,14 +121,14 @@ struct MobileModelFields: View {
|
||||
for: effort
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.disabled(!mapping.isSupported)
|
||||
.opacity(mapping.isSupported ? 1 : 0.55)
|
||||
.accessibilityLabel(Text("\(effort.displayName) provider value"))
|
||||
).singleLineText, axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.disabled(!mapping.isSupported)
|
||||
.opacity(mapping.isSupported ? 1 : 0.55)
|
||||
.accessibilityLabel(Text("\(effort.displayName) provider value"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,9 @@ struct MobileSkillEditor: View {
|
||||
content: {
|
||||
Section("Skill") {
|
||||
LabeledContent("Title") {
|
||||
TextField("Title", text: $model.draft.title).multilineTextAlignment(.trailing)
|
||||
TextField("Title", text: $model.draft.title.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
LabeledContent("Name") {
|
||||
Text(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@@ -76,7 +77,7 @@ struct MobileSnippetsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $query)
|
||||
.searchable(text: $query.singleLineText)
|
||||
.navigationTitle("Snippets")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
@@ -613,7 +614,8 @@ struct MobileSnippetEditor: View {
|
||||
save: save
|
||||
) {
|
||||
Section {
|
||||
TextField("Title", text: $draft.title).multilineTextAlignment(.leading)
|
||||
TextField("Title", text: $draft.title.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
TextEditor(text: $draft.body).multilineTextAlignment(.leading)
|
||||
.font(.body.monospaced()).frame(minHeight: 220)
|
||||
.textInputAutocapitalization(.never).autocorrectionDisabled()
|
||||
@@ -726,7 +728,9 @@ struct MobileSnippetPackageEditor: View {
|
||||
isDirty: draft != original, canSave: !trimmedName.isEmpty, compact: true,
|
||||
save: save
|
||||
) {
|
||||
TextField("Name", text: $draft.name).multilineTextAlignment(.leading)
|
||||
TextField("Name", text: $draft.name.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.multilineTextAlignment(
|
||||
.leading)
|
||||
Picker("Inside", selection: $draft.parentID) {
|
||||
Text("Top Level").tag(SnippetPackage.ID?.none)
|
||||
ForEach(parentChoices, id: \.package.id) { choice in
|
||||
@@ -834,7 +838,7 @@ struct MobileSnippetHostPicker: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $query, prompt: "Search hosts")
|
||||
.searchable(text: $query.singleLineText, prompt: "Search hosts")
|
||||
.navigationTitle(Text(title))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
@@ -63,7 +63,7 @@ struct MobileCommandHistoryView: View {
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
.searchable(text: $query)
|
||||
.searchable(text: $query.singleLineText)
|
||||
.navigationTitle("History")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import UIKit
|
||||
|
||||
struct MobileTerminalSnippetsView: View {
|
||||
@@ -40,7 +41,7 @@ struct MobileTerminalSnippetsView: View {
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.searchable(text: $query)
|
||||
.searchable(text: $query.singleLineText)
|
||||
.navigationTitle("Snippets")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
@@ -227,7 +227,7 @@ struct MobileVaultView: View {
|
||||
searchResults
|
||||
}
|
||||
}
|
||||
.searchable(text: $query)
|
||||
.searchable(text: $query.singleLineText)
|
||||
.navigationTitle("Vault")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
@@ -249,7 +249,7 @@ struct MobileVaultView: View {
|
||||
if let error = app.library.storageError { throw MobileEditorError.message(error) }
|
||||
groupName = ""
|
||||
}
|
||||
) { TextField("Name", text: $groupName) }
|
||||
) { TextField("Name", text: $groupName.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1) }
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Host?", isPresented: Binding(get: { deletingHost != nil }, set: { if !$0 { deletingHost = nil } }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
|
||||
struct MobileWorkspaceRenameView: View {
|
||||
let originalTitle: String
|
||||
@@ -19,7 +20,10 @@ struct MobileWorkspaceRenameView: View {
|
||||
canSave: !normalizedTitle.isEmpty && normalizedTitle != originalTitle,
|
||||
compact: true,
|
||||
save: { save(normalizedTitle) },
|
||||
content: { TextField("Workspace Name", text: $title) }
|
||||
content: {
|
||||
TextField("Workspace Name", text: $title.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(
|
||||
1)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ struct MobileWorkspaceView: View {
|
||||
Text("Use this choice for all conflicts in this batch.")
|
||||
}
|
||||
.alert("New Folder", isPresented: $creatingDirectory) {
|
||||
TextField("Name", text: $directoryName)
|
||||
TextField("Name", text: $directoryName.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
Button("Create") {
|
||||
Task {
|
||||
await workspace.sftpSession.createDirectory(directoryName)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
import UIKit
|
||||
|
||||
/// A field-scoped paste proxy; the original text-input delegate is never replaced.
|
||||
struct MobileSingleLineFieldInput: UIViewRepresentable {
|
||||
final class Marker: UIView {
|
||||
private weak var field: UITextField?
|
||||
private let proxy = DelegateProxy()
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
scheduleAttach()
|
||||
}
|
||||
|
||||
func scheduleAttach() {
|
||||
DispatchQueue.main.async { [weak self] in self?.attach() }
|
||||
}
|
||||
|
||||
private func attach() {
|
||||
guard window != nil else {
|
||||
detach()
|
||||
return
|
||||
}
|
||||
var ancestor = superview
|
||||
while let view = ancestor {
|
||||
if let candidate = findField(in: view) {
|
||||
guard !candidate.isSecureTextEntry else { return }
|
||||
if let existing = candidate.pasteDelegate as? DelegateProxy, existing !== proxy { return }
|
||||
if field !== candidate { detach() }
|
||||
field = candidate
|
||||
if candidate.pasteDelegate !== proxy {
|
||||
proxy.originalDelegate = candidate.pasteDelegate
|
||||
candidate.pasteDelegate = proxy
|
||||
}
|
||||
return
|
||||
}
|
||||
ancestor = view.superview
|
||||
}
|
||||
}
|
||||
|
||||
private func findField(in view: UIView) -> UITextField? {
|
||||
if let field = view as? UITextField, field.convert(field.bounds, to: self).intersects(bounds) {
|
||||
return field
|
||||
}
|
||||
return view.subviews.lazy.compactMap { self.findField(in: $0) }.first
|
||||
}
|
||||
|
||||
func detach() {
|
||||
if field?.pasteDelegate === proxy { field?.pasteDelegate = proxy.originalDelegate }
|
||||
field = nil
|
||||
proxy.originalDelegate = nil
|
||||
}
|
||||
}
|
||||
|
||||
// NSObject's selector lookup is nonisolated; UIKit's actual paste callback stays MainActor.
|
||||
nonisolated final class DelegateProxy: NSObject, UITextPasteDelegate {
|
||||
weak var originalDelegate: (any UITextPasteDelegate)?
|
||||
|
||||
override func responds(to selector: Selector!) -> Bool {
|
||||
super.responds(to: selector) || originalDelegate?.responds(to: selector) == true
|
||||
}
|
||||
|
||||
override func forwardingTarget(for selector: Selector!) -> Any? {
|
||||
if originalDelegate?.responds(to: selector) == true { return originalDelegate }
|
||||
return super.forwardingTarget(for: selector)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func textPasteConfigurationSupporting(
|
||||
_ textPasteConfigurationSupporting: any UITextPasteConfigurationSupporting,
|
||||
combineItemAttributedStrings itemStrings: [NSAttributedString], for textRange: UITextRange
|
||||
) -> NSAttributedString {
|
||||
let combined: NSAttributedString
|
||||
if let original = originalDelegate?.textPasteConfigurationSupporting?(
|
||||
textPasteConfigurationSupporting, combineItemAttributedStrings: itemStrings, for: textRange)
|
||||
{
|
||||
combined = original
|
||||
} else {
|
||||
let result = NSMutableAttributedString(string: "")
|
||||
for item in itemStrings { result.append(item) }
|
||||
combined = result
|
||||
}
|
||||
return SingleLineTextInput.normalized(combined)
|
||||
}
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> Marker {
|
||||
let view = Marker()
|
||||
view.isUserInteractionEnabled = false
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: Marker, context: Context) { view.scheduleAttach() }
|
||||
|
||||
static func dismantleUIView(_ view: Marker, coordinator: ()) { view.detach() }
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func mobileToolbarSearch(text: Binding<String>, prompt: Text? = nil) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
searchable(text: text, placement: .toolbar, prompt: prompt)
|
||||
searchable(text: text.singleLineText, placement: .toolbar, prompt: prompt)
|
||||
.searchToolbarBehavior(.minimize)
|
||||
} else {
|
||||
searchable(text: text, placement: .toolbar, prompt: prompt)
|
||||
searchable(text: text.singleLineText, placement: .toolbar, prompt: prompt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +19,10 @@ extension View {
|
||||
prompt: Text? = nil
|
||||
) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
searchable(text: text, isPresented: isPresented, placement: .toolbar, prompt: prompt)
|
||||
searchable(text: text.singleLineText, isPresented: isPresented, placement: .toolbar, prompt: prompt)
|
||||
.searchToolbarBehavior(.minimize)
|
||||
} else {
|
||||
searchable(text: text, isPresented: isPresented, placement: .toolbar, prompt: prompt)
|
||||
searchable(text: text.singleLineText, isPresented: isPresented, placement: .toolbar, prompt: prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite("Mobile single-line field editing", .serialized)
|
||||
@MainActor
|
||||
struct MobileSingleLineFieldEditingTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func hostFormNormalizesPasteAndPreservesFocus(language: String) async throws {
|
||||
let draft = MobileSingleLineHostTestDraft()
|
||||
let controller = UIHostingController(
|
||||
rootView: MobileSingleLineHostTestForm(draft: draft)
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.singleLineInputAdapter, { AnyView(MobileSingleLineFieldInput()) }))
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 520, height: 600))
|
||||
window.rootViewController = controller
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
controller.view.layoutIfNeeded()
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
let field = try #require(findField(in: controller.view, value: "fixture.invalid"))
|
||||
let inputDelegate = field.delegate
|
||||
let pasteDelegate = try #require(field.pasteDelegate as? MobileSingleLineFieldInput.DelegateProxy)
|
||||
#expect(field.becomeFirstResponder())
|
||||
let selection = try #require(field.textRange(from: field.beginningOfDocument, to: field.endOfDocument))
|
||||
field.selectedTextRange = selection
|
||||
// Invoke the native paste transformation synchronously: other UI suites can
|
||||
// change the key window while UIKit asynchronously loads item providers.
|
||||
let pasted = pasteDelegate.textPasteConfigurationSupporting(
|
||||
field, combineItemAttributedStrings: [NSAttributedString(string: "first.example\r\n\nsecond.example")],
|
||||
for: selection)
|
||||
field.insertText(pasted.string)
|
||||
#expect(draft.host.host == "first.example second.example")
|
||||
#expect(field.text == "first.example second.example")
|
||||
#expect(field.isFirstResponder)
|
||||
#expect(field.delegate === inputDelegate)
|
||||
field.selectedTextRange = field.textRange(from: field.beginningOfDocument, to: field.endOfDocument)
|
||||
let chineseSelection = try #require(field.selectedTextRange)
|
||||
let chinesePaste = pasteDelegate.textPasteConfigurationSupporting(
|
||||
field, combineItemAttributedStrings: [NSAttributedString(string: "中文\u{2028}名称")], for: chineseSelection)
|
||||
field.insertText(chinesePaste.string)
|
||||
#expect(draft.host.host == "中文 名称")
|
||||
#expect(field.isFirstResponder)
|
||||
}
|
||||
|
||||
private func findField(in view: UIView, value: String) -> UITextField? {
|
||||
if let field = view as? UITextField, field.text == value { return field }
|
||||
return view.subviews.lazy.compactMap { findField(in: $0, value: value) }.first
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
private final class MobileSingleLineHostTestDraft {
|
||||
var host = TermindDomain.Host(name: "Fixture", host: "fixture.invalid")
|
||||
}
|
||||
|
||||
private struct MobileSingleLineHostTestForm: View {
|
||||
@Bindable var draft: MobileSingleLineHostTestDraft
|
||||
|
||||
var body: some View { Form { HostConnectionFields(host: $draft.host) } }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@preconcurrency import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindPresentation
|
||||
|
||||
/// One window with one native Navigator column and one persistent detail deck.
|
||||
///
|
||||
@@ -58,6 +59,7 @@ struct RootView: View {
|
||||
)
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.environment(\.singleLinePasteAction, { SingleLineFieldEditing.paste() })
|
||||
.background {
|
||||
MetricsWindowRegistration(model: window)
|
||||
WindowToolbarSearchHost()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindPresentation
|
||||
|
||||
/// A token field: the tags already chosen, shown as removable chips, over a
|
||||
/// search field that narrows the tags in use elsewhere and offers to create the
|
||||
@@ -78,7 +79,9 @@ struct TagField: View {
|
||||
Image(systemName: "number")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField(text: $query, prompt: Text("Add a tag…")) { EmptyView() }
|
||||
TextField(text: $query.singleLineText, prompt: Text("Add a tag…"), axis: .horizontal) { EmptyView() }
|
||||
.singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.textFieldStyle(.plain)
|
||||
.labelsHidden()
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
@@ -2,6 +2,7 @@ import AppKit
|
||||
import ListViewKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindPresentation
|
||||
|
||||
nonisolated enum AssistantSidebarEscapePolicy {
|
||||
static func shouldCloseConversation(
|
||||
@@ -1340,8 +1341,8 @@ struct AssistantConversationRow: View {
|
||||
controlActiveState: controlActiveState,
|
||||
onSubmit: onCommitRename,
|
||||
onCancel: onCancelRename
|
||||
)
|
||||
.frame(height: 18)
|
||||
).singleLinePaste()
|
||||
.frame(height: 18)
|
||||
} else {
|
||||
Text(conversation.derivedTitle).lineLimit(1)
|
||||
}
|
||||
|
||||
@@ -106,15 +106,16 @@ struct HostEditorView: View {
|
||||
}
|
||||
|
||||
if draft.proxy != nil {
|
||||
TextField("Hostname", text: proxyString(\.hostname))
|
||||
TextField("Hostname", text: proxyString(\.hostname).singleLineText, axis: .horizontal)
|
||||
.singleLinePaste().lineLimit(1)
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
.accessibilityLabel("Proxy Hostname")
|
||||
TextField(
|
||||
"Port", value: proxyInt(\.port),
|
||||
format: .number.grouping(.never)
|
||||
)
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
.accessibilityLabel("Proxy Port")
|
||||
).lineLimit(1)
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
.accessibilityLabel("Proxy Port")
|
||||
Picker("Identity", selection: proxyIdentityChoice) {
|
||||
Text("None").tag(Choice<Identity.ID>.unset)
|
||||
ForEach(proxyIdentities) { identity in
|
||||
@@ -127,7 +128,9 @@ struct HostEditorView: View {
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
.accessibilityLabel("Proxy Identity")
|
||||
if draft.proxy?.identityID != nil {
|
||||
TextField("Username", text: proxyString(\.username))
|
||||
TextField("Username", text: proxyString(\.username).singleLineText, axis: .horizontal)
|
||||
.singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.padding(.leading, Metrics.Space.xxl)
|
||||
.accessibilityLabel("Proxy Username")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
/// Make or edit a group without leaving whatever raised the sheet — the
|
||||
/// companion to `NewIdentitySheet`, and the only place a group is ever edited.
|
||||
@@ -64,7 +65,7 @@ struct NewGroupSheet: View {
|
||||
private var form: some View {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Name", text: $name)
|
||||
TextField("Name", text: $name.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
|
||||
// "Inside", not "Parent": the answer is where the group goes,
|
||||
// and every other row here is phrased as a property of the
|
||||
|
||||
@@ -422,7 +422,7 @@ private struct SoftwareKeyEditor: View {
|
||||
}
|
||||
|
||||
Section("Key") {
|
||||
TextField("Name", text: $name)
|
||||
TextField("Name", text: $name.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
Picker("Store in", selection: $storage) {
|
||||
Label("iCloud", systemImage: "icloud.fill").tag(KeyStorage.iCloud)
|
||||
Label("This Mac", systemImage: "lock.laptopcomputer").tag(KeyStorage.thisDevice)
|
||||
@@ -827,7 +827,8 @@ private struct GenerateKeySheet: View {
|
||||
var body: some View {
|
||||
VaultDetailScaffold(title: "Generate Key", symbol: "key.fill", close: close) {
|
||||
Form {
|
||||
TextField("Name", text: $label, prompt: Text(defaultName))
|
||||
TextField("Name", text: $label.singleLineText, prompt: Text(defaultName), axis: .horizontal).lineLimit(
|
||||
1)
|
||||
Picker("Store in", selection: $storage) {
|
||||
ForEach(availableStorage) { location in
|
||||
Label(location.displayName, systemImage: location.symbol)
|
||||
@@ -873,7 +874,7 @@ private struct GenerateKeySheet: View {
|
||||
Text(cipher.displayName).tag(cipher)
|
||||
}
|
||||
}
|
||||
TextField("Rounds", value: $rounds, format: .number)
|
||||
TextField("Rounds", value: $rounds, format: .number).lineLimit(1)
|
||||
}
|
||||
if !passwordsMatch {
|
||||
Text("Passphrases do not match.")
|
||||
@@ -1047,7 +1048,7 @@ private struct SecureEnclaveKeyEditor: View {
|
||||
VaultDetailScaffold(title: "Edit Key", symbol: "pencil", close: close) {
|
||||
Form {
|
||||
Section("Key") {
|
||||
TextField("Name", text: $name)
|
||||
TextField("Name", text: $name.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
LabeledContent("Color") {
|
||||
ColorTagPicker(selection: $colorTag, layout: .compact)
|
||||
}
|
||||
@@ -1157,7 +1158,8 @@ struct ExportKeyToHostsSheet: View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search hosts", text: queryBinding)
|
||||
TextField("Search hosts", text: queryBinding.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($searchFocused)
|
||||
.onKeyPress(.upArrow) { moveCursor(-1) }
|
||||
|
||||
@@ -110,9 +110,10 @@ struct NewIdentitySheet: View {
|
||||
Form {
|
||||
Section {
|
||||
if configuration.showsName {
|
||||
TextField("Name", text: $name, prompt: Text(username))
|
||||
TextField("Name", text: $name.singleLineText, prompt: Text(username), axis: .horizontal).lineLimit(
|
||||
1)
|
||||
}
|
||||
TextField("Username", text: $username)
|
||||
TextField("Username", text: $username.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
|
||||
// Both optional, and both allowed at once: a key to get in and a
|
||||
// password the same account still asks for is an ordinary setup,
|
||||
|
||||
@@ -159,12 +159,16 @@ struct KnownHostEditorView: View {
|
||||
var body: some View {
|
||||
VaultDetailScaffold(title: "Host Key", symbol: "touchid", close: window.closeVaultWork) {
|
||||
Form {
|
||||
TextField("Hostname", text: $draft.hostname)
|
||||
if !draft.isHashed { TextField("Port", value: $draft.port, format: .number.grouping(.never)) }
|
||||
TextField("Hostname", text: $draft.hostname.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
if !draft.isHashed {
|
||||
TextField("Port", value: $draft.port, format: .number.grouping(.never)).lineLimit(1)
|
||||
}
|
||||
Picker("Key Type", selection: $draft.algorithm) {
|
||||
ForEach(SSHKeyAlgorithm.allCases) { Text($0.displayName).tag($0) }
|
||||
}
|
||||
TextField("Fingerprint", text: $draft.fingerprint)
|
||||
TextField("Fingerprint", text: $draft.fingerprint.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.font(.body.monospaced())
|
||||
if draft.isHashed {
|
||||
Text("The original address and port cannot be recovered from this hash.").font(.caption)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
|
||||
/// The window-level presentation for Command-P.
|
||||
///
|
||||
@@ -94,7 +95,9 @@ struct QuickOpenView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
TextField("Open a page, chat, workspace, host, or command", text: $query)
|
||||
TextField("Open a page, chat, workspace, host, or command", text: $query.singleLineText, axis: .horizontal)
|
||||
.singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.focused($searchFocused)
|
||||
|
||||
@@ -306,7 +306,8 @@ struct SFTPBrowserView: View {
|
||||
}
|
||||
}
|
||||
.alert("New Folder", isPresented: $newFolderPresented) {
|
||||
TextField("Folder Name", text: $newFolderName)
|
||||
TextField("Folder Name", text: $newFolderName.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Create") {
|
||||
let name = newFolderName
|
||||
@@ -321,7 +322,7 @@ struct SFTPBrowserView: View {
|
||||
set: { if !$0 { renameTarget = nil } }
|
||||
)
|
||||
) {
|
||||
TextField("Name", text: $renameName)
|
||||
TextField("Name", text: $renameName.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
Button("Cancel", role: .cancel) { renameTarget = nil }
|
||||
Button("Rename") {
|
||||
guard let target = renameTarget else { return }
|
||||
@@ -408,7 +409,8 @@ struct SFTPBrowserView: View {
|
||||
}
|
||||
|
||||
if editsPath {
|
||||
TextField("Remote Path", text: $sftpSession.pathText)
|
||||
TextField("Remote Path", text: $sftpSession.pathText.singleLineText, axis: .horizontal)
|
||||
.singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.body.monospaced())
|
||||
.focused($pathFocused)
|
||||
@@ -1328,7 +1330,7 @@ private struct SFTPEditPermissionsSheet: View {
|
||||
.truncationMode(.middle)
|
||||
.help(displayedPath)
|
||||
Spacer(minLength: Metrics.Space.xl)
|
||||
TextField("Mode", text: modeText)
|
||||
TextField("Mode", text: modeText.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.labelsHidden()
|
||||
.font(.body.monospacedDigit())
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
@@ -288,7 +288,8 @@ private struct SSHImportContent: View {
|
||||
editingNames.insert(item.id)
|
||||
model.setName($0, for: item.id)
|
||||
}
|
||||
))
|
||||
).singleLineText, axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
}
|
||||
let options = model.keyOptions(for: item)
|
||||
if options.count > 1 {
|
||||
@@ -312,7 +313,8 @@ private struct SSHImportContent: View {
|
||||
text: Binding(
|
||||
get: { binding.name.wrappedValue },
|
||||
set: { model.setName($0, for: item.id) }
|
||||
))
|
||||
).singleLineText, axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
}
|
||||
LabeledContent("Username", value: identity.username)
|
||||
LabeledContent(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AgentKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindPresentation
|
||||
|
||||
@Observable
|
||||
final class MCPServerEditorSession {
|
||||
@@ -86,7 +87,7 @@ struct MCPServerEditorPage: View {
|
||||
@ViewBuilder
|
||||
private func connectionSection(_ server: MCPServer) -> some View {
|
||||
Section {
|
||||
TextField("Name", text: binding(\.name))
|
||||
TextField("Name", text: binding(\.name).singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
Toggle(
|
||||
"Enabled",
|
||||
isOn: Binding(
|
||||
@@ -124,7 +125,9 @@ struct MCPServerEditorPage: View {
|
||||
ForEach(MCPServer.Transport.allCases) { Text($0.displayName).tag($0) }
|
||||
}
|
||||
|
||||
TextField("URL", text: binding(\.url), prompt: Text("MCP endpoint URL"))
|
||||
TextField("URL", text: binding(\.url).singleLineText, prompt: Text("MCP endpoint URL"), axis: .horizontal)
|
||||
.singleLinePaste()
|
||||
.lineLimit(1)
|
||||
|
||||
Text(server.transport.detail)
|
||||
.font(.caption)
|
||||
@@ -199,9 +202,13 @@ struct MCPServerEditorPage: View {
|
||||
ForEach(server.headers) { header in
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
TextField(
|
||||
"Name", text: headerBinding(header.id, \.name), prompt: Text("Header name"))
|
||||
"Name", text: headerBinding(header.id, \.name).singleLineText, prompt: Text("Header name"),
|
||||
axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
TextField(
|
||||
"Value", text: headerBinding(header.id, \.value), prompt: Text("Header value"))
|
||||
"Value", text: headerBinding(header.id, \.value).singleLineText, prompt: Text("Header value"),
|
||||
axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
Button {
|
||||
removeHeader(header.id)
|
||||
} label: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
@@ -37,9 +38,9 @@ struct MCPServerSettings: View {
|
||||
get: { server.port },
|
||||
set: { server.setPort($0) }
|
||||
), format: .number.grouping(.never)
|
||||
)
|
||||
.frame(width: 100)
|
||||
.multilineTextAlignment(.trailing)
|
||||
).lineLimit(1)
|
||||
.frame(width: 100)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
Text(
|
||||
"The server listens only on 127.0.0.1 and ::1. It is off by default; when enabled, external calls execute without per-call approval."
|
||||
@@ -98,7 +99,9 @@ struct MCPServerSettings: View {
|
||||
}
|
||||
|
||||
Section("Call History") {
|
||||
TextField("Filter by client, tool, host or argument", text: $historyQuery)
|
||||
TextField("Filter by client, tool, host or argument", text: $historyQuery.singleLineText, axis: .horizontal)
|
||||
.singleLinePaste()
|
||||
.lineLimit(1)
|
||||
let rows = app.toolAudit.entries(matching: historyQuery)
|
||||
if rows.isEmpty {
|
||||
ContentUnavailableView(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AgentKit
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
/// A provider's full-width models page: a checkable list and details for the
|
||||
/// selected row.
|
||||
@@ -117,10 +118,11 @@ struct ModelListEditor: View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
TextField(
|
||||
"Exact model ID, such as gpt-5.4",
|
||||
text: Binding(get: { state.newModelID }, set: { state.newModelID = $0 })
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(addModel)
|
||||
text: Binding(get: { state.newModelID }, set: { state.newModelID = $0 }).singleLineText,
|
||||
axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit(addModel)
|
||||
|
||||
Button("Cancel") {
|
||||
state.newModelID = ""
|
||||
@@ -141,8 +143,9 @@ struct ModelListEditor: View {
|
||||
HStack(spacing: Metrics.Space.xs) {
|
||||
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||
TextField(
|
||||
"Filter", text: Binding(get: { state.query }, set: { state.query = $0 })
|
||||
).textFieldStyle(.plain)
|
||||
"Filter", text: Binding(get: { state.query }, set: { state.query = $0 }).singleLineText,
|
||||
axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1).textFieldStyle(.plain)
|
||||
if !state.query.isEmpty {
|
||||
Button {
|
||||
state.query = ""
|
||||
@@ -335,7 +338,10 @@ struct ModelListEditor: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
TextField("Name", text: field(model, \.displayName), prompt: Text(model.id))
|
||||
TextField(
|
||||
"Name", text: field(model, \.displayName).singleLineText, prompt: Text(model.id),
|
||||
axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Picker("Type", selection: field(model, \.kind)) {
|
||||
@@ -525,11 +531,11 @@ struct ModelListEditor: View {
|
||||
updated.setReasoningWireValue(trimmed.isEmpty ? nil : value, for: effort)
|
||||
app.aiConfiguration.updateModel(updated, in: modelProviderID)
|
||||
}
|
||||
)
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(!mapping.isSupported)
|
||||
.accessibilityLabel(Text("\(effort.displayName) provider value"))
|
||||
).singleLineText, axis: .horizontal
|
||||
).singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(!mapping.isSupported)
|
||||
.accessibilityLabel(Text("\(effort.displayName) provider value"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,9 +559,9 @@ struct ModelListEditor: View {
|
||||
}
|
||||
),
|
||||
format: .number
|
||||
)
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
).lineLimit(1)
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ struct SkillEditorPage: View {
|
||||
@ViewBuilder
|
||||
private func identitySection(_ skill: AgentSkill) -> some View {
|
||||
Section {
|
||||
TextField("Title", text: binding(\.title))
|
||||
TextField("Title", text: binding(\.title).singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
|
||||
if isNameLocked {
|
||||
LabeledContent("Name") {
|
||||
|
||||
@@ -843,18 +843,18 @@ private struct SSHCommandHistorySidebar: View {
|
||||
prompt: String(localized: "Snippet Name"),
|
||||
onSubmit: { saveSnippet(from: entry) },
|
||||
onCancel: cancelSnippetNaming
|
||||
)
|
||||
.frame(height: 18)
|
||||
.padding(.horizontal, Metrics.Space.s)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Color.primary.opacity(0.055),
|
||||
in: .rect(cornerRadius: Metrics.Radius.small)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Metrics.Radius.small)
|
||||
.strokeBorder(Color.accentColor.opacity(0.55))
|
||||
}
|
||||
).singleLinePaste()
|
||||
.frame(height: 18)
|
||||
.padding(.horizontal, Metrics.Space.s)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Color.primary.opacity(0.055),
|
||||
in: .rect(cornerRadius: Metrics.Radius.small)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Metrics.Radius.small)
|
||||
.strokeBorder(Color.accentColor.opacity(0.55))
|
||||
}
|
||||
Text(CommandHistoryPresentation.title(for: entry))
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -1342,6 +1342,7 @@ private struct SidebarInlineTextField: NSViewRepresentable {
|
||||
field.focusRingType = .none
|
||||
field.font = NSFont.preferredFont(forTextStyle: .body)
|
||||
field.lineBreakMode = .byTruncatingTail
|
||||
field.cell?.usesSingleLineMode = true
|
||||
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
context.coordinator.requestFocus(for: field)
|
||||
return field
|
||||
@@ -1378,8 +1379,10 @@ private struct SidebarInlineTextField: NSViewRepresentable {
|
||||
}
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard let field = notification.object as? NSTextField else { return }
|
||||
text.wrappedValue = field.stringValue
|
||||
guard let field = notification.object as? NSTextField,
|
||||
let value = SingleLineFieldEditing.normalizedValue(in: field)
|
||||
else { return }
|
||||
text.wrappedValue = value
|
||||
}
|
||||
|
||||
func control(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@@ -588,7 +589,7 @@ struct SnippetEditorSheet: View {
|
||||
) {
|
||||
Form {
|
||||
Section {
|
||||
TextField("Name", text: $title)
|
||||
TextField("Name", text: $title.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.s) {
|
||||
Text("Script")
|
||||
.font(.caption)
|
||||
@@ -868,7 +869,7 @@ struct SnippetPackageSheet: View {
|
||||
symbol: "folder.fill", close: close
|
||||
) {
|
||||
Form {
|
||||
TextField("Name", text: $name)
|
||||
TextField("Name", text: $name.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
Picker("Inside", selection: $parentID) {
|
||||
Text("Top Level").tag(SnippetPackage.ID?.none)
|
||||
ForEach(parentChoices, id: \.package.id) { choice in
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
|
||||
nonisolated struct SerialComboOption: Identifiable, Hashable, Sendable {
|
||||
let value: String
|
||||
@@ -19,7 +20,7 @@ struct SerialEditableComboField: View {
|
||||
var body: some View {
|
||||
LabeledContent {
|
||||
HStack(spacing: 0) {
|
||||
TextField("", text: $value)
|
||||
TextField("", text: $value.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(.plain)
|
||||
.font(monospaced ? .body.monospaced() : .body)
|
||||
.padding(.leading, Metrics.Space.m)
|
||||
|
||||
@@ -723,12 +723,14 @@ private struct PaneView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if isRenaming {
|
||||
TextField("Pane Name", text: $draftTitle)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.focused($titleFieldFocused)
|
||||
.onSubmit(commitRename)
|
||||
.onExitCommand { isRenaming = false }
|
||||
TextField("Pane Name", text: $draftTitle.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(
|
||||
1
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.focused($titleFieldFocused)
|
||||
.onSubmit(commitRename)
|
||||
.onExitCommand { isRenaming = false }
|
||||
} else {
|
||||
title
|
||||
}
|
||||
@@ -1127,8 +1129,8 @@ private struct WelcomeWorkspaceTemplateRow: View {
|
||||
controlActiveState: controlActiveState,
|
||||
onSubmit: commitRename,
|
||||
onCancel: cancelRename
|
||||
)
|
||||
.frame(height: 18)
|
||||
).singleLinePaste()
|
||||
.frame(height: 18)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.xs) {
|
||||
Text(template.name)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindPresentation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// The native source list for Vault and Workspaces.
|
||||
@@ -414,8 +415,8 @@ private struct WorkspaceNavigatorRow: View {
|
||||
controlActiveState: controlActiveState,
|
||||
onSubmit: onCommitRename,
|
||||
onCancel: onCancelRename
|
||||
)
|
||||
.frame(height: 18)
|
||||
).singleLinePaste()
|
||||
.frame(height: 18)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(workspace.title(in: app.hostLibrary, locale: locale))
|
||||
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
extension Workspace {
|
||||
func workspaceTemplateLayout(in hosts: HostLibraryModel) -> WorkspaceTemplateLayout? {
|
||||
@@ -162,7 +163,8 @@ struct WorkspaceTemplateNameSheet: View {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.l) {
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
TextField("Workspace Name", text: $presentation.name)
|
||||
TextField("Workspace Name", text: $presentation.name.singleLineText, axis: .horizontal).singleLinePaste()
|
||||
.lineLimit(1)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.focused($isNameFocused)
|
||||
if let error = presentation.error {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
|
||||
extension View {
|
||||
func termindTransientOverlaySurface() -> some View {
|
||||
@@ -62,7 +63,7 @@ struct TermindSearchField: View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(prompt, text: $text)
|
||||
TextField(prompt, text: $text.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(.plain)
|
||||
.font(density == .prominent ? .title3 : .body)
|
||||
.focused(externalFocus ?? $localFocus)
|
||||
@@ -205,7 +206,7 @@ struct ManagedChooserHeader: View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(prompt, text: $query)
|
||||
TextField(prompt, text: $query.singleLineText, axis: .horizontal).singleLinePaste().lineLimit(1)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($searchFocused)
|
||||
.onSubmit(onSubmit)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindPresentation
|
||||
|
||||
/// A transparent native field for inline renaming inside emphasized sidebar
|
||||
/// rows. Return commits, Escape cancels, and a pointer exit commits only after
|
||||
@@ -103,8 +104,10 @@ struct SidebarInlineRenameField: NSViewRepresentable {
|
||||
}
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard let field = notification.object as? NSTextField else { return }
|
||||
text.wrappedValue = field.stringValue
|
||||
guard let field = notification.object as? NSTextField,
|
||||
let value = SingleLineFieldEditing.normalizedValue(in: field)
|
||||
else { return }
|
||||
text.wrappedValue = value
|
||||
}
|
||||
|
||||
func controlTextDidEndEditing(_: Notification) {
|
||||
@@ -400,7 +403,7 @@ struct SidebarSearchFooter: View {
|
||||
var placeholder: String
|
||||
|
||||
var body: some View {
|
||||
NativeSearchField(text: $text, placeholder: placeholder, controlSize: .large)
|
||||
NativeSearchField(text: $text, placeholder: placeholder, controlSize: .large).singleLinePaste()
|
||||
.frame(height: Metrics.Sidebar.searchFieldHeight)
|
||||
.padding(.horizontal, Metrics.Space.m)
|
||||
.padding(.vertical, Metrics.Space.s)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import AppKit
|
||||
import TermindPresentation
|
||||
|
||||
/// Keep the native field editor and binding in sync without interrupting marked-text composition.
|
||||
enum SingleLineFieldEditing {
|
||||
static func paste() {
|
||||
guard let editor = NSApp.keyWindow?.firstResponder as? NSTextView,
|
||||
let field = editor.delegate as? NSTextField, !(field is NSSecureTextField),
|
||||
let text = NSPasteboard.general.string(forType: .string)
|
||||
else { return }
|
||||
insert(text, into: editor)
|
||||
}
|
||||
|
||||
static func insert(_ text: String, into editor: NSTextView) {
|
||||
editor.insertText(SingleLineTextInput.normalized(text), replacementRange: editor.selectedRange())
|
||||
}
|
||||
|
||||
static func normalizedValue(in field: NSTextField) -> String? {
|
||||
let editor = field.currentEditor() as? NSTextView
|
||||
guard editor?.hasMarkedText() != true else { return nil }
|
||||
let original = field.stringValue
|
||||
let normalized = SingleLineTextInput.normalized(original)
|
||||
guard original != normalized else { return original }
|
||||
let selection = editor?.selectedRange()
|
||||
field.stringValue = normalized
|
||||
if let editor, let selection {
|
||||
let originalString = original as NSString
|
||||
let start = min(selection.location, originalString.length)
|
||||
let end = start + min(selection.length, originalString.length - start)
|
||||
let normalizedStart = (SingleLineTextInput.normalized(originalString.substring(to: start)) as NSString)
|
||||
.length
|
||||
let normalizedEnd = (SingleLineTextInput.normalized(originalString.substring(to: end)) as NSString).length
|
||||
editor.string = normalized
|
||||
editor.setSelectedRange(NSRange(location: normalizedStart, length: normalizedEnd - normalizedStart))
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@@ -94,6 +95,7 @@ struct NativeSearchField: NSViewRepresentable {
|
||||
field.stringValue = text
|
||||
field.controlSize = controlSize
|
||||
field.focusRingType = .default
|
||||
field.cell?.usesSingleLineMode = true
|
||||
// Filter as the user types. The default batches until Return, which for
|
||||
// a list that narrows in place means the field looks broken.
|
||||
field.sendsWholeSearchString = false
|
||||
@@ -178,8 +180,10 @@ struct NativeSearchField: NSViewRepresentable {
|
||||
}
|
||||
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard let searchField = notification.object as? NSSearchField else { return }
|
||||
field.text = searchField.stringValue
|
||||
guard let searchField = notification.object as? NSSearchField,
|
||||
let value = SingleLineFieldEditing.normalizedValue(in: searchField)
|
||||
else { return }
|
||||
field.text = value
|
||||
}
|
||||
|
||||
// MARK: Holding the keyboard
|
||||
@@ -409,7 +413,7 @@ struct WindowToolbarSearchHost: View {
|
||||
private var searchableHost: some View {
|
||||
Color.clear
|
||||
.searchable(
|
||||
text: searchText,
|
||||
text: searchText.singleLineText,
|
||||
isPresented: $isPresented,
|
||||
placement: .automatic,
|
||||
prompt: Text("Search")
|
||||
@@ -487,12 +491,12 @@ struct WindowToolbarFindField: View {
|
||||
cancel: context.cancel,
|
||||
installFocusHandler: { window.focusToolbarFindField = $0 },
|
||||
onFocusChange: { window.isToolbarFindFocused = $0 }
|
||||
)
|
||||
.frame(width: Metrics.Toolbar.searchFieldWidth)
|
||||
.help(Text(AppLocalization.string("Search", locale: locale)))
|
||||
.accessibilityLabel(
|
||||
Text(AppLocalization.string("Search", locale: locale))
|
||||
)
|
||||
).singleLinePaste()
|
||||
.frame(width: Metrics.Toolbar.searchFieldWidth)
|
||||
.help(Text(AppLocalization.string("Search", locale: locale)))
|
||||
.accessibilityLabel(
|
||||
Text(AppLocalization.string("Search", locale: locale))
|
||||
)
|
||||
} else {
|
||||
// Zero width, full height. A toolbar with nothing in it is
|
||||
// shorter than one holding a search field, and the titlebar
|
||||
|
||||
@@ -9,6 +9,29 @@ import Testing
|
||||
|
||||
@Suite
|
||||
struct ArchitectureBoundaryTests {
|
||||
@Test
|
||||
func singleLineEditingIsSharedButNativeFieldEditorsRemainAppOwned() throws {
|
||||
let root = sourceRoot.deletingLastPathComponent()
|
||||
let policy = try String(
|
||||
contentsOf: root.appending(path: "Frameworks/TermindPresentation/SingleLineTextInput.swift"),
|
||||
encoding: .utf8)
|
||||
for forbidden in ["import AppKit", "import UIKit", "import TermindPersistence", "import AgentKit"] {
|
||||
#expect(!policy.contains(forbidden))
|
||||
}
|
||||
let native = try String(
|
||||
contentsOf: sourceRoot.appending(path: "UI/Components/SingleLineFieldEditing.swift"), encoding: .utf8)
|
||||
#expect(native.contains("SingleLineTextInput.normalized"))
|
||||
#expect(native.contains("hasMarkedText()"))
|
||||
for path in [
|
||||
"TFM/Features/AI/ConversationTranscriptView.swift",
|
||||
"TFI/Features/AI/MobileAssistantComposer.swift",
|
||||
"TFI/Features/AI/MobileAssistantInteraction.swift",
|
||||
] {
|
||||
let content = try String(contentsOf: root.appending(path: path), encoding: .utf8)
|
||||
#expect(!content.contains(".singleLineText"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func aiSettingsUsePortableModelsAndSyncedSkills() throws {
|
||||
let root = sourceRoot.deletingLastPathComponent()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite("Native single-line field editing", .serialized)
|
||||
@MainActor
|
||||
struct SingleLineFieldEditingTests {
|
||||
@Test func nativeEditorKeepsTheCaretAndDefersMarkedTextNormalization() throws {
|
||||
let field = NSTextField()
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 100), styleMask: .borderless,
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentView = field
|
||||
defer { window.close() }
|
||||
#expect(window.makeFirstResponder(field))
|
||||
let editor = try #require(field.currentEditor() as? NSTextView)
|
||||
field.stringValue = "中文\r\n\nName"
|
||||
editor.string = field.stringValue
|
||||
editor.setSelectedRange(NSRange(location: 5, length: 0))
|
||||
#expect(SingleLineFieldEditing.normalizedValue(in: field) == "中文 Name")
|
||||
#expect(editor.string == "中文 Name")
|
||||
#expect(editor.selectedRange() == NSRange(location: 3, length: 0))
|
||||
#expect(window.firstResponder === editor)
|
||||
editor.setMarkedText(
|
||||
"拼音", selectedRange: NSRange(location: 2, length: 0), replacementRange: editor.selectedRange())
|
||||
#expect(editor.hasMarkedText())
|
||||
let composing = editor.string
|
||||
#expect(SingleLineFieldEditing.normalizedValue(in: field) == nil)
|
||||
#expect(editor.string == composing)
|
||||
editor.unmarkText()
|
||||
#expect(SingleLineFieldEditing.normalizedValue(in: field) == editor.string)
|
||||
}
|
||||
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func hostFormKeepsPastedAndTypedTextSingleLine(language: String) async throws {
|
||||
let draft = SingleLineHostTestDraft()
|
||||
let hosting = NSHostingView(
|
||||
rootView: SingleLineHostTestForm(draft: draft).environment(\.locale, Locale(identifier: language)))
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 400), styleMask: .borderless,
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentView = hosting
|
||||
defer { window.close() }
|
||||
hosting.layoutSubtreeIfNeeded()
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
let field = try #require(findEditableField(in: hosting, value: "fixture.invalid"))
|
||||
#expect(window.makeFirstResponder(field))
|
||||
let editor = try #require(field.currentEditor() as? NSTextView)
|
||||
editor.selectAll(nil)
|
||||
SingleLineFieldEditing.insert("first.example\r\n\nsecond.example", into: editor)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
#expect(draft.host.host == "first.example second.example")
|
||||
#expect(!field.stringValue.contains(where: { $0.isNewline }))
|
||||
#expect(window.firstResponder === editor)
|
||||
editor.selectAll(nil)
|
||||
editor.insertText("2001:db8::1", replacementRange: editor.selectedRange())
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
#expect(draft.host.host == "2001:db8::1")
|
||||
editor.doCommand(by: #selector(NSResponder.insertNewlineIgnoringFieldEditor(_:)))
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
#expect(!draft.host.host.contains(where: { $0.isNewline }))
|
||||
#expect(!field.stringValue.contains(where: { $0.isNewline }))
|
||||
let originalHeight = field.frame.height
|
||||
let longAddress = String(repeating: "long-host-", count: 80) + ".invalid"
|
||||
editor.selectAll(nil)
|
||||
SingleLineFieldEditing.insert(longAddress, into: editor)
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
#expect(draft.host.host == longAddress)
|
||||
#expect(field.frame.height == originalHeight)
|
||||
}
|
||||
|
||||
private func findEditableField(in view: NSView, value: String) -> NSTextField? {
|
||||
if let field = view as? NSTextField, field.isEditable, field.stringValue == value { return field }
|
||||
return view.subviews.lazy.compactMap { findEditableField(in: $0, value: value) }.first
|
||||
}
|
||||
|
||||
@Test func normalizesTheNativeFieldWithoutAnActiveEditor() {
|
||||
let field = NSTextField()
|
||||
field.stringValue = "中文\r\n\n名称"
|
||||
#expect(SingleLineFieldEditing.normalizedValue(in: field) == "中文 名称")
|
||||
#expect(field.stringValue == "中文 名称")
|
||||
}
|
||||
|
||||
@Test func renameCoordinatorWritesTheSameNormalizedValueAsTheField() {
|
||||
var text = "Original"
|
||||
var submitted = false
|
||||
var cancelled = false
|
||||
let coordinator = SidebarInlineRenameField.Coordinator(
|
||||
text: Binding(get: { text }, set: { text = $0 }),
|
||||
onSubmit: { submitted = true }, onCancel: { cancelled = true }
|
||||
)
|
||||
let field = NSTextField()
|
||||
field.stringValue = "New\u{2028}Name"
|
||||
coordinator.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: field))
|
||||
#expect(text == "New Name")
|
||||
#expect(field.stringValue == text)
|
||||
#expect(!submitted)
|
||||
#expect(!cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
private final class SingleLineHostTestDraft {
|
||||
var host = TermindDomain.Host(name: "Fixture", host: "fixture.invalid")
|
||||
}
|
||||
|
||||
private struct SingleLineHostTestForm: View {
|
||||
@Bindable var draft: SingleLineHostTestDraft
|
||||
|
||||
var body: some View {
|
||||
Form { HostConnectionFields(host: $draft.host) }.formStyle(.grouped)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user