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/CommandNotifications.swift b/ComputerSolitaire/CommandNotifications.swift index 60b3162..608dfe9 100644 --- a/ComputerSolitaire/CommandNotifications.swift +++ b/ComputerSolitaire/CommandNotifications.swift @@ -3,5 +3,4 @@ import Foundation extension Notification.Name { static let openSettings = Notification.Name("openSettings") static let openRulesAndScoring = Notification.Name("openRulesAndScoring") - static let openStatistics = Notification.Name("openStatistics") } diff --git a/ComputerSolitaire/ComputerSolitaireApp.swift b/ComputerSolitaire/ComputerSolitaireApp.swift index 7883917..58c7142 100644 --- a/ComputerSolitaire/ComputerSolitaireApp.swift +++ b/ComputerSolitaire/ComputerSolitaireApp.swift @@ -59,6 +59,7 @@ struct ComputerSolitaireApp: App { Label("Rules & Scoring", systemImage: "book") } } + GameMenuCommands() #endif CommandGroup(replacing: .appSettings) { Button { diff --git a/ComputerSolitaire/GameMenuCommands.swift b/ComputerSolitaire/GameMenuCommands.swift new file mode 100644 index 0000000..9c5d691 --- /dev/null +++ b/ComputerSolitaire/GameMenuCommands.swift @@ -0,0 +1,100 @@ +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 isHintVisible: 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)) + + if state?.isHintVisible ?? 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 1235e3b..6ce28ef 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,28 +250,21 @@ struct ContentView: View { } .help("Auto Finish") .disabled(isAutoFinishDisabled) - } - if isHintButtonVisible { - ToolbarItem(placement: .automatic) { + if isHintButtonVisible { Button { triggerHint() } label: { Label("Hint", systemImage: "lightbulb") } .help("Hint") - .keyboardShortcut("h", modifiers: []) .disabled(isHintDisabled) } - } - ToolbarItem(placement: .primaryAction) { Button { isShowingStats = true } label: { Label("Statistics", systemImage: "chart.bar") } .help("Statistics") - } - ToolbarItem(placement: .primaryAction) { Button { isShowingSettings = true } label: { @@ -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 @@ -314,9 +314,10 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .openRulesAndScoring)) { _ in presentRulesAndScoring(initialSection: .rules) } - .onReceive(NotificationCenter.default.publisher(for: .openStatistics)) { _ in - isShowingStats = true - } + ) + + let gameStateObservedView = AnyView( + commandObservedView .onChange(of: drawModeRawValue) { (_, newValue: Int) in let mode = DrawMode(rawValue: newValue) ?? .three viewModel.updateDrawMode(mode) @@ -362,6 +363,10 @@ struct ContentView: View { processPendingAutoMoveIfPossible() queueAutoFinishStepIfPossible() } + ) + + return AnyView( + gameStateObservedView .onChange(of: scenePhase) { _, _ in syncLifecyclePauseState() } @@ -377,6 +382,10 @@ struct ContentView: View { winCelebration.cancelTask() persistGameNow() } +#if os(macOS) + .focusedSceneValue(\.gameMenuActions, gameMenuActions) + .focusedSceneValue(\.gameMenuState, gameMenuState) +#endif ) } @@ -656,6 +665,38 @@ struct ContentView: View { || isWinCascadeAnimating } +#if os(macOS) + private var gameMenuActions: GameMenuActions { + GameMenuActions( + newGame: startNewGameFromUI, + redeal: redealFromUI, + undo: { + stopAutoFinish() + beginUndoAnimationIfNeeded() + }, + autoFinish: { + if isAutoFinishing { + stopAutoFinish() + } else { + startAutoFinish() + } + }, + hint: triggerHint, + showStatistics: { isShowingStats = true } + ) + } + + private var gameMenuState: GameMenuState { + GameMenuState( + canUndo: !isUndoDisabled, + canAutoFinish: isAutoFinishing || !isAutoFinishDisabled, + canHint: !isHintDisabled, + isHintVisible: isHintButtonVisible, + isAutoFinishing: isAutoFinishing + ) + } +#endif + private func triggerHint() { guard !isHintDisabled else { return } stopAutoFinish()