crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

HardwareKeyboardInputView.swift (9264B)


      1 import SwiftUI
      2 import UIKit
      3 
      4 struct HardwareKeyboardEvent {
      5     let keyCode: UIKeyboardHIDUsage
      6     let charactersIgnoringModifiers: String
      7     let modifierFlags: UIKeyModifierFlags
      8 }
      9 
     10 struct HardwareKeyboardInputView: UIViewRepresentable {
     11     private static let suspendCaptureNotification = Notification.Name(
     12         "HardwareKeyboardInputView.suspendCapture"
     13     )
     14     private static let resumeCaptureNotification = Notification.Name(
     15         "HardwareKeyboardInputView.resumeCapture"
     16     )
     17     @MainActor private static var isCaptureSuspended = false
     18 
     19     var onPress: (HardwareKeyboardEvent) -> Bool
     20     /// When false, the capture view relinquishes first responder so another
     21     /// view (the rebus text field) can own input and raise the system keyboard.
     22     var isActive: Bool = true
     23 
     24     func makeUIView(context: Context) -> KeyCaptureView {
     25         let view = KeyCaptureView()
     26         view.onPress = onPress
     27         view.setActive(isActive)
     28         return view
     29     }
     30 
     31     func updateUIView(_ uiView: KeyCaptureView, context: Context) {
     32         uiView.onPress = onPress
     33         uiView.setActive(isActive)
     34     }
     35 
     36     /// Temporarily yields hardware-keyboard ownership to UIKit content
     37     /// presented above the puzzle, such as the Save to Files filename field.
     38     @MainActor static func setCaptureSuspended(_ suspended: Bool) {
     39         isCaptureSuspended = suspended
     40         NotificationCenter.default.post(
     41             name: suspended ? suspendCaptureNotification : resumeCaptureNotification,
     42             object: nil
     43         )
     44     }
     45 
     46     final class KeyCaptureView: UIView {
     47         var onPress: ((HardwareKeyboardEvent) -> Bool)?
     48         private var isActive = true
     49         private var isSuspended = false
     50 
     51         override init(frame: CGRect) {
     52             super.init(frame: frame)
     53             isSuspended = HardwareKeyboardInputView.isCaptureSuspended
     54             observeCaptureNotifications()
     55         }
     56 
     57         required init?(coder: NSCoder) {
     58             super.init(coder: coder)
     59             isSuspended = HardwareKeyboardInputView.isCaptureSuspended
     60             observeCaptureNotifications()
     61         }
     62 
     63         deinit {
     64             NotificationCenter.default.removeObserver(self)
     65         }
     66 
     67         override var canBecomeFirstResponder: Bool { true }
     68 
     69         override var keyCommands: [UIKeyCommand]? {
     70             let letters = "abcdefghijklmnopqrstuvwxyz".map {
     71                 UIKeyCommand(
     72                     input: String($0),
     73                     modifierFlags: [],
     74                     action: #selector(handleKeyCommand(_:))
     75                 )
     76             }
     77 
     78             let digits = "0123456789".map {
     79                 UIKeyCommand(
     80                     input: String($0),
     81                     modifierFlags: [],
     82                     action: #selector(handleKeyCommand(_:))
     83                 )
     84             }
     85 
     86             // Undo/redo (⌘Z, ⇧⌘Z) are vended by the app menu (PuzzleCommands) so
     87             // they show in the hold-⌘ shortcut overlay; leaving them out here
     88             // lets those presses bubble up to that menu command.
     89             return letters + digits + [
     90                 UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleKeyCommand(_:))),
     91                 UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleKeyCommand(_:))),
     92                 UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: .command, action: #selector(handleKeyCommand(_:))),
     93                 UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: .command, action: #selector(handleKeyCommand(_:))),
     94                 UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleKeyCommand(_:))),
     95                 UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleKeyCommand(_:))),
     96                 UIKeyCommand(input: "\u{8}", modifierFlags: [], action: #selector(handleKeyCommand(_:))),
     97                 UIKeyCommand(input: "\u{7F}", modifierFlags: [], action: #selector(handleKeyCommand(_:))),
     98                 UIKeyCommand(input: "\t", modifierFlags: [], action: #selector(handleKeyCommand(_:))),
     99                 UIKeyCommand(input: "\t", modifierFlags: .shift, action: #selector(handleKeyCommand(_:))),
    100                 UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleKeyCommand(_:))),
    101                 UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(handleKeyCommand(_:))),
    102                 UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(handleKeyCommand(_:)))
    103             ]
    104         }
    105 
    106         override func didMoveToWindow() {
    107             super.didMoveToWindow()
    108             ensureFirstResponder()
    109         }
    110 
    111         func setActive(_ active: Bool) {
    112             isActive = active
    113             if active {
    114                 ensureFirstResponder()
    115             } else if isFirstResponder {
    116                 resignFirstResponder()
    117             }
    118         }
    119 
    120         func ensureFirstResponder() {
    121             guard isActive, !isSuspended, window != nil, !isFirstResponder else { return }
    122             Task { @MainActor in
    123                 guard self.isActive,
    124                       !self.isSuspended,
    125                       self.window != nil,
    126                       !self.isFirstResponder else { return }
    127                 self.becomeFirstResponder()
    128             }
    129         }
    130 
    131         private func observeCaptureNotifications() {
    132             NotificationCenter.default.addObserver(
    133                 self,
    134                 selector: #selector(suspendCapture),
    135                 name: HardwareKeyboardInputView.suspendCaptureNotification,
    136                 object: nil
    137             )
    138             NotificationCenter.default.addObserver(
    139                 self,
    140                 selector: #selector(resumeCapture),
    141                 name: HardwareKeyboardInputView.resumeCaptureNotification,
    142                 object: nil
    143             )
    144         }
    145 
    146         @objc private func suspendCapture() {
    147             isSuspended = true
    148             if isFirstResponder {
    149                 resignFirstResponder()
    150             }
    151         }
    152 
    153         @objc private func resumeCapture() {
    154             isSuspended = false
    155             ensureFirstResponder()
    156         }
    157 
    158         override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
    159             var unhandled: [UIPress] = []
    160 
    161             for press in presses {
    162                 guard let key = press.key else {
    163                     unhandled.append(press)
    164                     continue
    165                 }
    166 
    167                 let event = HardwareKeyboardEvent(
    168                     keyCode: key.keyCode,
    169                     charactersIgnoringModifiers: key.charactersIgnoringModifiers,
    170                     modifierFlags: key.modifierFlags
    171                 )
    172 
    173                 if onPress?(event) != true {
    174                     unhandled.append(press)
    175                 }
    176             }
    177 
    178             if !unhandled.isEmpty {
    179                 super.pressesBegan(Set(unhandled), with: event)
    180             }
    181         }
    182 
    183         @objc private func handleKeyCommand(_ command: UIKeyCommand) {
    184             guard let event = HardwareKeyboardEvent(
    185                 characters: command.input ?? "",
    186                 modifierFlags: command.modifierFlags
    187             ) else { return }
    188             _ = onPress?(event)
    189         }
    190     }
    191 }
    192 
    193 private extension HardwareKeyboardEvent {
    194     init?(characters: String, modifierFlags: UIKeyModifierFlags) {
    195         let keyCode: UIKeyboardHIDUsage
    196 
    197         switch characters {
    198         case UIKeyCommand.inputLeftArrow:
    199             keyCode = .keyboardLeftArrow
    200         case UIKeyCommand.inputRightArrow:
    201             keyCode = .keyboardRightArrow
    202         case UIKeyCommand.inputUpArrow:
    203             keyCode = .keyboardUpArrow
    204         case UIKeyCommand.inputDownArrow:
    205             keyCode = .keyboardDownArrow
    206         case UIKeyCommand.inputEscape:
    207             keyCode = .keyboardEscape
    208         case "\u{8}":
    209             keyCode = .keyboardDeleteOrBackspace
    210         case "\u{7F}":
    211             keyCode = .keyboardDeleteForward
    212         case "\t":
    213             keyCode = .keyboardTab
    214         case " ":
    215             keyCode = .keyboardSpacebar
    216         case "\r":
    217             keyCode = .keyboardReturnOrEnter
    218         case "a"..."z", "A"..."Z":
    219             guard let scalar = characters.uppercased().unicodeScalars.first,
    220                   let code = UIKeyboardHIDUsage(rawValue: Int(scalar.value - 65) + UIKeyboardHIDUsage.keyboardA.rawValue) else {
    221                 return nil
    222             }
    223             keyCode = code
    224         case "1"..."9":
    225             // HID usages run keyboard1…keyboard9 contiguously (keyboard0 sits
    226             // after, handled separately below).
    227             guard let digit = characters.first?.wholeNumberValue,
    228                   let code = UIKeyboardHIDUsage(rawValue: UIKeyboardHIDUsage.keyboard1.rawValue + (digit - 1)) else {
    229                 return nil
    230             }
    231             keyCode = code
    232         case "0":
    233             keyCode = .keyboard0
    234         default:
    235             return nil
    236         }
    237 
    238         self.init(
    239             keyCode: keyCode,
    240             charactersIgnoringModifiers: characters,
    241             modifierFlags: modifierFlags
    242         )
    243     }
    244 }