crossmate

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

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 }