PuzzleCommands.swift (6754B)
1 import SwiftUI 2 3 @MainActor 4 @Observable 5 final class RevealConfirmation { 6 var isConfirming = false 7 var pendingScope: RevealScope = .square 8 9 func request(_ scope: RevealScope) { 10 pendingScope = scope 11 isConfirming = true 12 } 13 14 /// Executes the pending reveal after the player confirms the alert. Every 15 /// reveal surface (toolbar Hints menu, app-level commands, the VoiceOver 16 /// cell action) funnels through `request` and this, so no path can reveal 17 /// without the confirmation. Returns the VoiceOver announcement for a 18 /// square reveal ("Revealed R"); the wider scopes return nil — their 19 /// outcome is read from the grid itself. 20 func performPending(on session: PlayerSession) -> String? { 21 switch pendingScope { 22 case .square: 23 session.revealSquare() 24 return CellAccessibilityDescriber(puzzle: session.puzzle, authorNames: [:]) 25 .revealAnnouncement( 26 atRow: session.selectedRow, 27 atCol: session.selectedCol, 28 squares: session.game.squares 29 ) 30 case .word: 31 session.revealCurrentWord() 32 return nil 33 case .puzzle: 34 session.revealPuzzle() 35 return nil 36 } 37 } 38 } 39 40 /// The active puzzle's command surface, published into the focused scene by 41 /// `PuzzleView` and read by `PuzzleCommands`. Carrying it through a focused 42 /// value is what lets the app-level menu (and the hold-⌘ discoverability 43 /// overlay it drives on iPadOS) target whichever puzzle is on screen, and 44 /// disable itself when none is. 45 struct PuzzleActionTarget: Equatable { 46 let session: PlayerSession 47 let revealConfirmation: RevealConfirmation 48 /// False when the puzzle is solved or input is blocked (e.g. access 49 /// revoked) — the same gate the toolbar's Entry/Hints menus use. 50 let isEnabled: Bool 51 52 // Rebus owns hardware input while active, so undo/redo step aside then — 53 // mirroring the old hardware-key behaviour before it moved into the menu. 54 @MainActor var canUndo: Bool { isEnabled && !session.isRebusActive && session.canUndo } 55 @MainActor var canRedo: Bool { isEnabled && !session.isRebusActive && session.canRedo } 56 57 /// Routes a reveal through `PuzzleView`'s confirmation alert rather than 58 /// revealing immediately, matching the toolbar buttons. 59 @MainActor 60 func requestReveal(_ scope: RevealScope) { 61 revealConfirmation.request(scope) 62 } 63 64 // PuzzleView republishes this on every render; keying equality on stable 65 // references and the enabled flag lets SwiftUI dedupe so the menu isn't 66 // rebuilt on every render. Rebuilding it on each pass during a puzzle load 67 // reentrantly deadlocks UIKit's menu builder. 68 static func == (lhs: PuzzleActionTarget, rhs: PuzzleActionTarget) -> Bool { 69 lhs.session === rhs.session 70 && lhs.revealConfirmation === rhs.revealConfirmation 71 && lhs.isEnabled == rhs.isEnabled 72 } 73 } 74 75 private struct PuzzleActionsKey: FocusedValueKey { 76 typealias Value = PuzzleActionTarget 77 } 78 79 extension FocusedValues { 80 var puzzleActions: PuzzleActionTarget? { 81 get { self[PuzzleActionsKey.self] } 82 set { self[PuzzleActionsKey.self] = newValue } 83 } 84 } 85 86 /// App-level keyboard shortcuts for the open puzzle. These populate the 87 /// hold-⌘ shortcut overlay (the iPad menu bar) and stay live wherever the 88 /// puzzle scene is active; with no puzzle on screen the focused value is `nil` 89 /// and every command disables itself. 90 struct PuzzleCommands: Commands { 91 @FocusedValue(\.puzzleActions) private var target 92 93 private var isEnabled: Bool { target?.isEnabled ?? false } 94 95 var body: some Commands { 96 // Replace the system Undo/Redo rather than adding our own: UIKit already 97 // vends ⌘Z / ⇧⌘Z via the standard undo: / redo: commands, and a second 98 // pair with the same shortcuts deadlocks the menu builder. The built-in 99 // ones target a responder's NSUndoManager, which the app doesn't use — 100 // undo runs through the session/mutator — so ours take their place. 101 CommandGroup(replacing: .undoRedo) { 102 Button("Undo Move") { target?.session.undo() } 103 .keyboardShortcut("z", modifiers: .command) 104 .disabled(!(target?.canUndo ?? false)) 105 Button("Redo Move") { target?.session.redo() } 106 .keyboardShortcut("z", modifiers: [.command, .shift]) 107 .disabled(!(target?.canRedo ?? false)) 108 } 109 110 CommandMenu("Entry") { 111 Button("Enter Rebus") { target?.session.startRebus() } 112 .keyboardShortcut("e", modifiers: .command) 113 .disabled(!isEnabled) 114 Button("Toggle Direction") { target?.session.toggleDirection() } 115 .keyboardShortcut(".", modifiers: .command) 116 .disabled(!isEnabled) 117 118 Divider() 119 120 Button("Clear Word") { target?.session.clearCurrentWord() } 121 .keyboardShortcut(.delete, modifiers: .command) 122 .disabled(!isEnabled) 123 Button("Clear Puzzle", role: .destructive) { target?.session.clearPuzzle() } 124 .keyboardShortcut(.delete, modifiers: [.command, .shift]) 125 .disabled(!isEnabled) 126 } 127 128 CommandMenu("Hints") { 129 Button("Check Square") { target?.session.checkSquare() } 130 .keyboardShortcut("k", modifiers: .command) 131 .disabled(!isEnabled) 132 Button("Check Word") { target?.session.checkCurrentWord() } 133 .keyboardShortcut("k", modifiers: [.command, .shift]) 134 .disabled(!isEnabled) 135 Button("Check Puzzle") { target?.session.checkPuzzle() } 136 .keyboardShortcut("k", modifiers: [.command, .control]) 137 .disabled(!isEnabled) 138 139 Divider() 140 141 Button("Fill Quarter") { target?.session.fillQuarter() } 142 .disabled(!isEnabled) 143 Button("Fill Half") { target?.session.fillHalf() } 144 .disabled(!isEnabled) 145 146 Divider() 147 148 Button("Reveal Square") { target?.requestReveal(.square) } 149 .keyboardShortcut("r", modifiers: .command) 150 .disabled(!isEnabled) 151 Button("Reveal Word") { target?.requestReveal(.word) } 152 .keyboardShortcut("r", modifiers: [.command, .shift]) 153 .disabled(!isEnabled) 154 Button("Reveal Puzzle", role: .destructive) { target?.requestReveal(.puzzle) } 155 .keyboardShortcut("r", modifiers: [.command, .control]) 156 .disabled(!isEnabled) 157 } 158 } 159 }