Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 175 additions & 132 deletions Xcodes/Frontend/SignIn/PinCodeTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,9 @@ struct PinCodeTextField: NSViewRepresentable {
view.codeDidComplete = { complete($0) }
return view
}

func updateNSView(_ nsView: NSViewType, context: Context) {
nsView.code = (0..<numberOfDigits).map { index in
if index < code.count {
let codeIndex = code.index(code.startIndex, offsetBy: index)
return code[codeIndex]
} else {
return nil
}
}
nsView.setCode(code)
}
}

Expand All @@ -46,31 +39,22 @@ struct PinCodeTextField_Previews: PreviewProvider {

// MARK: - PinCodeTextView

class PinCodeTextView: NSControl, NSTextFieldDelegate {
var code: [Character?] = [] {
didSet {
guard code != oldValue else { return }

if let handler = codeDidChange {
handler(String(code.compactMap { $0 }))
}
updateText()

if code.compactMap({ $0 }).count == numberOfDigits,
let handler = codeDidComplete {
handler(String(code.compactMap { $0 }))
}
}
}
/// A single hidden text field receives all input (so pasting and macOS one-time-code
/// autofill insert the whole code at once) while the per-digit boxes are display-only
class PinCodeTextView: NSControl, NSTextFieldDelegate {
var codeDidChange: ((String) -> Void)? = nil
var codeDidComplete: ((String) -> Void)? = nil

private(set) var currentCode = ""

private let numberOfDigits: Int
private let stackView: NSStackView = .init(frame: .zero)
private var characterViews: [PinCodeCharacterTextField] = []
private var characterBoxes: [PinCodeCharacterBox] = []
private let inputField = PinCodeInputField()
private var firstResponderObservation: NSKeyValueObservation?

// MARK: - Initializers

init(
numberOfDigits: Int,
itemSpacing: CGFloat
Expand All @@ -91,158 +75,217 @@ class PinCodeTextView: NSControl, NSTextFieldDelegate {
stackView.trailingAnchor.constraint(greaterThanOrEqualTo: self.trailingAnchor),
stackView.centerXAnchor.constraint(equalTo: self.centerXAnchor),
])

self.code = (0..<numberOfDigits).map { _ in nil }
self.characterViews = (0..<numberOfDigits).map { _ in
let view = PinCodeCharacterTextField()

self.characterBoxes = (0..<numberOfDigits).map { _ in
let view = PinCodeCharacterBox()
view.translatesAutoresizingMaskIntoConstraints = false
view.delegate = self
return view
}
characterViews.forEach {
stackView.addArrangedSubview($0)
stackView.heightAnchor.constraint(equalTo: $0.heightAnchor).isActive = true
characterBoxes.forEach {
stackView.addArrangedSubview($0)
stackView.heightAnchor.constraint(equalTo: $0.heightAnchor).isActive = true
}

// The invisible input field sits on top of the boxes so it gets all
// clicks and keyboard input, and anchors the system autofill suggestion
inputField.translatesAutoresizingMaskIntoConstraints = false
inputField.delegate = self
addSubview(inputField)
NSLayoutConstraint.activate([
inputField.topAnchor.constraint(equalTo: self.topAnchor),
inputField.bottomAnchor.constraint(equalTo: self.bottomAnchor),
inputField.leadingAnchor.constraint(equalTo: self.leadingAnchor),
inputField.trailingAnchor.constraint(equalTo: self.trailingAnchor),
])

updateBoxes()
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

private func updateText() {
characterViews.enumerated().forEach { (index, item) in
if (0..<code.count).contains(index) {
let _index = code.index(code.startIndex, offsetBy: index)
item.character = code[_index]
} else {
item.character = nil
}

func setCode(_ newCode: String) {
let sanitized = sanitize(newCode)
guard sanitized != currentCode else { return }
currentCode = sanitized
if inputField.stringValue != sanitized {
inputField.stringValue = sanitized
}
updateBoxes()
}

// MARK: NSTextFieldDelegate

func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(deleteBackward(_:)) {
// If empty, move to previous or first character view
if textView.string.isEmpty {
if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) {
window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter])
} else {
window?.makeFirstResponder(characterViews[0])
}

return true
}

private func sanitize(_ string: String) -> String {
String(string.filter { $0.isLetter || $0.isNumber }.prefix(numberOfDigits))
}

private func updateBoxes() {
let characters = Array(currentCode)
let activeIndex = isInputFocused ? min(characters.count, numberOfDigits - 1) : nil
characterBoxes.enumerated().forEach { (index, box) in
box.character = index < characters.count ? characters[index] : nil
box.isActive = index == activeIndex
}

// Perform default behaviour
}

private var isInputFocused: Bool {
guard let responder = window?.firstResponder else { return false }
if responder === inputField { return true }
if let editor = responder as? NSTextView, editor.delegate === inputField { return true }
return false
}

func controlTextDidChange(_ obj: Notification) {
guard
let field = obj.object as? NSTextField,
isEnabled,
let fieldIndex = characterViews.firstIndex(where: { $0 === field })
else { return }

let newFieldText = field.stringValue

// Handle pasting multiple characters (e.g., pasting "123456" from clipboard)
if newFieldText.count > 1 {
// Filter to alphanumeric characters only
let validCharacters = newFieldText.filter { $0.isLetter || $0.isNumber }

// Always start from the first field and clear previous content
var newCode = Array(repeating: Character?.none, count: numberOfDigits)
for (offset, character) in validCharacters.enumerated() {
if offset < numberOfDigits {
newCode[offset] = character
}
}

// Update all fields at once to avoid triggering didSet multiple times
code = newCode

// Move focus to next empty field or the last field if all are filled
let nextEmptyIndex = code.firstIndex(where: { $0 == nil }) ?? numberOfDigits - 1
if nextEmptyIndex < characterViews.count {
window?.makeFirstResponder(characterViews[nextEmptyIndex])
} else {
resignFirstResponder()

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
firstResponderObservation = window?.observe(\.firstResponder) { [weak self] _, _ in
DispatchQueue.main.async { self?.updateBoxes() }
}
// Focus immediately so the system one-time-code suggestion can appear
DispatchQueue.main.async { [weak self] in
guard let self, let window = self.window else { return }
if window.firstResponder === window {
window.makeFirstResponder(self.inputField)
}

return
}

// Handle single character input
let lastCharacter: Character?
if newFieldText.isEmpty {
lastCharacter = nil
} else {
lastCharacter = newFieldText[newFieldText.index(before: newFieldText.endIndex)]
}

// Returns the contiguous run of characters added between old and new,
// assuming a single insertion (typing, paste or autofill)
private func insertedText(from old: String, to new: String) -> String? {
guard new.count > old.count else { return nil }
let oldChars = Array(old)
let newChars = Array(new)
var prefix = 0
while prefix < oldChars.count, oldChars[prefix] == newChars[prefix] {
prefix += 1
}
var suffix = 0
while suffix < oldChars.count - prefix,
oldChars[oldChars.count - 1 - suffix] == newChars[newChars.count - 1 - suffix] {
suffix += 1
}
return String(newChars[prefix..<(newChars.count - suffix)])
}

code[fieldIndex] = lastCharacter

if lastCharacter != nil {
if fieldIndex >= characterViews.count - 1 {
resignFirstResponder()
} else {
window?.makeFirstResponder(characterViews[fieldIndex + 1])
}
// MARK: NSTextFieldDelegate

func controlTextDidChange(_ obj: Notification) {
guard isEnabled else { return }

let rawText = inputField.stringValue
let sanitized: String

// If a full code was pasted or autofilled while digits were already
// entered, the pasted code wins over the leftover digits
if let inserted = insertedText(from: currentCode, to: rawText),
inserted.count > 1,
sanitize(inserted).count == numberOfDigits {
sanitized = sanitize(inserted)
} else {
if let lastFieldIndexWithCharacter = code.lastIndex(where: { $0 != nil }) {
window?.makeFirstResponder(characterViews[lastFieldIndexWithCharacter])
} else {
window?.makeFirstResponder(characterViews[0])
}
sanitized = sanitize(rawText)
}

if inputField.stringValue != sanitized {
inputField.stringValue = sanitized
}

guard sanitized != currentCode else { return }
currentCode = sanitized
updateBoxes()

codeDidChange?(sanitized)
if sanitized.count == numberOfDigits {
codeDidComplete?(sanitized)
}
}

// MARK: NSResponder

override var acceptsFirstResponder: Bool {
true
}

override func becomeFirstResponder() -> Bool {
characterViews.first?.becomeFirstResponder() ?? false
inputField.becomeFirstResponder()
}

override var isEnabled: Bool {
didSet { inputField.isEnabled = isEnabled }
}
}

// MARK: - PinCodeCharacterTextField
// MARK: - PinCodeInputField

/// Invisible single-line field that owns the actual text input.
/// `.oneTimeCode` lets macOS offer 2FA codes from Messages/Mail
private class PinCodeInputField: NSTextField {
init() {
super.init(frame: .zero)

isBordered = false
drawsBackground = false
focusRingType = .none
textColor = .clear
usesSingleLineMode = true
cell?.isScrollable = true
contentType = .oneTimeCode
}

class PinCodeCharacterTextField: NSTextField {
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func becomeFirstResponder() -> Bool {
let didBecome = super.becomeFirstResponder()
if didBecome, let editor = currentEditor() as? NSTextView {
// Hide the field editor's caret and selection, the digit boxes
// underneath are the visible representation
editor.insertionPointColor = .clear
editor.selectedTextAttributes = [
.backgroundColor: NSColor.clear,
.foregroundColor: NSColor.clear,
]
}
return didBecome
}
}

// MARK: - PinCodeCharacterBox

/// Display-only box for a single digit
private class PinCodeCharacterBox: NSTextField {
var character: Character? = nil {
didSet {
stringValue = character.map(String.init) ?? ""
}
}
private var lastSize: NSSize?


var isActive: Bool = false {
didSet {
layer?.borderWidth = isActive ? 2 : 0
layer?.borderColor = NSColor.controlAccentColor.cgColor
layer?.cornerRadius = isActive ? 3 : 0
}
}

init() {
super.init(frame: .zero)

wantsLayer = true
isEditable = false
isSelectable = false
alignment = .center
maximumNumberOfLines = 1
font = .boldSystemFont(ofSize: 48)

setContentHuggingPriority(.required, for: .vertical)
setContentHuggingPriority(.required, for: .horizontal)
}

required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func textDidChange(_ notification: Notification) {
super.textDidChange(notification)
self.invalidateIntrinsicContentSize()
}


// This is kinda cheating
// Assuming that 0 is the widest and tallest character in 0-9
override var intrinsicContentSize: NSSize {
Expand Down