// // UITextInput+Autocomplete.swift // ComposeUI // // Created by Shadowfacts on 3/5/23. // import UIKit import SwiftUI extension UITextInput { func autocomplete(with string: String, permittedModes: AutocompleteModes, autocompleteState: inout AutocompleteState?) { guard let selectedTextRange, let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument), let text = self.text(in: wholeDocumentRange), let (lastWordStartIndex, _) = findAutocompleteLastWord() else { return } let distanceToEnd = self.offset(from: selectedTextRange.start, to: self.endOfDocument) let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start) let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) let insertSpace: Bool if distanceToEnd > 0 { let charAfterCursor = text[characterBeforeCursorIndex] insertSpace = charAfterCursor != " " && charAfterCursor != "\n" } else { insertSpace = true } let string = insertSpace ? string + " " : string let startPosition = self.position(from: self.beginningOfDocument, offset: text.utf16.distance(from: text.startIndex, to: lastWordStartIndex))! let lastWordRange = self.textRange(from: startPosition, to: selectedTextRange.start)! replace(lastWordRange, withText: string) autocompleteState = updateAutocompleteState(permittedModes: permittedModes) // keep the cursor at the same position in the text, immediately after what was inserted // if we inserted a space, move the cursor 1 farther so it's immediately after the pre-existing space let insertSpaceOffset = insertSpace ? 0 : 1 let newCursorPosition = self.position(from: self.endOfDocument, offset: -distanceToEnd + insertSpaceOffset)! self.selectedTextRange = self.textRange(from: newCursorPosition, to: newCursorPosition) } func updateAutocompleteState(permittedModes: AutocompleteModes) -> AutocompleteState? { guard let selectedTextRange, let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument), let text = self.text(in: wholeDocumentRange), !text.isEmpty, let (lastWordStartIndex, foundFirstAtSign) = findAutocompleteLastWord() else { return nil } let triggerChars = permittedModes.triggerChars if lastWordStartIndex > text.startIndex { // if the character before the "word" beginning is a valid part of a "word", // we aren't able to autocomplete let c = text[text.index(before: lastWordStartIndex)] if isPermittedForAutocomplete(c) || triggerChars.contains(c) { return nil } } let characterBeforeCursorIndex = text.utf16.index(text.startIndex, offsetBy: self.offset(from: self.beginningOfDocument, to: selectedTextRange.start)) if lastWordStartIndex >= text.startIndex { let lastWord = text[lastWordStartIndex.. (index: String.Index, foundFirstAtSign: Bool)? { guard (self as? UIView)?.isFirstResponder == true, let selectedTextRange, selectedTextRange.isEmpty, let wholeDocumentRange = self.textRange(from: self.beginningOfDocument, to: self.endOfDocument), let text = self.text(in: wholeDocumentRange), !text.isEmpty else { return nil } let selectedRangeStartUTF16 = self.offset(from: self.beginningOfDocument, to: selectedTextRange.start) let cursorIndex = text.utf16.index(text.startIndex, offsetBy: selectedRangeStartUTF16) guard cursorIndex != text.startIndex else { return nil } var lastWordStartIndex = text.index(before: cursorIndex) var foundFirstAtSign = false while true { let c = text[lastWordStartIndex] if !isPermittedForAutocomplete(c) { if foundFirstAtSign { if c != "@" { // move the index forward by 1, so that the first char of the substring is the 1st @ instead of whatever comes before it lastWordStartIndex = text.index(after: lastWordStartIndex) } break } else { if c == "@" { foundFirstAtSign = true } else if c != "." { // periods are allowed for domain names in mentions break } } } guard lastWordStartIndex > text.startIndex else { break } lastWordStartIndex = text.index(before: lastWordStartIndex) } return (lastWordStartIndex, foundFirstAtSign) } } enum AutocompleteState: Equatable { case mention(String) case emoji(String) case hashtag(String) } struct AutocompleteModes: OptionSet { static let mentions = AutocompleteModes(rawValue: 1 << 0) static let hashtags = AutocompleteModes(rawValue: 1 << 2) static let emojis = AutocompleteModes(rawValue: 1 << 3) static let all: AutocompleteModes = [ .mentions, .hashtags, .emojis, ] let rawValue: Int var triggerChars: [Character] { var chars: [Character] = [] if contains(.mentions) { chars.append("@") } if contains(.hashtags) { chars.append("#") } if contains(.emojis) { chars.append(":") } return chars } } private func isPermittedForAutocomplete(_ c: Character) -> Bool { return (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "_" }