PuzzleCommands.swift (5055B)
1 import SwiftUI 2 3 /// The active puzzle's command surface, published into the focused scene by 4 /// `PuzzleView` and read by `PuzzleCommands`. Carrying it through a focused 5 /// value is what lets the app-level menu (and the hold-⌘ discoverability 6 /// overlay it drives on iPadOS) target whichever puzzle is on screen, and 7 /// disable itself when none is. 8 struct PuzzleActionTarget: Equatable { 9 let session: PlayerSession 10 /// False when the puzzle is solved or input is blocked (e.g. access 11 /// revoked) — the same gate the toolbar's Entry/Hints menus use. 12 let isEnabled: Bool 13 /// Routes a reveal through `PuzzleView`'s confirmation alert rather than 14 /// revealing immediately, matching the toolbar buttons. 15 let requestReveal: (RevealScope) -> Void 16 17 // Rebus owns hardware input while active, so undo/redo step aside then — 18 // mirroring the old hardware-key behaviour before it moved into the menu. 19 @MainActor var canUndo: Bool { isEnabled && !session.isRebusActive && session.canUndo } 20 @MainActor var canRedo: Bool { isEnabled && !session.isRebusActive && session.canRedo } 21 22 // PuzzleView republishes this on every render and the closure identity 23 // changes each time; keying equality on the session and enabled flag lets 24 // SwiftUI dedupe so the menu isn't rebuilt on every render. Rebuilding it on 25 // each pass during a puzzle load reentrantly deadlocks UIKit's menu builder. 26 static func == (lhs: PuzzleActionTarget, rhs: PuzzleActionTarget) -> Bool { 27 lhs.session === rhs.session && lhs.isEnabled == rhs.isEnabled 28 } 29 } 30 31 private struct PuzzleActionsKey: FocusedValueKey { 32 typealias Value = PuzzleActionTarget 33 } 34 35 extension FocusedValues { 36 var puzzleActions: PuzzleActionTarget? { 37 get { self[PuzzleActionsKey.self] } 38 set { self[PuzzleActionsKey.self] = newValue } 39 } 40 } 41 42 /// App-level keyboard shortcuts for the open puzzle. These populate the 43 /// hold-⌘ shortcut overlay (the iPad menu bar) and stay live wherever the 44 /// puzzle scene is active; with no puzzle on screen the focused value is `nil` 45 /// and every command disables itself. 46 struct PuzzleCommands: Commands { 47 @FocusedValue(\.puzzleActions) private var target 48 49 private var isEnabled: Bool { target?.isEnabled ?? false } 50 51 var body: some Commands { 52 // Replace the system Undo/Redo rather than adding our own: UIKit already 53 // vends ⌘Z / ⇧⌘Z via the standard undo: / redo: commands, and a second 54 // pair with the same shortcuts deadlocks the menu builder. The built-in 55 // ones target a responder's NSUndoManager, which the app doesn't use — 56 // undo runs through the session/mutator — so ours take their place. 57 CommandGroup(replacing: .undoRedo) { 58 Button("Undo Move") { target?.session.undo() } 59 .keyboardShortcut("z", modifiers: .command) 60 .disabled(!(target?.canUndo ?? false)) 61 Button("Redo Move") { target?.session.redo() } 62 .keyboardShortcut("z", modifiers: [.command, .shift]) 63 .disabled(!(target?.canRedo ?? false)) 64 } 65 66 CommandMenu("Entry") { 67 Button("Enter Rebus") { target?.session.startRebus() } 68 .keyboardShortcut("e", modifiers: .command) 69 .disabled(!isEnabled) 70 Button("Toggle Direction") { target?.session.toggleDirection() } 71 .keyboardShortcut(".", modifiers: .command) 72 .disabled(!isEnabled) 73 74 Divider() 75 76 Button("Clear Word") { target?.session.clearCurrentWord() } 77 .keyboardShortcut(.delete, modifiers: .command) 78 .disabled(!isEnabled) 79 Button("Clear Puzzle", role: .destructive) { target?.session.clearPuzzle() } 80 .keyboardShortcut(.delete, modifiers: [.command, .shift]) 81 .disabled(!isEnabled) 82 } 83 84 CommandMenu("Hints") { 85 Button("Check Square") { target?.session.checkSquare() } 86 .keyboardShortcut("k", modifiers: .command) 87 .disabled(!isEnabled) 88 Button("Check Word") { target?.session.checkCurrentWord() } 89 .keyboardShortcut("k", modifiers: [.command, .shift]) 90 .disabled(!isEnabled) 91 Button("Check Puzzle") { target?.session.checkPuzzle() } 92 .keyboardShortcut("k", modifiers: [.command, .control]) 93 .disabled(!isEnabled) 94 95 Divider() 96 97 Button("Reveal Square") { target?.requestReveal(.square) } 98 .keyboardShortcut("r", modifiers: .command) 99 .disabled(!isEnabled) 100 Button("Reveal Word") { target?.requestReveal(.word) } 101 .keyboardShortcut("r", modifiers: [.command, .shift]) 102 .disabled(!isEnabled) 103 Button("Reveal Puzzle") { target?.requestReveal(.puzzle) } 104 .keyboardShortcut("r", modifiers: [.command, .control]) 105 .disabled(!isEnabled) 106 } 107 } 108 }