crossmate

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

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 }