Normalize newlines in single-line fields

This commit is contained in:
Kaede Akino committed 2026-09-16 17:16:44 +08:00
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()) }
}
+8
View File
@@ -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
+2
View File
@@ -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)
}
}
+5 -2
View File
@@ -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") {
+2 -1
View File
@@ -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) }
}
}
+10 -4
View File
@@ -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)
}
+14 -11
View File
@@ -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) {
+2 -2
View File
@@ -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() }
}
+5 -4
View File
@@ -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) } }
}
+2
View File
@@ -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()
+4 -1
View File
@@ -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)
+3 -2
View File
@@ -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)
}
+8 -5
View File
@@ -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")
}
+2 -1
View File
@@ -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
+7 -5
View File
@@ -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) }
+3 -2
View File
@@ -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)
+4 -1
View File
@@ -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)
+6 -4
View File
@@ -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)
+4 -2
View File
@@ -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(
+21 -15
View File
@@ -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)
}
}
+1 -1
View File
@@ -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") {
+17 -14
View File
@@ -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(
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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 {
+3 -2
View File
@@ -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)
+6 -3
View File
@@ -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
}
}
+13 -9
View File
@@ -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
+23
View File
@@ -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()
+120
View File
@@ -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)
}
}