crossmate

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

commit 89c6eb043f7504a4c9f5ce372dad5e1e47771953
parent 8793ab064ba240a07f857b77af9730365590d944
Author: Michael Camilleri <[email protected]>
Date:   Wed,  6 May 2026 18:17:54 +0900

Add external keyboard input handling

This commit adds support for hardware key presses from the puzzle view and
route them through the existing PlayerSession actions for letters, delete,
arrows, tab, space, return, escape, and command-left/right navigation.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++++++
MCrossmate/CrossmateApp.swift | 1+
MCrossmate/Services/AppServices.swift | 2++
ACrossmate/Services/InputMonitor.swift | 46++++++++++++++++++++++++++++++++++++++++++++++
ACrossmate/Views/HardwareKeyboardInputView.swift | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/PuzzleView.swift | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
6 files changed, 339 insertions(+), 2 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -15,9 +15,11 @@ 170D481E47CE5CB17BB8619E /* GamePlayerColorStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC4C0246B2BCE686A3516DB /* GamePlayerColorStoreTests.swift */; }; 17A754692F05B97DBDD645F2 /* PlayerSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */; }; 197DDF45C36B9570BB9AE4B5 /* AuthorIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F1471BE4D6D84361DD692B /* AuthorIdentity.swift */; }; + 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BDD06460A76D4AF31077732 /* InputMonitor.swift */; }; 1CC2D062086FDC5894BFEFA2 /* DiagnosticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434862125EC5C0C0F3717ECA /* DiagnosticsView.swift */; }; 1F4E5473F78A5CEDBA9719CE /* NYTAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A253416F4FEA271A80B22A73 /* NYTAuthService.swift */; }; 24B460FECF10A5BCC29E204E /* MoveLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543481AA9FA32BF14076EB1C /* MoveLogTests.swift */; }; + 2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */; }; 2C0DFC182240A2519ED1FA6A /* GameMutatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFC1C59A30FB2571598273E4 /* GameMutatorTests.swift */; }; 2F43F24C98D7FF00CA486753 /* PuzzleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE9E1A5C72FF3918F54CFA /* PuzzleView.swift */; }; 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC7784917397BCD6B8D679D /* PuzzleCatalog.swift */; }; @@ -132,6 +134,7 @@ 64C8064F04FC6177D987ACA2 /* Puzzle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Puzzle.swift; sourceTree = "<group>"; }; 666155B0C17A8CED11C45A80 /* GamePlayerColorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePlayerColorStore.swift; sourceTree = "<group>"; }; 68072F4F3EB5D5A78E03D408 /* ShareRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareRoutingTests.swift; sourceTree = "<group>"; }; + 6BDD06460A76D4AF31077732 /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = "<group>"; }; 6F0B4F65D017C1FBAC3B23DF /* PlayerSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerSelection.swift; sourceTree = "<group>"; }; 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DriveMonitor.swift; sourceTree = "<group>"; }; 73DDDED719CFFDD6035C3B48 /* SyncEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncEngine.swift; sourceTree = "<group>"; }; @@ -144,6 +147,7 @@ 927186458ED03FD0C5660765 /* CrossmateModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CrossmateModel.xcdatamodel; sourceTree = "<group>"; }; 93EE5BA78566EDED68D846AB /* GameStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStore.swift; sourceTree = "<group>"; }; 9447F0FE34C63810C6F1D8BE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; + 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareKeyboardInputView.swift; sourceTree = "<group>"; }; 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A4B7C6A8A23C6E4CCEC759F /* BundledBrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledBrowseView.swift; sourceTree = "<group>"; }; 9B2289E9F6A78502581886CD /* NameBroadcasterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBroadcasterTests.swift; sourceTree = "<group>"; }; @@ -304,6 +308,7 @@ 5ABB557BA10CBE9909056882 /* GameShareItem.swift */, D9BB7D9759D27F7BA6734FDE /* GridThumbnailView.swift */, CAB4BB9E160C3A59C653E7A9 /* GridView.swift */, + 947102E58EFCF898D258AC3E /* HardwareKeyboardInputView.swift */, 87B1BB8AB6309AF111671CB5 /* ImportedBrowseView.swift */, 7D28E8CBB1AFFD801E87D4E3 /* KeyboardView.swift */, F2F7D62E5E9EE2AEFC8940F4 /* NewGameSheet.swift */, @@ -354,6 +359,7 @@ 16E1DA8C1B4E73AFB779CC06 /* DebuggingMonitors.swift */, 70AD1A006E6D03E4429E3BF0 /* DriveMonitor.swift */, 462CE0FD356F6137C9BFD30F /* ImportService.swift */, + 6BDD06460A76D4AF31077732 /* InputMonitor.swift */, 33878A29B09A6154C7A63C82 /* KeychainHelper.swift */, 4803C2E84FC5B3BFE593171D /* NameBroadcaster.swift */, A253416F4FEA271A80B22A73 /* NYTAuthService.swift */, @@ -514,8 +520,10 @@ D58980B92C99122C368D4216 /* GameStore.swift in Sources */, C7370BCAD585EEFD366204E3 /* GridThumbnailView.swift in Sources */, 765B50552B13175F91A25EA1 /* GridView.swift in Sources */, + 2B03A1A36AB55495ED0E8684 /* HardwareKeyboardInputView.swift in Sources */, 4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */, 8478F0BC0CA624C78DC0A3B5 /* ImportedBrowseView.swift in Sources */, + 1A19D13D9B820E276C60819E /* InputMonitor.swift in Sources */, F77177F48728ECEACD3B28B3 /* KeyboardView.swift in Sources */, 38C913D00ED762BD9E355A2D /* KeychainHelper.swift in Sources */, F2BE3AA7211847AD0CCF1202 /* MoveBuffer.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -20,6 +20,7 @@ struct CrossmateApp: App { ) .environment(\.managedObjectContext, services.persistence.viewContext) .environment(services.driveMonitor) + .environment(services.inputMonitor) .environment(services.syncMonitor) .environment(\.syncEngine, services.syncEngine) .environment(services.nytAuth) diff --git a/Crossmate/Services/AppServices.swift b/Crossmate/Services/AppServices.swift @@ -11,6 +11,7 @@ final class AppServices { let nytAuth: NYTAuthService let driveMonitor: DriveMonitor let nytFetcher: NYTPuzzleFetcher + let inputMonitor: InputMonitor let moveBuffer: MoveBuffer let snapshotService: SnapshotService let presencePublisher: PresencePublisher @@ -41,6 +42,7 @@ final class AppServices { self.nytAuth = NYTAuthService() self.driveMonitor = DriveMonitor() self.nytFetcher = NYTPuzzleFetcher { NYTAuthService.currentCookie() } + self.inputMonitor = InputMonitor() let identity = AuthorIdentity() self.identity = identity diff --git a/Crossmate/Services/InputMonitor.swift b/Crossmate/Services/InputMonitor.swift @@ -0,0 +1,46 @@ +import GameController +import Observation + +@MainActor +@Observable +final class InputMonitor { + private(set) var isConnected: Bool + + private var observers: [NSObjectProtocol] = [] + + init() { + #if targetEnvironment(simulator) + self.isConnected = false + #else + self.isConnected = GCKeyboard.coalesced != nil + + let center = NotificationCenter.default + observers.append(center.addObserver( + forName: .GCKeyboardDidConnect, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.update() + } + }) + observers.append(center.addObserver( + forName: .GCKeyboardDidDisconnect, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.update() + } + }) + #endif + } + + func update() { + #if targetEnvironment(simulator) + isConnected = false + #else + isConnected = GCKeyboard.coalesced != nil + #endif + } +} diff --git a/Crossmate/Views/HardwareKeyboardInputView.swift b/Crossmate/Views/HardwareKeyboardInputView.swift @@ -0,0 +1,141 @@ +import SwiftUI +import UIKit + +struct HardwareKeyboardEvent { + let keyCode: UIKeyboardHIDUsage + let charactersIgnoringModifiers: String + let modifierFlags: UIKeyModifierFlags +} + +struct HardwareKeyboardInputView: UIViewRepresentable { + var onPress: (HardwareKeyboardEvent) -> Bool + + func makeUIView(context: Context) -> KeyCaptureView { + let view = KeyCaptureView() + view.onPress = onPress + return view + } + + func updateUIView(_ uiView: KeyCaptureView, context: Context) { + uiView.onPress = onPress + uiView.ensureFirstResponder() + } + + final class KeyCaptureView: UIView { + var onPress: ((HardwareKeyboardEvent) -> Bool)? + + override var canBecomeFirstResponder: Bool { true } + + override var keyCommands: [UIKeyCommand]? { + let letters = "abcdefghijklmnopqrstuvwxyz".map { + UIKeyCommand( + input: String($0), + modifierFlags: [], + action: #selector(handleKeyCommand(_:)) + ) + } + + return letters + [ + UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: .command, action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: .command, action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: "\u{8}", modifierFlags: [], action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: "\u{7F}", modifierFlags: [], action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: "\t", modifierFlags: [], action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: "\t", modifierFlags: .shift, action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(handleKeyCommand(_:))), + UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(handleKeyCommand(_:))) + ] + } + + override func didMoveToWindow() { + super.didMoveToWindow() + ensureFirstResponder() + } + + func ensureFirstResponder() { + guard window != nil, !isFirstResponder else { return } + Task { @MainActor in + self.becomeFirstResponder() + } + } + + override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) { + var unhandled: [UIPress] = [] + + for press in presses { + guard let key = press.key else { + unhandled.append(press) + continue + } + + let event = HardwareKeyboardEvent( + keyCode: key.keyCode, + charactersIgnoringModifiers: key.charactersIgnoringModifiers, + modifierFlags: key.modifierFlags + ) + + if onPress?(event) != true { + unhandled.append(press) + } + } + + if !unhandled.isEmpty { + super.pressesBegan(Set(unhandled), with: event) + } + } + + @objc private func handleKeyCommand(_ command: UIKeyCommand) { + guard let event = HardwareKeyboardEvent(command: command) else { return } + _ = onPress?(event) + } + } +} + +private extension HardwareKeyboardEvent { + init?(command: UIKeyCommand) { + let keyCode: UIKeyboardHIDUsage + let characters = command.input ?? "" + + switch characters { + case UIKeyCommand.inputLeftArrow: + keyCode = .keyboardLeftArrow + case UIKeyCommand.inputRightArrow: + keyCode = .keyboardRightArrow + case UIKeyCommand.inputUpArrow: + keyCode = .keyboardUpArrow + case UIKeyCommand.inputDownArrow: + keyCode = .keyboardDownArrow + case UIKeyCommand.inputEscape: + keyCode = .keyboardEscape + case "\u{8}": + keyCode = .keyboardDeleteOrBackspace + case "\u{7F}": + keyCode = .keyboardDeleteForward + case "\t": + keyCode = .keyboardTab + case " ": + keyCode = .keyboardSpacebar + case "\r": + keyCode = .keyboardReturnOrEnter + case "a"..."z", "A"..."Z": + guard let scalar = characters.uppercased().unicodeScalars.first, + let code = UIKeyboardHIDUsage(rawValue: Int(scalar.value - 65) + UIKeyboardHIDUsage.keyboardA.rawValue) else { + return nil + } + keyCode = code + default: + return nil + } + + self.init( + keyCode: keyCode, + charactersIgnoringModifiers: characters, + modifierFlags: command.modifierFlags + ) + } +} diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -7,6 +7,7 @@ struct PuzzleView: View { var onComplete: (() -> Void)? = nil var onResign: (() throws -> Void)? = nil var onDelete: (() throws -> Void)? = nil + @Environment(InputMonitor.self) private var inputMonitor @Environment(PlayerPreferences.self) private var preferences @Environment(\.dismiss) private var dismiss @State private var isRenaming = false @@ -62,6 +63,11 @@ struct PuzzleView: View { } } .background(Color(.systemBackground)) + .background { + HardwareKeyboardInputView(onPress: handleHardwareKeyboardEvent) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + } .ignoresSafeArea(.keyboard) .modifier(PuzzleToolbarModifier( session: session, @@ -221,12 +227,12 @@ struct PuzzleView: View { if isSolved { SuccessPanel(session: session, roster: roster) .transition(.move(edge: .bottom)) - } else { + } else if showsCustomKeyboard { KeyboardView(session: session, showsNavigationKeys: isLandscapePad) .transition(.move(edge: .bottom)) } } - .frame(height: KeyboardView.standardHeight) + .frame(height: controlsPanelHeight) .clipped() .background { Color(.systemGroupedBackground) @@ -236,6 +242,139 @@ struct PuzzleView: View { } } + private var controlsPanelHeight: CGFloat { + isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0 + } + + private var showsCustomKeyboard: Bool { + !inputMonitor.isConnected + } + + private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool { + guard !isSolved else { return false } + + switch event.keyCode { + case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE, + .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ, + .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO, + .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT, + .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY, + .keyboardZ: + guard !event.modifierFlags.contains(.command), + !event.modifierFlags.contains(.control), + !event.modifierFlags.contains(.alternate), + let letter = hardwareKeyboardLetter(from: event) else { + return false + } + if session.isRebusActive { + session.appendRebusLetter(letter) + } else { + session.enter(letter) + } + return true + + case .keyboardDeleteOrBackspace, .keyboardDeleteForward: + if session.isRebusActive { + session.deleteRebusLetter() + } else { + session.deleteBackward() + } + return true + + case .keyboardLeftArrow: + guard !session.isRebusActive else { return false } + if event.modifierFlags.contains(.command) { + session.goToPreviousWord() + return true + } + moveWithHardwareArrow(direction: .across) { + session.goToPreviousLetter() + } + return true + + case .keyboardRightArrow: + guard !session.isRebusActive else { return false } + if event.modifierFlags.contains(.command) { + session.goToNextWord() + return true + } + moveWithHardwareArrow(direction: .across) { + session.goToNextLetter() + } + return true + + case .keyboardUpArrow: + guard !session.isRebusActive else { return false } + moveWithHardwareArrow(direction: .down) { + session.goToPreviousLetter() + } + return true + + case .keyboardDownArrow: + guard !session.isRebusActive else { return false } + moveWithHardwareArrow(direction: .down) { + session.goToNextLetter() + } + return true + + case .keyboardTab: + guard !session.isRebusActive else { return false } + if event.modifierFlags.contains(.shift) { + session.goToPreviousClue() + } else { + session.goToNextClue() + } + return true + + case .keyboardSpacebar: + guard !session.isRebusActive else { return false } + session.toggleDirection() + return true + + case .keyboardReturnOrEnter: + if session.isRebusActive { + session.commitRebus() + } else { + session.toggleDirection() + } + return true + + case .keyboardEscape: + if session.isRebusActive { + session.commitRebus() + return true + } + return false + + default: + return false + } + } + + private func hardwareKeyboardLetter(from event: HardwareKeyboardEvent) -> String? { + let scalars = event.charactersIgnoringModifiers.unicodeScalars + guard scalars.count == 1, let scalar = scalars.first else { return nil } + + switch scalar.value { + case 65...90, 97...122: + return String(Character(scalar)).uppercased() + default: + return nil + } + } + + private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) { + if session.direction != direction { + let previousDirection = session.direction + session.setDirection(direction) + if session.direction != previousDirection { + return + } + } + + move() + } + private func leaveSharedGame() async { guard let shareController else { return } do {