Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/util/TextHelper.swift
1 views
//
// 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)
    }
}