// // 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) } }