crossmate

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

commit 56b72088c655d9bcea08d6ce19807f55ec2f207e
parent e25a779efb4788ca54ae140de46e7b75c984ee71
Author: Michael Camilleri <[email protected]>
Date:   Tue, 23 Jun 2026 05:07:47 +0900

List puzzle shortcuts in the hold-⌘ overlay on iPad

Holding the Command key with a hardware keyboard now brings up the
discoverability overlay listing the puzzle's actions — Undo, Redo,
Toggle Direction, Enter Rebus, Clear, and the Check and Reveal
families — rather than showing nothing at all.

The previous path vended these as raw UIKeyCommands carrying the
deprecated discoverabilityTitle from the KeyCaptureView buried inside
the SwiftUI hierarchy. That fires the commands but does not populate the
overlay on current iPadOS, which builds it from the menu system instead,
so the shortcuts worked yet stayed invisible.

This commit introduces a PuzzleCommands Commands type, attached to the
WindowGroup, that exposes the actions through two menus, Entry and
Hints, mirroring the puzzle toolbar. PuzzleView publishes the live
session as a PuzzleActionTarget via focusedSceneValue, so the menu
targets whichever puzzle is on screen and disables itself when no puzzle
is on screen or when the puzzle is solved or input-blocked. Reveal
routes through the same confirmation alert the toolbar uses rather than
revealing immediately.

Undo and redo move out of KeyCaptureView and handleHardwareKeyboardEvent
and into the menu, leaving it as their single definition: a ⌘-modified Z
now falls through the letter case and bubbles up to the menu command.
Word navigation and bare-key entry stay in the capture view, the latter
because the menu system cannot carry unmodified keys.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 3+++
MCrossmate/Views/Puzzle/HardwareKeyboardInputView.swift | 10+++-------
ACrossmate/Views/Puzzle/PuzzleCommands.swift | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCrossmate/Views/Puzzle/PuzzleView.swift | 27+++++++++++++++------------
5 files changed, 121 insertions(+), 19 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -144,6 +144,7 @@ AA38A51862FC0AB8F7D34899 /* NYTToXDConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54223FED97577A593B7964E /* NYTToXDConverterTests.swift */; }; AACC9F70AEEDCB3360FFDEFF /* GridStateMergerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C7F3A9BD7FAF81CB77032A6 /* GridStateMergerTests.swift */; }; AB6D98C7A78D91D7BEFB4A4C /* MarketingPuzzleScreenshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */; }; + ADBEAD1C0139BCF864CA8A1D /* PuzzleCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89528AF69DE06C61FA8E91A1 /* PuzzleCommands.swift */; }; AE5D8C531F89F05B7201B3AC /* SessionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64DAE64C9AA042B330C526F /* SessionMonitorTests.swift */; }; AF4F1AE2A1F94E92C785C524 /* Square.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB851649DE78AAAC5A928C52 /* Square.swift */; }; B00743DAF8F46F14CE13E909 /* FriendsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 298A9C54A1CC753E860E174E /* FriendsView.swift */; }; @@ -348,6 +349,7 @@ 847415468DBB1C566D18BC17 /* ReplayControlsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplayControlsTests.swift; sourceTree = "<group>"; }; 86470163BFF956F3DE438506 /* Moves.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Moves.swift; sourceTree = "<group>"; }; 88E8AACB638FE5724B534B41 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; + 89528AF69DE06C61FA8E91A1 /* PuzzleCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleCommands.swift; sourceTree = "<group>"; }; 89B1FFD2F90141EA949A8540 /* JournalReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JournalReplayTests.swift; sourceTree = "<group>"; }; 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushClient.swift; sourceTree = "<group>"; }; 8AEFE2C7623A899BAABD85F4 /* MarketingPuzzleScreenshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketingPuzzleScreenshotView.swift; sourceTree = "<group>"; }; @@ -680,6 +682,7 @@ 6940546CFA1E87EF814AA6BB /* HardwareKeyboardInputView.swift */, E18FF14E0D73B0D2DB427F08 /* JoiningPuzzleView.swift */, 3FDE73AD7C543B29C8E493F8 /* KeyboardView.swift */, + 89528AF69DE06C61FA8E91A1 /* PuzzleCommands.swift */, ADBA3FB1334DB816E62B7D9B /* PuzzleHeader.swift */, F5DF04E70017065DFA95B396 /* PuzzleModifiers.swift */, A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */, @@ -1109,6 +1112,7 @@ F2F7CB23DA62BF714632B097 /* PushRequestAuthenticator.swift in Sources */, 503229FF89FF7C29CEF4C16D /* Puzzle.swift in Sources */, 350722635E9A17324148CACC /* PuzzleCatalog.swift in Sources */, + ADBEAD1C0139BCF864CA8A1D /* PuzzleCommands.swift in Sources */, D2AC1D9BD7E387B06B9B8A0E /* PuzzleHeader.swift in Sources */, 082B9BAADE3AFA54EFE30E19 /* PuzzleModifiers.swift in Sources */, 24F7ED458A1C09F8CF309B35 /* PuzzleNotificationText+GameEntity.swift in Sources */, diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift @@ -87,6 +87,9 @@ struct CrossmateApp: App { }) } } + .commands { + PuzzleCommands() + } } } diff --git a/Crossmate/Views/Puzzle/HardwareKeyboardInputView.swift b/Crossmate/Views/Puzzle/HardwareKeyboardInputView.swift @@ -50,14 +50,10 @@ struct HardwareKeyboardInputView: UIViewRepresentable { ) } - let undo = UIKeyCommand(input: "z", modifierFlags: .command, action: #selector(handleKeyCommand(_:))) - undo.discoverabilityTitle = "Undo Move" - let redo = UIKeyCommand(input: "z", modifierFlags: [.command, .shift], action: #selector(handleKeyCommand(_:))) - redo.discoverabilityTitle = "Redo Move" - + // Undo/redo (⌘Z, ⇧⌘Z) are vended by the app menu (PuzzleCommands) so + // they show in the hold-⌘ shortcut overlay; leaving them out here + // lets those presses bubble up to that menu command. return letters + digits + [ - undo, - redo, 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(_:))), diff --git a/Crossmate/Views/Puzzle/PuzzleCommands.swift b/Crossmate/Views/Puzzle/PuzzleCommands.swift @@ -0,0 +1,96 @@ +import SwiftUI + +/// The active puzzle's command surface, published into the focused scene by +/// `PuzzleView` and read by `PuzzleCommands`. Carrying it through a focused +/// value is what lets the app-level menu (and the hold-⌘ discoverability +/// overlay it drives on iPadOS) target whichever puzzle is on screen, and +/// disable itself when none is. +@MainActor +struct PuzzleActionTarget { + let session: PlayerSession + /// False when the puzzle is solved or input is blocked (e.g. access + /// revoked) — the same gate the toolbar's Entry/Hints menus use. + let isEnabled: Bool + /// Routes a reveal through `PuzzleView`'s confirmation alert rather than + /// revealing immediately, matching the toolbar buttons. + let requestReveal: (RevealScope) -> Void + + // Rebus owns hardware input while active, so undo/redo step aside then — + // mirroring the old hardware-key behaviour before it moved into the menu. + var canUndo: Bool { isEnabled && !session.isRebusActive && session.canUndo } + var canRedo: Bool { isEnabled && !session.isRebusActive && session.canRedo } +} + +private struct PuzzleActionsKey: FocusedValueKey { + typealias Value = PuzzleActionTarget +} + +extension FocusedValues { + var puzzleActions: PuzzleActionTarget? { + get { self[PuzzleActionsKey.self] } + set { self[PuzzleActionsKey.self] = newValue } + } +} + +/// App-level keyboard shortcuts for the open puzzle. These populate the +/// hold-⌘ shortcut overlay (the iPad menu bar) and stay live wherever the +/// puzzle scene is active; with no puzzle on screen the focused value is `nil` +/// and every command disables itself. +struct PuzzleCommands: Commands { + @FocusedValue(\.puzzleActions) private var target + + private var isEnabled: Bool { target?.isEnabled ?? false } + + var body: some Commands { + CommandMenu("Entry") { + Button("Undo Move") { target?.session.undo() } + .keyboardShortcut("z", modifiers: .command) + .disabled(!(target?.canUndo ?? false)) + Button("Redo Move") { target?.session.redo() } + .keyboardShortcut("z", modifiers: [.command, .shift]) + .disabled(!(target?.canRedo ?? false)) + + Divider() + + Button("Enter Rebus") { target?.session.startRebus() } + .keyboardShortcut("e", modifiers: .command) + .disabled(!isEnabled) + Button("Toggle Direction") { target?.session.toggleDirection() } + .keyboardShortcut(".", modifiers: .command) + .disabled(!isEnabled) + + Divider() + + Button("Clear Word") { target?.session.clearCurrentWord() } + .keyboardShortcut(.delete, modifiers: .command) + .disabled(!isEnabled) + Button("Clear Puzzle", role: .destructive) { target?.session.clearPuzzle() } + .keyboardShortcut(.delete, modifiers: [.command, .shift]) + .disabled(!isEnabled) + } + + CommandMenu("Hints") { + Button("Check Square") { target?.session.checkSquare() } + .keyboardShortcut("k", modifiers: .command) + .disabled(!isEnabled) + Button("Check Word") { target?.session.checkCurrentWord() } + .keyboardShortcut("k", modifiers: [.command, .shift]) + .disabled(!isEnabled) + Button("Check Puzzle") { target?.session.checkPuzzle() } + .keyboardShortcut("k", modifiers: [.command, .control]) + .disabled(!isEnabled) + + Divider() + + Button("Reveal Square") { target?.requestReveal(.square) } + .keyboardShortcut("r", modifiers: .command) + .disabled(!isEnabled) + Button("Reveal Word") { target?.requestReveal(.word) } + .keyboardShortcut("r", modifiers: [.command, .shift]) + .disabled(!isEnabled) + Button("Reveal Puzzle") { target?.requestReveal(.puzzle) } + .keyboardShortcut("r", modifiers: [.command, .control]) + .disabled(!isEnabled) + } + } +} diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift @@ -197,6 +197,17 @@ struct PuzzleView: View { performDelete: performDelete, leaveSharedGame: leaveSharedGame )) + // Surfaces the puzzle's actions to the app-level menu so the hold-⌘ + // shortcut overlay lists them; reveal routes through the same + // confirmation alert the toolbar uses. + .focusedSceneValue(\.puzzleActions, PuzzleActionTarget( + session: session, + isEnabled: !isSolved && !isInputBlocked, + requestReveal: { scope in + pendingRevealScope = scope + isConfirmingReveal = true + } + )) .onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { newSize in @@ -456,18 +467,10 @@ struct PuzzleView: View { private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool { guard !isSolved, !isInputBlocked else { return false } - // Cmd+Z undoes, Shift-Cmd-Z redoes. Caught before the letter switch so - // the modified press isn't read as typing a "Z". - if event.keyCode == .keyboardZ, event.modifierFlags.contains(.command) { - guard !session.isRebusActive else { return false } - if event.modifierFlags.contains(.shift) { - session.redo() - } else { - session.undo() - } - return true - } - + // Undo/redo (⌘Z, ⇧⌘Z) live in the app menu (see PuzzleCommands) so they + // appear in the hold-⌘ shortcut overlay. A ⌘-modified Z falls through + // the letter case below (which rejects modifiers) and bubbles up to that + // menu command. switch event.keyCode { case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE, .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ,