From 581300362a4b44891d62d44f3a614f1ddae8a3dc Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 16 Sep 2026 17:16:44 +0800 Subject: [PATCH] Normalize newlines in single-line fields --- FrameworkTests/SingleLineTextInputTests.swift | 56 ++++++++ .../ConnectionDecisionForms.swift | 3 +- .../HostConnectionFields.swift | 17 ++- .../ModelProviderConnectionFields.swift | 8 +- .../PortForwardingFields.swift | 17 ++- .../SingleLineTextInput.swift | 104 +++++++++++++++ Memory/ARCHITECTURE.md | 8 ++ TFI/App/MobileRootView.swift | 2 + .../Assistant/MobileAssistantView.swift | 9 +- .../HostChooser/MobileConnectionPicker.swift | 2 +- .../HostEditor/MobileHostEditor.swift | 10 +- .../Keychain/MobileCredentialsView.swift | 10 +- TFI/Features/Keychain/MobileKeyEditor.swift | 7 +- .../KnownHosts/MobileKnownHostsView.swift | 19 +-- .../SFTP/MobileRemoteFileEditor.swift | 11 +- TFI/Features/SFTP/MobileSFTPBrowser.swift | 3 +- TFI/Features/SFTP/MobileSFTPHistoryView.swift | 3 +- .../Settings/MobileAISettingsView.swift | 14 +- TFI/Features/Settings/MobileMCPSettings.swift | 14 +- TFI/Features/Settings/MobileModelFields.swift | 25 ++-- .../Settings/MobileSkillSettings.swift | 4 +- .../Snippets/MobileSnippetsView.swift | 12 +- .../Terminal/MobileCommandHistoryView.swift | 2 +- .../Terminal/MobileTerminalSnippetsView.swift | 3 +- TFI/Features/Vault/MobileVaultView.swift | 4 +- .../MobileWorkspaceRenameView.swift | 6 +- .../Workspaces/MobileWorkspaceView.swift | 2 +- .../MobileSingleLineFieldInput.swift | 97 ++++++++++++++ TFI/UI/Components/MobileToolbarSearch.swift | 9 +- .../MobileSingleLineFieldEditingTests.swift | 66 ++++++++++ TFM/App/RootView.swift | 2 + TFM/Design/TagField.swift | 5 +- TFM/Features/Assistant/AssistantView.swift | 5 +- TFM/Features/HostEditor/HostEditorView.swift | 13 +- TFM/Features/Hosts/NewGroupSheet.swift | 3 +- TFM/Features/Keychain/KeychainPageView.swift | 12 +- TFM/Features/Keychain/NewIdentitySheet.swift | 5 +- .../KnownHosts/KnownHostsPageView.swift | 10 +- TFM/Features/QuickOpen/QuickOpenView.swift | 5 +- TFM/Features/SFTP/SFTPBrowserView.swift | 10 +- TFM/Features/SSHImport/SSHImportView.swift | 6 +- .../Settings/MCPServerEditorSheet.swift | 15 ++- TFM/Features/Settings/MCPServerSettings.swift | 11 +- TFM/Features/Settings/ModelListEditor.swift | 36 +++--- TFM/Features/Settings/SkillEditorSheet.swift | 2 +- TFM/Features/Sidebar/WindowSidebarView.swift | 31 +++-- TFM/Features/Snippets/SnippetsPageView.swift | 5 +- TFM/Features/Terminal/SerialControls.swift | 3 +- .../Workspaces/WorkspaceContentView.swift | 18 +-- .../Workspaces/WorkspaceNavigatorView.swift | 5 +- .../Workspaces/WorkspaceTemplateSupport.swift | 4 +- TFM/UI/Components/ChromeComponents.swift | 5 +- TFM/UI/Components/SidebarRow.swift | 9 +- .../Components/SingleLineFieldEditing.swift | 38 ++++++ TFM/UI/Components/WindowToolbar.swift | 22 ++-- TFMTests/ArchitectureBoundaryTests.swift | 23 ++++ TFMTests/SingleLineFieldEditingTests.swift | 120 ++++++++++++++++++ 57 files changed, 802 insertions(+), 168 deletions(-) create mode 100644 FrameworkTests/SingleLineTextInputTests.swift create mode 100644 Frameworks/TermindPresentation/SingleLineTextInput.swift create mode 100644 TFI/UI/Components/MobileSingleLineFieldInput.swift create mode 100644 TFITests/MobileSingleLineFieldEditingTests.swift create mode 100644 TFM/UI/Components/SingleLineFieldEditing.swift create mode 100644 TFMTests/SingleLineFieldEditingTests.swift diff --git a/FrameworkTests/SingleLineTextInputTests.swift b/FrameworkTests/SingleLineTextInputTests.swift new file mode 100644 index 00000000..503fc3c7 --- /dev/null +++ b/FrameworkTests/SingleLineTextInputTests.swift @@ -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( + 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") + } +} diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift index f510b01f..589c901b 100644 --- a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -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 { diff --git a/Frameworks/TermindPresentation/HostConnectionFields.swift b/Frameworks/TermindPresentation/HostConnectionFields.swift index 281ce4eb..3b43ee02 100644 --- a/Frameworks/TermindPresentation/HostConnectionFields.swift +++ b/Frameworks/TermindPresentation/HostConnectionFields.swift @@ -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) diff --git a/Frameworks/TermindPresentation/ModelProviderConnectionFields.swift b/Frameworks/TermindPresentation/ModelProviderConnectionFields.swift index 87d60c9a..61a77868 100644 --- a/Frameworks/TermindPresentation/ModelProviderConnectionFields.swift +++ b/Frameworks/TermindPresentation/ModelProviderConnectionFields.swift @@ -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 { diff --git a/Frameworks/TermindPresentation/PortForwardingFields.swift b/Frameworks/TermindPresentation/PortForwardingFields.swift index 9a23e252..336589e2 100644 --- a/Frameworks/TermindPresentation/PortForwardingFields.swift +++ b/Frameworks/TermindPresentation/PortForwardingFields.swift @@ -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 diff --git a/Frameworks/TermindPresentation/SingleLineTextInput.swift b/Frameworks/TermindPresentation/SingleLineTextInput.swift new file mode 100644 index 00000000..57c2e01f --- /dev/null +++ b/Frameworks/TermindPresentation/SingleLineTextInput.swift @@ -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 { + 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()) } +} diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index c5e7f8c5..7c790f30 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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 diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 9aa3cac8..a0abbf83 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -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) } diff --git a/TFI/Features/Assistant/MobileAssistantView.swift b/TFI/Features/Assistant/MobileAssistantView.swift index e6704440..640c099e 100644 --- a/TFI/Features/Assistant/MobileAssistantView.swift +++ b/TFI/Features/Assistant/MobileAssistantView.swift @@ -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) } } diff --git a/TFI/Features/HostChooser/MobileConnectionPicker.swift b/TFI/Features/HostChooser/MobileConnectionPicker.swift index 8ce6e92c..1af5541c 100644 --- a/TFI/Features/HostChooser/MobileConnectionPicker.swift +++ b/TFI/Features/HostChooser/MobileConnectionPicker.swift @@ -170,7 +170,7 @@ struct MobileHostPicker: View { } } } - .searchable(text: $model.query, prompt: "Search hosts") + .searchable(text: $model.query.singleLineText, prompt: "Search hosts") .navigationTitle(Text(title)) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index f0e522ba..ac14753c 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -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) diff --git a/TFI/Features/Keychain/MobileCredentialsView.swift b/TFI/Features/Keychain/MobileCredentialsView.swift index 04d99bfe..b606fac1 100644 --- a/TFI/Features/Keychain/MobileCredentialsView.swift +++ b/TFI/Features/Keychain/MobileCredentialsView.swift @@ -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) } } diff --git a/TFI/Features/Keychain/MobileKeyEditor.swift b/TFI/Features/Keychain/MobileKeyEditor.swift index f673f9f2..47380172 100644 --- a/TFI/Features/Keychain/MobileKeyEditor.swift +++ b/TFI/Features/Keychain/MobileKeyEditor.swift @@ -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 { diff --git a/TFI/Features/KnownHosts/MobileKnownHostsView.swift b/TFI/Features/KnownHosts/MobileKnownHostsView.swift index 0285038f..806a6a2a 100644 --- a/TFI/Features/KnownHosts/MobileKnownHostsView.swift +++ b/TFI/Features/KnownHosts/MobileKnownHostsView.swift @@ -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 { diff --git a/TFI/Features/SFTP/MobileRemoteFileEditor.swift b/TFI/Features/SFTP/MobileRemoteFileEditor.swift index c8d50bdb..4d00ecee 100644 --- a/TFI/Features/SFTP/MobileRemoteFileEditor.swift +++ b/TFI/Features/SFTP/MobileRemoteFileEditor.swift @@ -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") { diff --git a/TFI/Features/SFTP/MobileSFTPBrowser.swift b/TFI/Features/SFTP/MobileSFTPBrowser.swift index c036023e..e9318904 100644 --- a/TFI/Features/SFTP/MobileSFTPBrowser.swift +++ b/TFI/Features/SFTP/MobileSFTPBrowser.swift @@ -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() diff --git a/TFI/Features/SFTP/MobileSFTPHistoryView.swift b/TFI/Features/SFTP/MobileSFTPHistoryView.swift index 74f6366d..4f03be4a 100644 --- a/TFI/Features/SFTP/MobileSFTPHistoryView.swift +++ b/TFI/Features/SFTP/MobileSFTPHistoryView.swift @@ -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) { diff --git a/TFI/Features/Settings/MobileAISettingsView.swift b/TFI/Features/Settings/MobileAISettingsView.swift index d7552a92..e65e82a6 100644 --- a/TFI/Features/Settings/MobileAISettingsView.swift +++ b/TFI/Features/Settings/MobileAISettingsView.swift @@ -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) } } } diff --git a/TFI/Features/Settings/MobileMCPSettings.swift b/TFI/Features/Settings/MobileMCPSettings.swift index e662284f..47bd2fa8 100644 --- a/TFI/Features/Settings/MobileMCPSettings.swift +++ b/TFI/Features/Settings/MobileMCPSettings.swift @@ -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) } diff --git a/TFI/Features/Settings/MobileModelFields.swift b/TFI/Features/Settings/MobileModelFields.swift index 47ee0bab..0964abc9 100644 --- a/TFI/Features/Settings/MobileModelFields.swift +++ b/TFI/Features/Settings/MobileModelFields.swift @@ -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")) } } diff --git a/TFI/Features/Settings/MobileSkillSettings.swift b/TFI/Features/Settings/MobileSkillSettings.swift index e7a0d509..eadc49ce 100644 --- a/TFI/Features/Settings/MobileSkillSettings.swift +++ b/TFI/Features/Settings/MobileSkillSettings.swift @@ -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( diff --git a/TFI/Features/Snippets/MobileSnippetsView.swift b/TFI/Features/Snippets/MobileSnippetsView.swift index 2180765b..7f3d9bb6 100644 --- a/TFI/Features/Snippets/MobileSnippetsView.swift +++ b/TFI/Features/Snippets/MobileSnippetsView.swift @@ -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 { diff --git a/TFI/Features/Terminal/MobileCommandHistoryView.swift b/TFI/Features/Terminal/MobileCommandHistoryView.swift index 4926c6cb..b90019e0 100644 --- a/TFI/Features/Terminal/MobileCommandHistoryView.swift +++ b/TFI/Features/Terminal/MobileCommandHistoryView.swift @@ -63,7 +63,7 @@ struct MobileCommandHistoryView: View { .background(.bar) } } - .searchable(text: $query) + .searchable(text: $query.singleLineText) .navigationTitle("History") .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/TFI/Features/Terminal/MobileTerminalSnippetsView.swift b/TFI/Features/Terminal/MobileTerminalSnippetsView.swift index 3dd4a6b7..e264769f 100644 --- a/TFI/Features/Terminal/MobileTerminalSnippetsView.swift +++ b/TFI/Features/Terminal/MobileTerminalSnippetsView.swift @@ -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) { diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 069dcbbf..14c3c28b 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -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 } }), diff --git a/TFI/Features/Workspaces/MobileWorkspaceRenameView.swift b/TFI/Features/Workspaces/MobileWorkspaceRenameView.swift index 16b154d0..c12f5157 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceRenameView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceRenameView.swift @@ -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) + } ) } diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 8597a4e1..8f34a47a 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -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) diff --git a/TFI/UI/Components/MobileSingleLineFieldInput.swift b/TFI/UI/Components/MobileSingleLineFieldInput.swift new file mode 100644 index 00000000..2451ed52 --- /dev/null +++ b/TFI/UI/Components/MobileSingleLineFieldInput.swift @@ -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() } +} diff --git a/TFI/UI/Components/MobileToolbarSearch.swift b/TFI/UI/Components/MobileToolbarSearch.swift index e7e5320e..daee0552 100644 --- a/TFI/UI/Components/MobileToolbarSearch.swift +++ b/TFI/UI/Components/MobileToolbarSearch.swift @@ -1,13 +1,14 @@ import SwiftUI +import TermindPresentation extension View { @ViewBuilder func mobileToolbarSearch(text: Binding, 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) } } } diff --git a/TFITests/MobileSingleLineFieldEditingTests.swift b/TFITests/MobileSingleLineFieldEditingTests.swift new file mode 100644 index 00000000..c2950b85 --- /dev/null +++ b/TFITests/MobileSingleLineFieldEditingTests.swift @@ -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) } } +} diff --git a/TFM/App/RootView.swift b/TFM/App/RootView.swift index 0b5a5dc0..5260bf33 100644 --- a/TFM/App/RootView.swift +++ b/TFM/App/RootView.swift @@ -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() diff --git a/TFM/Design/TagField.swift b/TFM/Design/TagField.swift index 9a23aa3d..996c6102 100644 --- a/TFM/Design/TagField.swift +++ b/TFM/Design/TagField.swift @@ -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) diff --git a/TFM/Features/Assistant/AssistantView.swift b/TFM/Features/Assistant/AssistantView.swift index 2a4c761a..19cafc38 100644 --- a/TFM/Features/Assistant/AssistantView.swift +++ b/TFM/Features/Assistant/AssistantView.swift @@ -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) } diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index dfa80617..339a259b 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -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.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") } diff --git a/TFM/Features/Hosts/NewGroupSheet.swift b/TFM/Features/Hosts/NewGroupSheet.swift index 0bed9771..dc2b72e5 100644 --- a/TFM/Features/Hosts/NewGroupSheet.swift +++ b/TFM/Features/Hosts/NewGroupSheet.swift @@ -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 diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index 1fe40142..9cfc2e6d 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -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) } diff --git a/TFM/Features/Keychain/NewIdentitySheet.swift b/TFM/Features/Keychain/NewIdentitySheet.swift index 2157d461..8e57de42 100644 --- a/TFM/Features/Keychain/NewIdentitySheet.swift +++ b/TFM/Features/Keychain/NewIdentitySheet.swift @@ -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, diff --git a/TFM/Features/KnownHosts/KnownHostsPageView.swift b/TFM/Features/KnownHosts/KnownHostsPageView.swift index 53504f83..6578875c 100644 --- a/TFM/Features/KnownHosts/KnownHostsPageView.swift +++ b/TFM/Features/KnownHosts/KnownHostsPageView.swift @@ -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) diff --git a/TFM/Features/QuickOpen/QuickOpenView.swift b/TFM/Features/QuickOpen/QuickOpenView.swift index 71aa3959..b99cb157 100644 --- a/TFM/Features/QuickOpen/QuickOpenView.swift +++ b/TFM/Features/QuickOpen/QuickOpenView.swift @@ -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) diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift index ec083564..0c90c17d 100644 --- a/TFM/Features/SFTP/SFTPBrowserView.swift +++ b/TFM/Features/SFTP/SFTPBrowserView.swift @@ -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) diff --git a/TFM/Features/SSHImport/SSHImportView.swift b/TFM/Features/SSHImport/SSHImportView.swift index 53e1df30..42edd82d 100644 --- a/TFM/Features/SSHImport/SSHImportView.swift +++ b/TFM/Features/SSHImport/SSHImportView.swift @@ -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( diff --git a/TFM/Features/Settings/MCPServerEditorSheet.swift b/TFM/Features/Settings/MCPServerEditorSheet.swift index 4d517806..6bac095f 100644 --- a/TFM/Features/Settings/MCPServerEditorSheet.swift +++ b/TFM/Features/Settings/MCPServerEditorSheet.swift @@ -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: { diff --git a/TFM/Features/Settings/MCPServerSettings.swift b/TFM/Features/Settings/MCPServerSettings.swift index 64152d52..b2580b24 100644 --- a/TFM/Features/Settings/MCPServerSettings.swift +++ b/TFM/Features/Settings/MCPServerSettings.swift @@ -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( diff --git a/TFM/Features/Settings/ModelListEditor.swift b/TFM/Features/Settings/ModelListEditor.swift index 1a176090..31914e50 100644 --- a/TFM/Features/Settings/ModelListEditor.swift +++ b/TFM/Features/Settings/ModelListEditor.swift @@ -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) } } diff --git a/TFM/Features/Settings/SkillEditorSheet.swift b/TFM/Features/Settings/SkillEditorSheet.swift index 73cfea4d..70467b6f 100644 --- a/TFM/Features/Settings/SkillEditorSheet.swift +++ b/TFM/Features/Settings/SkillEditorSheet.swift @@ -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") { diff --git a/TFM/Features/Sidebar/WindowSidebarView.swift b/TFM/Features/Sidebar/WindowSidebarView.swift index e4c8281f..93f100ac 100644 --- a/TFM/Features/Sidebar/WindowSidebarView.swift +++ b/TFM/Features/Sidebar/WindowSidebarView.swift @@ -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( diff --git a/TFM/Features/Snippets/SnippetsPageView.swift b/TFM/Features/Snippets/SnippetsPageView.swift index cc5c1a91..19e560ea 100644 --- a/TFM/Features/Snippets/SnippetsPageView.swift +++ b/TFM/Features/Snippets/SnippetsPageView.swift @@ -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 diff --git a/TFM/Features/Terminal/SerialControls.swift b/TFM/Features/Terminal/SerialControls.swift index 817fc376..f53adfe8 100644 --- a/TFM/Features/Terminal/SerialControls.swift +++ b/TFM/Features/Terminal/SerialControls.swift @@ -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) diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index 8ecf9811..282e3e21 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -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) diff --git a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift index b0489aa7..b38cb5ab 100644 --- a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift @@ -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)) diff --git a/TFM/Features/Workspaces/WorkspaceTemplateSupport.swift b/TFM/Features/Workspaces/WorkspaceTemplateSupport.swift index 1a6845a3..c123fb61 100644 --- a/TFM/Features/Workspaces/WorkspaceTemplateSupport.swift +++ b/TFM/Features/Workspaces/WorkspaceTemplateSupport.swift @@ -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 { diff --git a/TFM/UI/Components/ChromeComponents.swift b/TFM/UI/Components/ChromeComponents.swift index 42846118..8ce1419f 100644 --- a/TFM/UI/Components/ChromeComponents.swift +++ b/TFM/UI/Components/ChromeComponents.swift @@ -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) diff --git a/TFM/UI/Components/SidebarRow.swift b/TFM/UI/Components/SidebarRow.swift index 6ffd7c19..273b05b6 100644 --- a/TFM/UI/Components/SidebarRow.swift +++ b/TFM/UI/Components/SidebarRow.swift @@ -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) diff --git a/TFM/UI/Components/SingleLineFieldEditing.swift b/TFM/UI/Components/SingleLineFieldEditing.swift new file mode 100644 index 00000000..e346f197 --- /dev/null +++ b/TFM/UI/Components/SingleLineFieldEditing.swift @@ -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 + } +} diff --git a/TFM/UI/Components/WindowToolbar.swift b/TFM/UI/Components/WindowToolbar.swift index c0f7a0de..dae6a939 100644 --- a/TFM/UI/Components/WindowToolbar.swift +++ b/TFM/UI/Components/WindowToolbar.swift @@ -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 diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index ea17d431..2ae17c4e 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -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() diff --git a/TFMTests/SingleLineFieldEditingTests.swift b/TFMTests/SingleLineFieldEditingTests.swift new file mode 100644 index 00000000..a44c1370 --- /dev/null +++ b/TFMTests/SingleLineFieldEditingTests.swift @@ -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) + } +}