crossmate

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

commit a5ee89929135e0d20291a90cda3a3d283b9c42db
parent 56b72088c655d9bcea08d6ce19807f55ec2f207e
Author: Michael Camilleri <[email protected]>
Date:   Tue, 23 Jun 2026 06:42:55 +0900

Stop the puzzle command menu hanging the menu builder

Opening a puzzle on iPad with a hardware keyboard attached froze the
app. Two separate faults in how the new Entry and Hints commands feed
UIKit's menu builder were each enough to wedge it.

Undo and Redo declared their own ⌘Z and ⇧⌘Z inside the Entry menu, a
second pair alongside the standard undo:/redo: commands UIKit already
vends. Duplicate shortcuts wedge the menu builder. This commit moves
Undo and Redo into CommandGroup(replacing: .undoRedo) so they take the
place of the system pair instead of colliding with it; the built-in pair
targets a responder's NSUndoManager the app does not use, so nothing is
lost.

The focused value was the second fault. PuzzleView republishes a fresh
PuzzleActionTarget on every render, and because it carries a closure its
identity changed each time, so SwiftUI rebuilt the menu on every pass. A
puzzle load renders rapidly, and a rebuild kicked off while one was in
flight reentrantly deadlocks the builder at idle. PuzzleActionTarget is
now Equatable, keyed on the session identity and the enabled flag and
ignoring the closure, so SwiftUI rebuilds the menu only when something
actually changed.

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

Diffstat:
MCrossmate/Views/Puzzle/PuzzleCommands.swift | 26+++++++++++++++++++-------
1 file changed, 19 insertions(+), 7 deletions(-)

diff --git a/Crossmate/Views/Puzzle/PuzzleCommands.swift b/Crossmate/Views/Puzzle/PuzzleCommands.swift @@ -5,8 +5,7 @@ import SwiftUI /// 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 { +struct PuzzleActionTarget: Equatable { 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. @@ -17,8 +16,16 @@ struct PuzzleActionTarget { // 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 } + @MainActor var canUndo: Bool { isEnabled && !session.isRebusActive && session.canUndo } + @MainActor var canRedo: Bool { isEnabled && !session.isRebusActive && session.canRedo } + + // PuzzleView republishes this on every render and the closure identity + // changes each time; keying equality on the session and enabled flag lets + // SwiftUI dedupe so the menu isn't rebuilt on every render. Rebuilding it on + // each pass during a puzzle load reentrantly deadlocks UIKit's menu builder. + static func == (lhs: PuzzleActionTarget, rhs: PuzzleActionTarget) -> Bool { + lhs.session === rhs.session && lhs.isEnabled == rhs.isEnabled + } } private struct PuzzleActionsKey: FocusedValueKey { @@ -42,16 +49,21 @@ struct PuzzleCommands: Commands { private var isEnabled: Bool { target?.isEnabled ?? false } var body: some Commands { - CommandMenu("Entry") { + // Replace the system Undo/Redo rather than adding our own: UIKit already + // vends ⌘Z / ⇧⌘Z via the standard undo: / redo: commands, and a second + // pair with the same shortcuts deadlocks the menu builder. The built-in + // ones target a responder's NSUndoManager, which the app doesn't use — + // undo runs through the session/mutator — so ours take their place. + CommandGroup(replacing: .undoRedo) { 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() - + CommandMenu("Entry") { Button("Enter Rebus") { target?.session.startRebus() } .keyboardShortcut("e", modifiers: .command) .disabled(!isEnabled)