107 lines
3.9 KiB
Swift
107 lines
3.9 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
/// 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()) }
|
|
}
|