crossmate

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

commit 3c0b63ebe81f1323a25cc1b37934a7bbd13105bf
parent 1da61558ecc3bc3ebf3de174725d65beab54b35b
Author: Michael Camilleri <[email protected]>
Date:   Wed, 22 Jul 2026 17:36:57 +0900

Simplify answer-end movement settings

The separate wrapping and next-answer controls overlapped, so their
precedence made the cursor's behaviour difficult to predict (and
confusing to explain).

This commit replaces these options with one 'After Last Fill' menu whose
choices are exclusive. 'Wrap in Current Answer' follows 'Skip Filled
Squares': it returns to the first blank when skipping is enabled,
returns to the first square when it is disabled, and stays put when no
eligible blank remains. 'Move to Next Answer' continues directly to the
next answer.

The new synced preference defaults to moving to the next answer and migrates
the earlier wrapping choice.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/Models/PlayerPreferences.swift | 59+++++++++++++++++++++++++++++++++++------------------------
MCrossmate/Models/PlayerSession.swift | 21++++++++++++++-------
MCrossmate/Views/Settings/SettingsView.swift | 27++++++++++++++++++++++-----
MTests/Unit/PlayerPreferencesTests.swift | 23+++++++++++++++--------
MTests/Unit/PlayerSessionNavigationTests.swift | 37++++++++++++++++++++++++++++---------
5 files changed, 114 insertions(+), 53 deletions(-)

diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift @@ -15,6 +15,11 @@ import SwiftUI @Observable @MainActor final class PlayerPreferences { + enum AnswerEndMovement: String { + case wrapInCurrentAnswer + case moveToNextAnswer + } + private enum Keys { static let colorID = "playerColorID" static let name = "playerName" @@ -25,8 +30,9 @@ final class PlayerPreferences { static let notifiesCompletions = "notifiesCompletions" static let notifiesInvitations = "notifiesInvitations" static let skipsFilledCells = "skipsFilledCells" + 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 @@ -89,16 +95,11 @@ final class PlayerPreferences { didSet { write(Keys.skipsFilledCells, skipsFilledCells) } } - /// Whether reaching the end of an incomplete word returns to its first - /// blank. This takes priority over moving to the next clue. - var wrapsToFirstBlank: Bool { - didSet { write(Keys.wrapsToFirstBlank, wrapsToFirstBlank) } - } - - /// Whether reaching the end of a word moves to the next clue when no wrap - /// target takes priority. - var movesToNextClue: Bool { - didSet { write(Keys.movesToNextClue, movesToNextClue) } + /// Where typing at the final square of an answer continues. When wrapping, + /// `skipsFilledCells` decides whether the destination is the first square + /// or the first blank square. + var answerEndMovement: AnswerEndMovement { + didSet { write(Keys.answerEndMovement, answerEndMovement.rawValue) } } init( @@ -123,12 +124,17 @@ final class PlayerPreferences { self.skipsFilledCells = cloud?.object(forKey: Keys.skipsFilledCells) as? Bool ?? local.object(forKey: Keys.skipsFilledCells) as? Bool ?? true - self.wrapsToFirstBlank = cloud?.object(forKey: Keys.wrapsToFirstBlank) as? Bool - ?? local.object(forKey: Keys.wrapsToFirstBlank) as? Bool - ?? true - self.movesToNextClue = cloud?.object(forKey: Keys.movesToNextClue) as? Bool - ?? local.object(forKey: Keys.movesToNextClue) as? Bool - ?? true + let storedAnswerEndMovement = cloud?.string(forKey: Keys.answerEndMovement) + ?? local.string(forKey: Keys.answerEndMovement) + if let storedAnswerEndMovement, + let movement = AnswerEndMovement(rawValue: storedAnswerEndMovement) { + self.answerEndMovement = movement + } else { + let legacyWraps = cloud?.object(forKey: Keys.wrapsToFirstBlank) as? Bool + ?? local.object(forKey: Keys.wrapsToFirstBlank) as? Bool + ?? false + self.answerEndMovement = legacyWraps ? .wrapInCurrentAnswer : .moveToNextAnswer + } guard let cloud else { return } cloud.synchronize() NotificationCenter.default.addObserver( @@ -176,13 +182,18 @@ final class PlayerPreferences { newSkipsFilledCells != skipsFilledCells { skipsFilledCells = newSkipsFilledCells } - if let newWrapsToFirstBlank = cloud.object(forKey: Keys.wrapsToFirstBlank) as? Bool, - newWrapsToFirstBlank != wrapsToFirstBlank { - wrapsToFirstBlank = newWrapsToFirstBlank - } - if let newMovesToNextClue = cloud.object(forKey: Keys.movesToNextClue) as? Bool, - newMovesToNextClue != movesToNextClue { - movesToNextClue = newMovesToNextClue + if let rawMovement = cloud.string(forKey: Keys.answerEndMovement), + 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 + if migratedMovement != answerEndMovement { + answerEndMovement = migratedMovement + } } } diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -654,8 +654,8 @@ final class PlayerSession { } /// Advances after typing, optionally walking past entries already supplied - /// by this player or a collaborator. At the word's end, wrapping to an - /// earlier blank takes priority over advancing to the next clue. + /// by this player or a collaborator. At the answer's end, the movement + /// preference either wraps within the answer or advances to the next one. private func advanceAfterEntry() { let (dr, dc) = step(for: direction) if preferences?.skipsFilledCells ?? true { @@ -680,11 +680,18 @@ final class PlayerSession { } } - if preferences?.wrapsToFirstBlank ?? true, - let blank = currentWordCells().first(where: { isBlankCell(row: $0.row, col: $0.col) }) { - selectedRow = blank.row - selectedCol = blank.col - } else if preferences?.movesToNextClue ?? true { + switch preferences?.answerEndMovement ?? .moveToNextAnswer { + case .wrapInCurrentAnswer: + let destination = if preferences?.skipsFilledCells ?? true { + currentWordCells().first(where: { isBlankCell(row: $0.row, col: $0.col) }) + } else { + currentWordCells().first + } + if let destination { + selectedRow = destination.row + selectedCol = destination.col + } + case .moveToNextAnswer: moveClue(by: +1) } } diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift @@ -77,7 +77,7 @@ struct SettingsView: View { } header: { Text("Notifications") } footer: { - Text("These settings apply only to this device.") + Text("Notification settings apply only to this device.") } Section("Sharing") { @@ -220,12 +220,29 @@ private struct MovementSettingsSection: View { var body: some View { Section { Toggle("Skip Filled Squares", isOn: $preferences.skipsFilledCells) - Toggle("Wrap to First Blank", isOn: $preferences.wrapsToFirstBlank) - Toggle("Move to Next Clue", isOn: $preferences.movesToNextClue) + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text("After Last Fill") + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + + Picker("After Last Fill", selection: $preferences.answerEndMovement) { + Text("Wrap in Current Answer") + .tag(PlayerPreferences.AnswerEndMovement.wrapInCurrentAnswer) + Text("Move to Next Answer") + .tag(PlayerPreferences.AnswerEndMovement.moveToNextAnswer) + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize(horizontal: true, vertical: false) + } + + Text("Sets behaviour after filling in the last empty square in an answer.") + .font(.footnote) + .foregroundStyle(.secondary) + } } header: { Text("Movement") - } footer: { - Text("At the end of a word, wrapping takes priority over moving to the next clue.") } } } diff --git a/Tests/Unit/PlayerPreferencesTests.swift b/Tests/Unit/PlayerPreferencesTests.swift @@ -6,15 +6,14 @@ import Testing @Suite("Player preferences", .serialized) @MainActor struct PlayerPreferencesTests { - @Test("Movement preferences default on") - func movementPreferencesDefaultOn() throws { + @Test("Movement preferences have expected defaults") + func movementPreferencesHaveExpectedDefaults() throws { let defaults = try makeDefaults() let preferences = PlayerPreferences(local: defaults, cloud: nil) #expect(preferences.skipsFilledCells) - #expect(preferences.wrapsToFirstBlank) - #expect(preferences.movesToNextClue) + #expect(preferences.answerEndMovement == .moveToNextAnswer) } @Test("Movement preferences persist locally") @@ -23,13 +22,21 @@ struct PlayerPreferencesTests { let preferences = PlayerPreferences(local: defaults, cloud: nil) preferences.skipsFilledCells = false - preferences.wrapsToFirstBlank = false - preferences.movesToNextClue = false + preferences.answerEndMovement = .wrapInCurrentAnswer let restored = PlayerPreferences(local: defaults, cloud: nil) #expect(!restored.skipsFilledCells) - #expect(!restored.wrapsToFirstBlank) - #expect(!restored.movesToNextClue) + #expect(restored.answerEndMovement == .wrapInCurrentAnswer) + } + + @Test("Legacy wrapping preference migrates to answer-end movement") + func legacyWrappingPreferenceMigrates() throws { + let defaults = try makeDefaults() + defaults.set(true, forKey: "wrapsToFirstBlank") + + let preferences = PlayerPreferences(local: defaults, cloud: nil) + + #expect(preferences.answerEndMovement == .wrapInCurrentAnswer) } private func makeDefaults() throws -> UserDefaults { diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift @@ -132,8 +132,10 @@ struct PlayerSessionNavigationTests { @Test("Typing at word end wraps to its first blank") func typingAtWordEndWrapsToFirstBlank() throws { - let session = try makeNavigationSession() - session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false) + let preferences = try makePreferences() + preferences.answerEndMovement = .wrapInCurrentAnswer + let session = try makeNavigationSession(preferences: preferences) + session.mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) session.select(row: 0, col: 2) session.setDirection(.across) @@ -141,13 +143,13 @@ struct PlayerSessionNavigationTests { #expect(session.direction == .across) #expect(session.selectedRow == 0) - #expect(session.selectedCol == 0) + #expect(session.selectedCol == 1) } - @Test("Disabling wrap allows word-end movement with an earlier blank") - func disablingWrapAllowsNextClue() throws { + @Test("Moving to the next answer leaves an earlier blank behind") + func movingToNextAnswerLeavesEarlierBlank() throws { let preferences = try makePreferences() - preferences.wrapsToFirstBlank = false + preferences.answerEndMovement = .moveToNextAnswer let session = try makeNavigationSession(preferences: preferences) session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false) session.select(row: 0, col: 2) @@ -161,10 +163,10 @@ struct PlayerSessionNavigationTests { #expect(session.selectedCol == 0) } - @Test("Disabling next-clue movement leaves cursor at completed word end") - func disablingNextClueMovementStaysAtWordEnd() throws { + @Test("Wrapping with skipping stays put when no blanks remain") + func wrappingWithSkippingStaysPutWithoutBlanks() throws { let preferences = try makePreferences() - preferences.movesToNextClue = false + preferences.answerEndMovement = .wrapInCurrentAnswer let session = try makeNavigationSession(preferences: preferences) session.mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false) session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false) @@ -178,6 +180,23 @@ struct PlayerSessionNavigationTests { #expect(session.selectedCol == 2) } + @Test("Wrapping without skipping returns to the first square") + func wrappingWithoutSkippingReturnsToFirstSquare() throws { + let preferences = try makePreferences() + preferences.skipsFilledCells = false + preferences.answerEndMovement = .wrapInCurrentAnswer + let session = try makeNavigationSession(preferences: preferences) + session.mutator.setLetter("A", atRow: 0, atCol: 0, 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 == 0) + } + @Test("Next clue selects its first blank square") func nextClueSelectsFirstBlankSquare() throws { let session = try makeNavigationSession()