commit 73edb19b291887f85dbd4d1127eab610dddc905b
parent 3c0b63ebe81f1323a25cc1b37934a7bbd13105bf
Author: Michael Camilleri <[email protected]>
Date: Wed, 22 Jul 2026 17:55:25 +0900
Add a stationary answer-end option
The 'After Last Fill' menu required the cursor either to wrap or
advance, leaving no explicit way to keep it on the square just entered.
This commit adds 'Do Nothing' and leaves the cursor in place when
selected. Users who had disabled both older movement options are
migrated to 'Do Nothing', preserving their existing behaviour.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
5 files changed, 59 insertions(+), 10 deletions(-)
diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift
@@ -16,6 +16,7 @@ import SwiftUI
@MainActor
final class PlayerPreferences {
enum AnswerEndMovement: String {
+ case doNothing
case wrapInCurrentAnswer
case moveToNextAnswer
}
@@ -33,6 +34,7 @@ final class PlayerPreferences {
static let answerEndMovement = "answerEndMovement"
// Retained only to migrate the earlier pair of movement toggles.
static let wrapsToFirstBlank = "wrapsToFirstBlank"
+ static let movesToNextClue = "movesToNextClue"
}
private let local: UserDefaults
@@ -132,8 +134,12 @@ final class PlayerPreferences {
} else {
let legacyWraps = cloud?.object(forKey: Keys.wrapsToFirstBlank) as? Bool
?? local.object(forKey: Keys.wrapsToFirstBlank) as? Bool
- ?? false
- self.answerEndMovement = legacyWraps ? .wrapInCurrentAnswer : .moveToNextAnswer
+ let legacyMoves = cloud?.object(forKey: Keys.movesToNextClue) as? Bool
+ ?? local.object(forKey: Keys.movesToNextClue) as? Bool
+ self.answerEndMovement = Self.migratedAnswerEndMovement(
+ wraps: legacyWraps,
+ moves: legacyMoves
+ )
}
guard let cloud else { return }
cloud.synchronize()
@@ -186,17 +192,29 @@ final class PlayerPreferences {
let newAnswerEndMovement = AnswerEndMovement(rawValue: rawMovement),
newAnswerEndMovement != answerEndMovement {
answerEndMovement = newAnswerEndMovement
- } else if cloud.object(forKey: Keys.answerEndMovement) == nil,
- let legacyWraps = cloud.object(forKey: Keys.wrapsToFirstBlank) as? Bool {
- let migratedMovement: AnswerEndMovement = legacyWraps
- ? .wrapInCurrentAnswer
- : .moveToNextAnswer
+ } else if cloud.object(forKey: Keys.answerEndMovement) == nil {
+ let legacyWraps = cloud.object(forKey: Keys.wrapsToFirstBlank) as? Bool
+ let legacyMoves = cloud.object(forKey: Keys.movesToNextClue) as? Bool
+ guard legacyWraps != nil || legacyMoves != nil else { return }
+ let migratedMovement = Self.migratedAnswerEndMovement(
+ wraps: legacyWraps,
+ moves: legacyMoves
+ )
if migratedMovement != answerEndMovement {
answerEndMovement = migratedMovement
}
}
}
+ private static func migratedAnswerEndMovement(
+ wraps: Bool?,
+ moves: Bool?
+ ) -> AnswerEndMovement {
+ if wraps == true { return .wrapInCurrentAnswer }
+ if moves == false { return .doNothing }
+ return .moveToNextAnswer
+ }
+
private static func isUsableName(_ name: String) -> Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -681,6 +681,8 @@ final class PlayerSession {
}
switch preferences?.answerEndMovement ?? .moveToNextAnswer {
+ case .doNothing:
+ break
case .wrapInCurrentAnswer:
let destination = if preferences?.skipsFilledCells ?? true {
currentWordCells().first(where: { isBlankCell(row: $0.row, col: $0.col) })
diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift
@@ -227,6 +227,8 @@ private struct MovementSettingsSection: View {
.fixedSize(horizontal: false, vertical: true)
Picker("After Last Fill", selection: $preferences.answerEndMovement) {
+ Text("Do Nothing")
+ .tag(PlayerPreferences.AnswerEndMovement.doNothing)
Text("Wrap in Current Answer")
.tag(PlayerPreferences.AnswerEndMovement.wrapInCurrentAnswer)
Text("Move to Next Answer")
@@ -237,7 +239,7 @@ private struct MovementSettingsSection: View {
.fixedSize(horizontal: true, vertical: false)
}
- Text("Sets behaviour after filling in the last empty square in an answer.")
+ Text("Sets behaviour after filling in the last square in an answer.")
.font(.footnote)
.foregroundStyle(.secondary)
}
diff --git a/Tests/Unit/PlayerPreferencesTests.swift b/Tests/Unit/PlayerPreferencesTests.swift
@@ -22,11 +22,11 @@ struct PlayerPreferencesTests {
let preferences = PlayerPreferences(local: defaults, cloud: nil)
preferences.skipsFilledCells = false
- preferences.answerEndMovement = .wrapInCurrentAnswer
+ preferences.answerEndMovement = .doNothing
let restored = PlayerPreferences(local: defaults, cloud: nil)
#expect(!restored.skipsFilledCells)
- #expect(restored.answerEndMovement == .wrapInCurrentAnswer)
+ #expect(restored.answerEndMovement == .doNothing)
}
@Test("Legacy wrapping preference migrates to answer-end movement")
@@ -39,6 +39,17 @@ struct PlayerPreferencesTests {
#expect(preferences.answerEndMovement == .wrapInCurrentAnswer)
}
+ @Test("Legacy disabled movement preferences migrate to do nothing")
+ func legacyDisabledMovementPreferencesMigrate() throws {
+ let defaults = try makeDefaults()
+ defaults.set(false, forKey: "wrapsToFirstBlank")
+ defaults.set(false, forKey: "movesToNextClue")
+
+ let preferences = PlayerPreferences(local: defaults, cloud: nil)
+
+ #expect(preferences.answerEndMovement == .doNothing)
+ }
+
private func makeDefaults() throws -> UserDefaults {
try #require(UserDefaults(suiteName: "test-pref-\(UUID().uuidString)"))
}
diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift
@@ -163,6 +163,22 @@ struct PlayerSessionNavigationTests {
#expect(session.selectedCol == 0)
}
+ @Test("Doing nothing leaves the cursor on the last fill")
+ func doingNothingLeavesCursorOnLastFill() throws {
+ let preferences = try makePreferences()
+ preferences.answerEndMovement = .doNothing
+ let session = try makeNavigationSession(preferences: preferences)
+ session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
+ session.select(row: 0, col: 2)
+ session.setDirection(.across)
+
+ session.enter("C")
+
+ #expect(session.direction == .across)
+ #expect(session.selectedRow == 0)
+ #expect(session.selectedCol == 2)
+ }
+
@Test("Wrapping with skipping stays put when no blanks remain")
func wrappingWithSkippingStaysPutWithoutBlanks() throws {
let preferences = try makePreferences()