mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-08-24 13:23:08 +09:00
280 lines
11 KiB
Swift
280 lines
11 KiB
Swift
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
// This code is loosely based on https://github.com/Antol/APAutocompleteTextField
|
|
|
|
import UIKit
|
|
import Shared
|
|
|
|
/// Delegate for the text field events. Since AutocompleteTextField owns the UITextFieldDelegate,
|
|
/// callers must use this instead.
|
|
protocol AutocompleteTextFieldDelegate: class {
|
|
func autocompleteTextField(_ autocompleteTextField: AutocompleteTextField, didEnterText text: String)
|
|
func autocompleteTextFieldShouldReturn(_ autocompleteTextField: AutocompleteTextField) -> Bool
|
|
func autocompleteTextFieldShouldClear(_ autocompleteTextField: AutocompleteTextField) -> Bool
|
|
func autocompleteTextFieldDidBeginEditing(_ autocompleteTextField: AutocompleteTextField)
|
|
}
|
|
|
|
struct AutocompleteTextFieldUX {
|
|
static let HighlightColor = UIColor(rgb: 0xccdded)
|
|
}
|
|
|
|
class AutocompleteTextField: UITextField, UITextFieldDelegate {
|
|
var autocompleteDelegate: AutocompleteTextFieldDelegate?
|
|
|
|
// AutocompleteTextLabel repersents the actual autocomplete text.
|
|
// The textfields "text" property only contains the entered text, while this label holds the autocomplete text
|
|
// This makes sure that the autocomplete doesnt mess with keyboard suggestions provided by third party keyboards.
|
|
private var autocompleteTextLabel: UILabel?
|
|
private var hideCursor: Bool = false
|
|
|
|
var isSelectionActive: Bool {
|
|
return autocompleteTextLabel != nil
|
|
}
|
|
|
|
// This variable is a solution to get the right behavior for refocusing
|
|
// the AutocompleteTextField. The initial transition into Overlay Mode
|
|
// doesn't involve the user interacting with AutocompleteTextField.
|
|
// Thus, we update shouldApplyCompletion in touchesBegin() to reflect whether
|
|
// the highlight is active and then the text field is updated accordingly
|
|
// in touchesEnd() (eg. applyCompletion() is called or not)
|
|
fileprivate var notifyTextChanged: (() -> Void)?
|
|
private var lastReplacement: String?
|
|
|
|
var highlightColor = AutocompleteTextFieldUX.HighlightColor
|
|
|
|
override var text: String? {
|
|
didSet {
|
|
super.text = text
|
|
self.textDidChange(self)
|
|
}
|
|
}
|
|
|
|
override var accessibilityValue: String? {
|
|
get {
|
|
return (self.text ?? "") + (self.autocompleteTextLabel?.text ?? "")
|
|
}
|
|
set(value) {
|
|
super.accessibilityValue = value
|
|
}
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
commonInit()
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
super.init(coder: aDecoder)
|
|
commonInit()
|
|
}
|
|
|
|
fileprivate func commonInit() {
|
|
super.delegate = self
|
|
super.addTarget(self, action: #selector(AutocompleteTextField.textDidChange(_:)), for: UIControlEvents.editingChanged)
|
|
notifyTextChanged = debounce(0.1, action: {
|
|
if self.isEditing {
|
|
self.autocompleteDelegate?.autocompleteTextField(self, didEnterText: self.normalizeString(self.text ?? ""))
|
|
}
|
|
})
|
|
}
|
|
|
|
override var keyCommands: [UIKeyCommand]? {
|
|
return [
|
|
UIKeyCommand(input: UIKeyInputLeftArrow, modifierFlags: .init(rawValue: 0), action: #selector(self.handleKeyCommand(sender:))),
|
|
UIKeyCommand(input: UIKeyInputRightArrow, modifierFlags: .init(rawValue: 0), action: #selector(self.handleKeyCommand(sender:)))
|
|
]
|
|
}
|
|
|
|
func handleKeyCommand(sender: UIKeyCommand) {
|
|
switch sender.input {
|
|
case UIKeyInputLeftArrow:
|
|
if isSelectionActive {
|
|
applyCompletion()
|
|
|
|
// Set the current position to the beginning of the text.
|
|
selectedTextRange = textRange(from: beginningOfDocument, to: beginningOfDocument)
|
|
} else if let range = selectedTextRange {
|
|
if range.start == beginningOfDocument {
|
|
return
|
|
}
|
|
|
|
guard let cursorPosition = position(from: range.start, offset: -1) else {
|
|
return
|
|
}
|
|
|
|
selectedTextRange = textRange(from: cursorPosition, to: cursorPosition)
|
|
}
|
|
return
|
|
case UIKeyInputRightArrow:
|
|
if isSelectionActive {
|
|
applyCompletion()
|
|
|
|
// Set the current position to the end of the text.
|
|
selectedTextRange = textRange(from: endOfDocument, to: endOfDocument)
|
|
} else if let range = selectedTextRange {
|
|
if range.end == endOfDocument {
|
|
return
|
|
}
|
|
|
|
guard let cursorPosition = position(from: range.end, offset: 1) else {
|
|
return
|
|
}
|
|
|
|
selectedTextRange = textRange(from: cursorPosition, to: cursorPosition)
|
|
}
|
|
return
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
func highlightAll() {
|
|
let text = self.text
|
|
self.text = ""
|
|
setAutocompleteSuggestion(text ?? "")
|
|
selectedTextRange = textRange(from: endOfDocument, to: endOfDocument)
|
|
}
|
|
|
|
fileprivate func normalizeString(_ string: String) -> String {
|
|
return string.lowercased().stringByTrimmingLeadingCharactersInSet(CharacterSet.whitespaces)
|
|
}
|
|
|
|
/// Commits the completion by setting the text and removing the highlight.
|
|
fileprivate func applyCompletion() {
|
|
|
|
// Clear the current completion, then set the text without the attributed style.
|
|
let text = (self.text ?? "") + (self.autocompleteTextLabel?.text ?? "")
|
|
removeCompletion()
|
|
self.text = text
|
|
hideCursor = false
|
|
// Move the cursor to the end of the completion.
|
|
selectedTextRange = textRange(from: endOfDocument, to: endOfDocument)
|
|
}
|
|
|
|
/// Removes the autocomplete-highlighted
|
|
fileprivate func removeCompletion() {
|
|
autocompleteTextLabel?.removeFromSuperview()
|
|
autocompleteTextLabel = nil
|
|
}
|
|
|
|
// `shouldChangeCharactersInRange` is called before the text changes, and textDidChange is called after.
|
|
// Since the text has changed, remove the completion here, and textDidChange will fire the callback to
|
|
// get the new autocompletion.
|
|
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
|
|
lastReplacement = string
|
|
return true
|
|
}
|
|
|
|
func setAutocompleteSuggestion(_ suggestion: String?) {
|
|
let text = self.text ?? ""
|
|
|
|
guard let suggestion = suggestion, isEditing && markedTextRange == nil else {
|
|
hideCursor = false
|
|
return
|
|
}
|
|
|
|
let normalized = normalizeString(text)
|
|
guard suggestion.startsWith(normalized) && normalized.characters.count < suggestion.characters.count else {
|
|
hideCursor = false
|
|
return
|
|
}
|
|
|
|
let suggestionText = suggestion.substring(from: suggestion.characters.index(suggestion.startIndex, offsetBy: normalized.characters.count))
|
|
let autocompleteText = NSMutableAttributedString(string: suggestionText)
|
|
autocompleteText.addAttribute(NSBackgroundColorAttributeName, value: highlightColor, range: NSRange(location: 0, length: suggestionText.characters.count))
|
|
autocompleteTextLabel?.removeFromSuperview() // should be nil. But just in case
|
|
autocompleteTextLabel = createAutocompleteLabelWith(autocompleteText)
|
|
if let l = autocompleteTextLabel {
|
|
addSubview(l)
|
|
hideCursor = true
|
|
forceResetCursor()
|
|
}
|
|
}
|
|
|
|
override func caretRect(for position: UITextPosition) -> CGRect {
|
|
return hideCursor ? CGRect.zero : super.caretRect(for: position)
|
|
}
|
|
|
|
private func createAutocompleteLabelWith(_ autocompleteText: NSAttributedString) -> UILabel {
|
|
let label = UILabel()
|
|
var frame = self.bounds
|
|
label.attributedText = autocompleteText
|
|
label.font = self.font
|
|
label.accessibilityIdentifier = "autocomplete"
|
|
label.backgroundColor = self.backgroundColor
|
|
label.textColor = self.textColor
|
|
|
|
let enteredTextSize = self.attributedText?.boundingRect(with: self.frame.size, options: NSStringDrawingOptions.usesLineFragmentOrigin, context: nil)
|
|
frame.origin.x = (enteredTextSize?.width.rounded() ?? 0)
|
|
frame.size.width = self.frame.size.width - frame.origin.x
|
|
frame.size.height = self.frame.size.height - 1
|
|
label.frame = frame
|
|
return label
|
|
}
|
|
|
|
func textFieldDidBeginEditing(_ textField: UITextField) {
|
|
autocompleteDelegate?.autocompleteTextFieldDidBeginEditing(self)
|
|
}
|
|
|
|
func textFieldShouldEndEditing(_ textField: UITextField) -> Bool {
|
|
applyCompletion()
|
|
return true
|
|
}
|
|
|
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
|
applyCompletion()
|
|
return autocompleteDelegate?.autocompleteTextFieldShouldReturn(self) ?? true
|
|
}
|
|
|
|
func textFieldShouldClear(_ textField: UITextField) -> Bool {
|
|
removeCompletion()
|
|
return autocompleteDelegate?.autocompleteTextFieldShouldClear(self) ?? true
|
|
}
|
|
|
|
override func setMarkedText(_ markedText: String?, selectedRange: NSRange) {
|
|
// Clear the autocompletion if any provisionally inserted text has been
|
|
// entered (e.g., a partial composition from a Japanese keyboard).
|
|
removeCompletion()
|
|
super.setMarkedText(markedText, selectedRange: selectedRange)
|
|
}
|
|
|
|
func textDidChange(_ textField: UITextField) {
|
|
hideCursor = autocompleteTextLabel != nil
|
|
removeCompletion()
|
|
|
|
let isAtEnd = selectedTextRange?.start == endOfDocument
|
|
let isEmpty = lastReplacement?.isEmpty ?? true
|
|
if !isEmpty, isAtEnd, markedTextRange == nil {
|
|
notifyTextChanged?()
|
|
} else {
|
|
hideCursor = false
|
|
}
|
|
}
|
|
|
|
// Reset the cursor to the end of the text field.
|
|
// This forces `caretRect(for position: UITextPosition)` to be called which will decide if we should show the cursor
|
|
// This exists because ` caretRect(for position: UITextPosition)` is not called after we apply an autocompletion.
|
|
private func forceResetCursor() {
|
|
selectedTextRange = nil
|
|
selectedTextRange = textRange(from: endOfDocument, to: endOfDocument)
|
|
}
|
|
|
|
override func deleteBackward() {
|
|
lastReplacement = nil
|
|
hideCursor = false
|
|
if isSelectionActive {
|
|
removeCompletion()
|
|
forceResetCursor()
|
|
} else {
|
|
super.deleteBackward()
|
|
}
|
|
}
|
|
|
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
applyCompletion()
|
|
super.touchesBegan(touches, with: event)
|
|
}
|
|
|
|
}
|