From a425c27597d0eaddcf5384913040f695c2aee41a Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sun, 22 Feb 2026 21:35:53 -0800 Subject: [PATCH 1/4] Add Game menu and command handling Introduce a GameCommand enum and a .gameCommand Notification.Name; add a new "Game" CommandMenu in the app that posts these notifications (New Game, Redeal, Undo, Auto Finish, Hint) with keyboard shortcuts. Update ContentView to observe .gameCommand notifications and handle them via a new handleGameCommand(_:) method that maps commands to existing UI actions (start/redeal/undo/auto-finish/hint). Also refactor applyObservers to chain command and game-state observers while preserving lifecycle and draw-mode change handling. --- ComputerSolitaire/CommandNotifications.swift | 9 +++++ ComputerSolitaire/ComputerSolitaireApp.swift | 36 ++++++++++++++++++++ ComputerSolitaire/Views/ContentView.swift | 34 +++++++++++++++++- 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/ComputerSolitaire/CommandNotifications.swift b/ComputerSolitaire/CommandNotifications.swift index 60b3162..3d6b5f8 100644 --- a/ComputerSolitaire/CommandNotifications.swift +++ b/ComputerSolitaire/CommandNotifications.swift @@ -1,7 +1,16 @@ import Foundation +enum GameCommand { + case newGame + case redeal + case undo + case autoFinish + case hint +} + extension Notification.Name { static let openSettings = Notification.Name("openSettings") static let openRulesAndScoring = Notification.Name("openRulesAndScoring") static let openStatistics = Notification.Name("openStatistics") + static let gameCommand = Notification.Name("gameCommand") } diff --git a/ComputerSolitaire/ComputerSolitaireApp.swift b/ComputerSolitaire/ComputerSolitaireApp.swift index 7883917..a93d3a5 100644 --- a/ComputerSolitaire/ComputerSolitaireApp.swift +++ b/ComputerSolitaire/ComputerSolitaireApp.swift @@ -59,6 +59,42 @@ struct ComputerSolitaireApp: App { Label("Rules & Scoring", systemImage: "book") } } + CommandMenu("Game") { + Button { + NotificationCenter.default.post(name: .gameCommand, object: GameCommand.newGame) + } label: { + Label("New Game", systemImage: "plus") + } + .keyboardShortcut("n", modifiers: [.command, .shift]) + Button { + NotificationCenter.default.post(name: .gameCommand, object: GameCommand.redeal) + } label: { + Label("Redeal", systemImage: "arrow.clockwise") + } + Divider() + Button { + NotificationCenter.default.post(name: .gameCommand, object: GameCommand.undo) + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + } + .keyboardShortcut("z", modifiers: .command) + Button { + NotificationCenter.default.post(name: .gameCommand, object: GameCommand.autoFinish) + } label: { + Label("Auto Finish", systemImage: "bolt") + } + Button { + NotificationCenter.default.post(name: .gameCommand, object: GameCommand.hint) + } label: { + Label("Hint", systemImage: "lightbulb") + } + Divider() + Button { + NotificationCenter.default.post(name: .openStatistics, object: nil) + } label: { + Label("Statistics…", systemImage: "chart.bar") + } + } #endif CommandGroup(replacing: .appSettings) { Button { diff --git a/ComputerSolitaire/Views/ContentView.swift b/ComputerSolitaire/Views/ContentView.swift index 1235e3b..46f229d 100644 --- a/ComputerSolitaire/Views/ContentView.swift +++ b/ComputerSolitaire/Views/ContentView.swift @@ -306,7 +306,7 @@ struct ContentView: View { } private func applyObservers(to view: AnyView) -> AnyView { - AnyView( + let commandObservedView = AnyView( view .onReceive(NotificationCenter.default.publisher(for: .openSettings)) { _ in isShowingSettings = true @@ -317,6 +317,14 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .openStatistics)) { _ in isShowingStats = true } + .onReceive(NotificationCenter.default.publisher(for: .gameCommand)) { notification in + guard let command = notification.object as? GameCommand else { return } + handleGameCommand(command) + } + ) + + let gameStateObservedView = AnyView( + commandObservedView .onChange(of: drawModeRawValue) { (_, newValue: Int) in let mode = DrawMode(rawValue: newValue) ?? .three viewModel.updateDrawMode(mode) @@ -362,6 +370,10 @@ struct ContentView: View { processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } + ) + + return AnyView( + gameStateObservedView .onChange(of: scenePhase) { _, _ in syncLifecyclePauseState() } @@ -656,6 +668,26 @@ struct ContentView: View { || isWinCascadeAnimating } + private func handleGameCommand(_ command: GameCommand) { + switch command { + case .newGame: + startNewGameFromUI() + case .redeal: + redealFromUI() + case .undo: + stopAutoFinish() + beginUndoAnimationIfNeeded() + case .autoFinish: + if isAutoFinishing { + stopAutoFinish() + } else { + startAutoFinish() + } + case .hint: + triggerHint() + } + } + private func triggerHint() { guard !isHintDisabled else { return } stopAutoFinish() From e4923ce8c347a25860e14aa370efafcefd9c34fb Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 22 Feb 2026 23:59:41 -0800 Subject: [PATCH 2/4] Refactor Game menu to focused scene commands Replace the NotificationCenter-based game commands with a macOS Commands implementation using FocusedValues. Added GameMenuCommands.swift that defines GameMenuActions and GameMenuState and provides a Game CommandMenu with proper enable/disable logic and keyboard shortcuts. Removed the GameCommand enum and related notification names (openStatistics, gameCommand) from CommandNotifications. ComputerSolitaireApp now uses GameMenuCommands() instead of the inline CommandMenu. ContentView no longer listens for game/statistics notifications; it exposes focusedSceneValue entries for gameMenuActions and gameMenuState and supplies computed action/state wrappers to wire up menu behavior. --- ComputerSolitaire/CommandNotifications.swift | 10 -- ComputerSolitaire/ComputerSolitaireApp.swift | 37 +------- ComputerSolitaire/GameMenuCommands.swift | 97 ++++++++++++++++++++ ComputerSolitaire/Views/ContentView.swift | 57 +++++++----- 4 files changed, 130 insertions(+), 71 deletions(-) create mode 100644 ComputerSolitaire/GameMenuCommands.swift diff --git a/ComputerSolitaire/CommandNotifications.swift b/ComputerSolitaire/CommandNotifications.swift index 3d6b5f8..608dfe9 100644 --- a/ComputerSolitaire/CommandNotifications.swift +++ b/ComputerSolitaire/CommandNotifications.swift @@ -1,16 +1,6 @@ import Foundation -enum GameCommand { - case newGame - case redeal - case undo - case autoFinish - case hint -} - extension Notification.Name { static let openSettings = Notification.Name("openSettings") static let openRulesAndScoring = Notification.Name("openRulesAndScoring") - static let openStatistics = Notification.Name("openStatistics") - static let gameCommand = Notification.Name("gameCommand") } diff --git a/ComputerSolitaire/ComputerSolitaireApp.swift b/ComputerSolitaire/ComputerSolitaireApp.swift index a93d3a5..58c7142 100644 --- a/ComputerSolitaire/ComputerSolitaireApp.swift +++ b/ComputerSolitaire/ComputerSolitaireApp.swift @@ -59,42 +59,7 @@ struct ComputerSolitaireApp: App { Label("Rules & Scoring", systemImage: "book") } } - CommandMenu("Game") { - Button { - NotificationCenter.default.post(name: .gameCommand, object: GameCommand.newGame) - } label: { - Label("New Game", systemImage: "plus") - } - .keyboardShortcut("n", modifiers: [.command, .shift]) - Button { - NotificationCenter.default.post(name: .gameCommand, object: GameCommand.redeal) - } label: { - Label("Redeal", systemImage: "arrow.clockwise") - } - Divider() - Button { - NotificationCenter.default.post(name: .gameCommand, object: GameCommand.undo) - } label: { - Label("Undo", systemImage: "arrow.uturn.backward") - } - .keyboardShortcut("z", modifiers: .command) - Button { - NotificationCenter.default.post(name: .gameCommand, object: GameCommand.autoFinish) - } label: { - Label("Auto Finish", systemImage: "bolt") - } - Button { - NotificationCenter.default.post(name: .gameCommand, object: GameCommand.hint) - } label: { - Label("Hint", systemImage: "lightbulb") - } - Divider() - Button { - NotificationCenter.default.post(name: .openStatistics, object: nil) - } label: { - Label("Statistics…", systemImage: "chart.bar") - } - } + GameMenuCommands() #endif CommandGroup(replacing: .appSettings) { Button { diff --git a/ComputerSolitaire/GameMenuCommands.swift b/ComputerSolitaire/GameMenuCommands.swift new file mode 100644 index 0000000..ed838e1 --- /dev/null +++ b/ComputerSolitaire/GameMenuCommands.swift @@ -0,0 +1,97 @@ +import SwiftUI + +#if os(macOS) +struct GameMenuActions { + var newGame: () -> Void + var redeal: () -> Void + var undo: () -> Void + var autoFinish: () -> Void + var hint: () -> Void + var showStatistics: () -> Void +} + +struct GameMenuState { + var canUndo: Bool + var canAutoFinish: Bool + var canHint: Bool + var isAutoFinishing: Bool +} + +private struct GameMenuActionsFocusedKey: FocusedValueKey { + typealias Value = GameMenuActions +} + +private struct GameMenuStateFocusedKey: FocusedValueKey { + typealias Value = GameMenuState +} + +extension FocusedValues { + var gameMenuActions: GameMenuActions? { + get { self[GameMenuActionsFocusedKey.self] } + set { self[GameMenuActionsFocusedKey.self] = newValue } + } + + var gameMenuState: GameMenuState? { + get { self[GameMenuStateFocusedKey.self] } + set { self[GameMenuStateFocusedKey.self] = newValue } + } +} + +struct GameMenuCommands: Commands { + @FocusedValue(\.gameMenuActions) private var actions + @FocusedValue(\.gameMenuState) private var state + + var body: some Commands { + CommandMenu("Game") { + Button { + actions?.newGame() + } label: { + Label("New Game", systemImage: "plus") + } + .keyboardShortcut("n", modifiers: [.command, .shift]) + .disabled(actions == nil) + + Button { + actions?.redeal() + } label: { + Label("Redeal", systemImage: "arrow.clockwise") + } + .disabled(actions == nil) + + Divider() + + Button { + actions?.undo() + } label: { + Label("Undo", systemImage: "arrow.uturn.backward") + } + .keyboardShortcut("z", modifiers: [.command, .option]) + .disabled(!(state?.canUndo ?? false)) + + Button { + actions?.autoFinish() + } label: { + Label(state?.isAutoFinishing == true ? "Stop Auto Finish" : "Auto Finish", systemImage: "bolt") + } + .disabled(!(state?.canAutoFinish ?? false)) + + Button { + actions?.hint() + } label: { + Label("Hint", systemImage: "lightbulb") + } + .keyboardShortcut("h", modifiers: [.command, .shift]) + .disabled(!(state?.canHint ?? false)) + + Divider() + + Button { + actions?.showStatistics() + } label: { + Label("Statistics…", systemImage: "chart.bar") + } + .disabled(actions == nil) + } + } +} +#endif diff --git a/ComputerSolitaire/Views/ContentView.swift b/ComputerSolitaire/Views/ContentView.swift index 46f229d..7aa4c92 100644 --- a/ComputerSolitaire/Views/ContentView.swift +++ b/ComputerSolitaire/Views/ContentView.swift @@ -252,7 +252,6 @@ struct ContentView: View { Label("Hint", systemImage: "lightbulb") } .help("Hint") - .keyboardShortcut("h", modifiers: []) .disabled(isHintDisabled) } } @@ -314,13 +313,6 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .openRulesAndScoring)) { _ in presentRulesAndScoring(initialSection: .rules) } - .onReceive(NotificationCenter.default.publisher(for: .openStatistics)) { _ in - isShowingStats = true - } - .onReceive(NotificationCenter.default.publisher(for: .gameCommand)) { notification in - guard let command = notification.object as? GameCommand else { return } - handleGameCommand(command) - } ) let gameStateObservedView = AnyView( @@ -389,6 +381,10 @@ struct ContentView: View { winCelebration.cancelTask() persistGameNow() } +#if os(macOS) + .focusedSceneValue(\.gameMenuActions, gameMenuActions) + .focusedSceneValue(\.gameMenuState, gameMenuState) +#endif ) } @@ -668,26 +664,37 @@ struct ContentView: View { || isWinCascadeAnimating } - private func handleGameCommand(_ command: GameCommand) { - switch command { - case .newGame: - startNewGameFromUI() - case .redeal: - redealFromUI() - case .undo: - stopAutoFinish() - beginUndoAnimationIfNeeded() - case .autoFinish: - if isAutoFinishing { +#if os(macOS) + private var gameMenuActions: GameMenuActions { + GameMenuActions( + newGame: startNewGameFromUI, + redeal: redealFromUI, + undo: { stopAutoFinish() - } else { - startAutoFinish() - } - case .hint: - triggerHint() - } + beginUndoAnimationIfNeeded() + }, + autoFinish: { + if isAutoFinishing { + stopAutoFinish() + } else { + startAutoFinish() + } + }, + hint: triggerHint, + showStatistics: { isShowingStats = true } + ) } + private var gameMenuState: GameMenuState { + GameMenuState( + canUndo: !isUndoDisabled, + canAutoFinish: isAutoFinishing || !isAutoFinishDisabled, + canHint: !isHintDisabled, + isAutoFinishing: isAutoFinishing + ) + } +#endif + private func triggerHint() { guard !isHintDisabled else { return } stopAutoFinish() From e07a05c103b607b2dbb41bbaaba79761f0dba46e Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 23 Feb 2026 00:21:47 -0800 Subject: [PATCH 3/4] Refactor macOS toolbar in ContentView Rework the toolbar implementation for macOS in ContentView: wrap the view with .toolbar consistently, introduce ToolbarSpacer(.flexible) and .fixed spacers, convert individual ToolbarItem placements into ToolbarItemGroup(.primaryAction), and simplify button declarations to use Label with .labelStyle for consistent icon+title presentation. The hint button is now conditionally included inside the group and redundant ToolbarItem wrappers were removed to tidy up layout and improve alignment/appearance. --- ComputerSolitaire/Views/ContentView.swift | 29 ++++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/ComputerSolitaire/Views/ContentView.swift b/ComputerSolitaire/Views/ContentView.swift index 7aa4c92..4c6d904 100644 --- a/ComputerSolitaire/Views/ContentView.swift +++ b/ComputerSolitaire/Views/ContentView.swift @@ -172,7 +172,8 @@ struct ContentView: View { private func applyToolbar(to view: AnyView) -> AnyView { AnyView( - view.toolbar { + view + .toolbar { #if os(iOS) ToolbarItemGroup(placement: .bottomBar) { Menu { @@ -216,15 +217,23 @@ struct ContentView: View { } #endif #if os(macOS) - ToolbarItemGroup(placement: .automatic) { - Button("New Game") { + ToolbarSpacer(.flexible) + ToolbarItemGroup(placement: .primaryAction) { + Button { startNewGameFromUI() + } label: { + Label("New Game", systemImage: "plus") + .labelStyle(.titleAndIcon) } - Button("Redeal") { + Button { redealFromUI() + } label: { + Label("Redeal", systemImage: "arrow.clockwise") + .labelStyle(.titleAndIcon) } } - ToolbarItem(placement: .automatic) { + ToolbarSpacer(.fixed) + ToolbarItemGroup(placement: .primaryAction) { Button { stopAutoFinish() beginUndoAnimationIfNeeded() @@ -234,8 +243,6 @@ struct ContentView: View { .labelStyle(.iconOnly) .help("Undo") .disabled(isUndoDisabled) - } - ToolbarItem(placement: .automatic) { Button { startAutoFinish() } label: { @@ -243,9 +250,7 @@ struct ContentView: View { } .help("Auto Finish") .disabled(isAutoFinishDisabled) - } - if isHintButtonVisible { - ToolbarItem(placement: .automatic) { + if isHintButtonVisible { Button { triggerHint() } label: { @@ -254,16 +259,12 @@ struct ContentView: View { .help("Hint") .disabled(isHintDisabled) } - } - ToolbarItem(placement: .primaryAction) { Button { isShowingStats = true } label: { Label("Statistics", systemImage: "chart.bar") } .help("Statistics") - } - ToolbarItem(placement: .primaryAction) { Button { isShowingSettings = true } label: { From 68d8b47e523ef55818eb6c306d0886775fe0616e Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 23 Feb 2026 00:31:10 -0800 Subject: [PATCH 4/4] Conditionally show Hint item in menu bar --- ComputerSolitaire.xcodeproj/project.pbxproj | 4 ++-- ComputerSolitaire/GameMenuCommands.swift | 15 +++++++++------ ComputerSolitaire/Views/ContentView.swift | 1 + 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/ComputerSolitaire.xcodeproj/project.pbxproj b/ComputerSolitaire.xcodeproj/project.pbxproj index d588227..d5c429e 100644 --- a/ComputerSolitaire.xcodeproj/project.pbxproj +++ b/ComputerSolitaire.xcodeproj/project.pbxproj @@ -245,7 +245,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.5.0; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; @@ -295,7 +295,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.2; - MARKETING_VERSION = 0.5.0; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = crapshack.ComputerSolitaire; PRODUCT_NAME = "Computer Solitaire"; REGISTER_APP_GROUPS = YES; diff --git a/ComputerSolitaire/GameMenuCommands.swift b/ComputerSolitaire/GameMenuCommands.swift index ed838e1..9c5d691 100644 --- a/ComputerSolitaire/GameMenuCommands.swift +++ b/ComputerSolitaire/GameMenuCommands.swift @@ -14,6 +14,7 @@ struct GameMenuState { var canUndo: Bool var canAutoFinish: Bool var canHint: Bool + var isHintVisible: Bool var isAutoFinishing: Bool } @@ -75,13 +76,15 @@ struct GameMenuCommands: Commands { } .disabled(!(state?.canAutoFinish ?? false)) - Button { - actions?.hint() - } label: { - Label("Hint", systemImage: "lightbulb") + if state?.isHintVisible ?? false { + Button { + actions?.hint() + } label: { + Label("Hint", systemImage: "lightbulb") + } + .keyboardShortcut("h", modifiers: [.command, .shift]) + .disabled(!(state?.canHint ?? false)) } - .keyboardShortcut("h", modifiers: [.command, .shift]) - .disabled(!(state?.canHint ?? false)) Divider() diff --git a/ComputerSolitaire/Views/ContentView.swift b/ComputerSolitaire/Views/ContentView.swift index 4c6d904..6ce28ef 100644 --- a/ComputerSolitaire/Views/ContentView.swift +++ b/ComputerSolitaire/Views/ContentView.swift @@ -691,6 +691,7 @@ struct ContentView: View { canUndo: !isUndoDisabled, canAutoFinish: isAutoFinishing || !isAutoFinishDisabled, canHint: !isHintDisabled, + isHintVisible: isHintButtonVisible, isAutoFinishing: isAutoFinishing ) }