184 lines
6.7 KiB
Swift
184 lines
6.7 KiB
Swift
//
|
|
// Copyright 2017 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
public import SignalUI
|
|
|
|
public enum TextFieldHelper {
|
|
/// Used to implement the UITextFieldDelegate method: `textField:shouldChangeCharactersInRange:replacementString`
|
|
/// Takes advantage of Swift's superior unicode handling to append partial pasted text without splitting multi-byte characters.
|
|
public static func textField(
|
|
_ textField: UITextField,
|
|
shouldChangeCharactersInRange editingRange: NSRange,
|
|
replacementString: String,
|
|
maxByteCount: Int? = nil,
|
|
maxUnicodeScalarCount: Int? = nil,
|
|
maxGlyphCount: Int? = nil,
|
|
) -> Bool {
|
|
let (shouldChange, changedString) = TextHelper.shouldChangeCharactersInRange(
|
|
with: textField.text,
|
|
editingRange: editingRange,
|
|
replacementString: replacementString,
|
|
maxByteCount: maxByteCount,
|
|
maxUnicodeScalarCount: maxUnicodeScalarCount,
|
|
maxGlyphCount: maxGlyphCount,
|
|
)
|
|
|
|
if let changedString {
|
|
owsAssertDebug(!shouldChange)
|
|
textField.text = changedString
|
|
}
|
|
|
|
return shouldChange
|
|
}
|
|
}
|
|
|
|
public enum TextViewHelper {
|
|
/// Used to implement the UITextViewDelegate method: `textView:shouldChangeTextIn:replacementText`
|
|
/// Takes advantage of Swift's superior unicode handling to append partial pasted text without splitting multi-byte characters.
|
|
public static func textView(
|
|
_ textView: UITextView,
|
|
shouldChangeTextIn range: NSRange,
|
|
replacementText: String,
|
|
maxByteCount: Int? = nil,
|
|
maxGlyphCount: Int? = nil,
|
|
) -> Bool {
|
|
let (shouldChange, changedString) = TextHelper.shouldChangeCharactersInRange(
|
|
with: textView.text,
|
|
editingRange: range,
|
|
replacementString: replacementText,
|
|
maxByteCount: maxByteCount,
|
|
maxGlyphCount: maxGlyphCount,
|
|
)
|
|
|
|
if let changedString {
|
|
owsAssertDebug(!shouldChange)
|
|
textView.text = changedString
|
|
textView.delegate?.textViewDidChange?(textView)
|
|
}
|
|
|
|
return shouldChange
|
|
}
|
|
|
|
public static func textView(
|
|
_ textView: TextViewWithPlaceholder,
|
|
shouldChangeTextIn range: NSRange,
|
|
replacementText: String,
|
|
maxByteCount: Int? = nil,
|
|
maxGlyphCount: Int? = nil,
|
|
) -> Bool {
|
|
let (shouldChange, changedString) = TextHelper.shouldChangeCharactersInRange(
|
|
with: textView.text,
|
|
editingRange: range,
|
|
replacementString: replacementText,
|
|
maxByteCount: maxByteCount,
|
|
maxGlyphCount: maxGlyphCount,
|
|
)
|
|
|
|
if let changedString {
|
|
owsAssertDebug(!shouldChange)
|
|
textView.text = changedString
|
|
}
|
|
|
|
return shouldChange
|
|
}
|
|
}
|
|
|
|
public enum TextHelper {
|
|
public static func shouldChangeCharactersInRange(
|
|
with existingString: String?,
|
|
editingRange: NSRange,
|
|
replacementString: String,
|
|
maxByteCount: Int? = nil,
|
|
maxUnicodeScalarCount: Int? = nil,
|
|
maxGlyphCount: Int? = nil,
|
|
) -> (shouldChange: Bool, changedString: String?) {
|
|
// At least one must be set.
|
|
owsAssertDebug(maxByteCount != nil || maxGlyphCount != nil || maxUnicodeScalarCount != nil)
|
|
|
|
func hasValidLength(_ string: String) -> Bool {
|
|
if let maxByteCount {
|
|
let byteCount = string.utf8.count
|
|
guard byteCount <= maxByteCount else {
|
|
return false
|
|
}
|
|
}
|
|
if let maxUnicodeScalarCount {
|
|
let unicodeScalarCount = string.unicodeScalars.count
|
|
guard unicodeScalarCount <= maxUnicodeScalarCount else {
|
|
return false
|
|
}
|
|
}
|
|
if let maxGlyphCount {
|
|
let glyphCount = string.glyphCount
|
|
guard glyphCount <= maxGlyphCount else {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
let existingString = existingString ?? ""
|
|
|
|
// Given an NSRange, we need to interact with the NS flavor of substring
|
|
|
|
// Filtering the string for display may insert some new characters. We need
|
|
// to verify that after insertion the string is still within our byte bounds.
|
|
let notFilteredForDisplay = (existingString as NSString)
|
|
.replacingCharacters(in: editingRange, with: replacementString)
|
|
let filteredForDisplay = notFilteredForDisplay.filterStringForDisplay()
|
|
|
|
if
|
|
hasValidLength(notFilteredForDisplay),
|
|
hasValidLength(filteredForDisplay)
|
|
{
|
|
|
|
// Only allow the textfield to insert the replacement
|
|
// if _both_ it's filtered and unfiltered length are
|
|
// valid.
|
|
//
|
|
// * We can't measure just the filtered length or we
|
|
// would allow unlimited whitespace to be appended
|
|
// to the end of the string.
|
|
// * We can't measure just the unfiltered length, since
|
|
// filterStringForDisplay() can increase the length
|
|
// of the string (e.g. appending Bidi characters).
|
|
// * We can't replace the textfield contents with the
|
|
// filtered string, or we would prevent users from
|
|
// (legitimately) appending whitespace to the tail of
|
|
// of the string.
|
|
return (shouldChange: true, changedString: nil)
|
|
}
|
|
|
|
// Don't allow any change if inserting a single char is already over the limit (typically this means typing)
|
|
if replacementString.count < 2 {
|
|
return (shouldChange: false, changedString: nil)
|
|
}
|
|
|
|
// However if pasting, accept as much of the string as possible.
|
|
var acceptableSubstring = ""
|
|
|
|
for char in replacementString {
|
|
var maybeAcceptableSubstring = acceptableSubstring
|
|
maybeAcceptableSubstring.append(char)
|
|
|
|
let newFilteredString = (existingString as NSString)
|
|
.replacingCharacters(in: editingRange, with: maybeAcceptableSubstring)
|
|
.filterStringForDisplay()
|
|
|
|
if hasValidLength(newFilteredString) {
|
|
acceptableSubstring = maybeAcceptableSubstring
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
let changedString = (existingString as NSString).replacingCharacters(in: editingRange, with: acceptableSubstring)
|
|
|
|
// We've already handled any valid editing manually, so prevent further changes.
|
|
return (shouldChange: false, changedString: changedString)
|
|
}
|
|
}
|