crossmate

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

commit 8ce8a636ce38fec29dd8a653150e6c5140b64ef3
parent 1c9f85393580032a8b1e2eadc8eaf5836aa5b965
Author: Michael Camilleri <[email protected]>
Date:   Tue, 21 Jul 2026 22:54:54 +0900

Add word-end movement preferences

Crossmate could skip filled squares while typing, but the user could not
choose whether reaching a word's end revisited an earlier blank or
continued to the next clue.

This commit adds synced, default-on 'Wrap to First Blank' and 'Move to
Next Clue' controls alongside 'Skip Filled Squares' in a dedicated
Movement section. Wrapping takes priority, Word navigation avoids filled
and completed answers, and the explicit Letter controls remain literal.

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

Diffstat:
MCrossmate/Models/PlayerPreferences.swift | 32++++++++++++++++++++++++++++++--
MCrossmate/Models/PlayerSession.swift | 111++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
MCrossmate/Views/Settings/SettingsView.swift | 22++++++++++++++++++----
MTests/Unit/PlayerPreferencesTests.swift | 14++++++++++----
MTests/Unit/PlayerSessionNavigationTests.swift | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 284 insertions(+), 37 deletions(-)

diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift @@ -25,6 +25,8 @@ final class PlayerPreferences { static let notifiesCompletions = "notifiesCompletions" static let notifiesInvitations = "notifiesInvitations" static let skipsFilledCells = "skipsFilledCells" + static let wrapsToFirstBlank = "wrapsToFirstBlank" + static let movesToNextClue = "movesToNextClue" } private let local: UserDefaults @@ -81,12 +83,24 @@ final class PlayerPreferences { didSet { local.set(notifiesInvitations, forKey: Keys.notifiesInvitations) } } - /// Whether typing advances past cells that already contain an entry. This - /// follows the user across devices so cursor behaviour stays consistent. + /// Whether automatic cursor movement avoids cells that already contain an + /// entry. This follows the user across devices so behaviour stays consistent. var skipsFilledCells: Bool { 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) } + } + init( local: UserDefaults = .standard, cloud: NSUbiquitousKeyValueStore? = .default @@ -109,6 +123,12 @@ 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 guard let cloud else { return } cloud.synchronize() NotificationCenter.default.addObserver( @@ -156,6 +176,14 @@ 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 + } } private static func isUsableName(_ name: String) -> Bool { diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -264,7 +264,7 @@ final class PlayerSession { retreat() } - private func moveClue(by offset: Int) { + private func moveClue(by offset: Int, skipsFilledCells override: Bool? = nil) { // Walk every clue in order: all acrosses, then all downs. Stepping past // the last across rolls into the first down (and vice versa going // backwards), which matches how most crossword apps behave. @@ -275,12 +275,12 @@ final class PlayerSession { let currentIndex = ordered.firstIndex { $0.0 == direction && $0.1.number == currentNumber } ?? 0 - let count = ordered.count - let nextIndex = ((currentIndex + offset) % count + count) % count - - let (newDirection, newClue) = ordered[nextIndex] - direction = newDirection - moveToClueStart(number: newClue.number) + moveAmongClues( + ordered, + from: currentIndex, + by: offset, + skipsFilledCells: override ?? preferences?.skipsFilledCells ?? true + ) } private func orderedClues() -> [(Puzzle.Direction, Puzzle.Clue)] { @@ -293,9 +293,40 @@ final class PlayerSession { guard !clues.isEmpty else { return } let currentNumber = currentClueNumber() let currentIndex = clues.firstIndex { $0.number == currentNumber } ?? 0 + moveAmongClues( + clues.map { (direction, $0) }, + from: currentIndex, + by: offset, + skipsFilledCells: preferences?.skipsFilledCells ?? true + ) + } + + /// Moves through a clue sequence, skipping filled starting squares and + /// completed clues when that preference is enabled. A fully filled grid + /// has no eligible destination, so the cursor remains where it is. + private func moveAmongClues( + _ clues: [(Puzzle.Direction, Puzzle.Clue)], + from currentIndex: Int, + by offset: Int, + skipsFilledCells: Bool + ) { let count = clues.count - let nextIndex = ((currentIndex + offset) % count + count) % count - moveToClueStart(number: clues[nextIndex].number) + for distance in 1...count { + let index = ((currentIndex + offset * distance) % count + count) % count + let (newDirection, clue) = clues[index] + if skipsFilledCells { + guard let blank = firstBlankCell(direction: newDirection, number: clue.number) else { + continue + } + direction = newDirection + selectedRow = blank.row + selectedCol = blank.col + } else { + direction = newDirection + moveToClueStart(number: clue.number) + } + return + } } // MARK: - Check / Reveal / Clear @@ -606,37 +637,63 @@ final class PlayerSession { selectedRow = r selectedCol = c } else { - advanceToNextClue() + // The explicit next-letter control stays literal at a word + // boundary, just as the previous-letter control does: move to the + // immediately adjacent clue without filtering its destination. + moveClue(by: +1, skipsFilledCells: false) } } /// Advances after typing, optionally walking past entries already supplied - /// by this player or a collaborator. The walk stays within the current - /// answer; reaching its end preserves the existing next-clue behaviour. + /// by this player or a collaborator. At the word's end, wrapping to an + /// earlier blank takes priority over advancing to the next clue. private func advanceAfterEntry() { - guard preferences?.skipsFilledCells ?? true else { - advance() - return - } - let (dr, dc) = step(for: direction) - var row = selectedRow + dr - var col = selectedCol + dc - while isValid(row: row, col: col), !puzzle.cells[row][col].isBlock { - let cell = puzzle.cells[row][col] - if game.squares[row][col].entry.isEmpty, !cell.expectsBlank { + if preferences?.skipsFilledCells ?? true { + var row = selectedRow + dr + var col = selectedCol + dc + while isValid(row: row, col: col), !puzzle.cells[row][col].isBlock { + if isBlankCell(row: row, col: col) { + selectedRow = row + selectedCol = col + return + } + row += dr + col += dc + } + } else { + let row = selectedRow + dr + let col = selectedCol + dc + if isValid(row: row, col: col), !puzzle.cells[row][col].isBlock { selectedRow = row selectedCol = col return } - row += dr - col += dc } - advanceToNextClue() + + 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 { + moveClue(by: +1) + } } - private func advanceToNextClue() { - moveClue(by: +1) + private func firstBlankCell( + direction: Puzzle.Direction, + number: Int + ) -> Puzzle.Cell? { + guard let start = puzzle.cell(numbered: number) else { return nil } + return puzzle.wordCells( + atRow: start.row, + col: start.col, + direction: direction + ).first { isBlankCell(row: $0.row, col: $0.col) } + } + + private func isBlankCell(row: Int, col: Int) -> Bool { + game.squares[row][col].entry.isEmpty && !puzzle.cells[row][col].expectsBlank } private func retreat() { diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift @@ -66,6 +66,8 @@ struct SettingsView: View { } } + MovementSettingsSection(preferences: preferences) + Section { Toggle("Nudges", isOn: $preferences.notifiesNudges) Toggle("Pauses", isOn: $preferences.notifiesPauses) @@ -84,10 +86,6 @@ struct SettingsView: View { } } - Section("Advanced") { - Toggle("Skip Filled Cells", isOn: $preferences.skipsFilledCells) - } - if debugMode { Section("Debugging") { Toggle("Enable iCloud Sync", isOn: $preferences.isICloudSyncEnabled) @@ -216,6 +214,22 @@ struct SettingsView: View { } } +private struct MovementSettingsSection: View { + @Bindable var preferences: PlayerPreferences + + 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) + } header: { + Text("Movement") + } footer: { + Text("At the end of a word, wrapping takes priority over moving to the next clue.") + } + } +} + private struct BlockedUsersView: View { @Environment(\.appActions) private var appActions diff --git a/Tests/Unit/PlayerPreferencesTests.swift b/Tests/Unit/PlayerPreferencesTests.swift @@ -6,24 +6,30 @@ import Testing @Suite("Player preferences", .serialized) @MainActor struct PlayerPreferencesTests { - @Test("Skip filled cells defaults on") - func skipFilledCellsDefaultsOn() throws { + @Test("Movement preferences default on") + func movementPreferencesDefaultOn() throws { let defaults = try makeDefaults() let preferences = PlayerPreferences(local: defaults, cloud: nil) #expect(preferences.skipsFilledCells) + #expect(preferences.wrapsToFirstBlank) + #expect(preferences.movesToNextClue) } - @Test("Skip filled cells persists locally") - func skipFilledCellsPersistsLocally() throws { + @Test("Movement preferences persist locally") + func movementPreferencesPersistLocally() throws { let defaults = try makeDefaults() let preferences = PlayerPreferences(local: defaults, cloud: nil) preferences.skipsFilledCells = false + preferences.wrapsToFirstBlank = false + preferences.movesToNextClue = false let restored = PlayerPreferences(local: defaults, cloud: nil) #expect(!restored.skipsFilledCells) + #expect(!restored.wrapsToFirstBlank) + #expect(!restored.movesToNextClue) } private func makeDefaults() throws -> UserDefaults { diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift @@ -78,6 +78,8 @@ struct PlayerSessionNavigationTests { let firstDown = try #require(session.puzzle.downClues.first) session.selectClue(direction: .across, number: finalAcross.number) + session.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false) + session.mutator.setLetter("G", atRow: 2, atCol: 1, pencil: false) session.select(row: 2, col: 2) session.setDirection(.across) session.enter("H") @@ -93,6 +95,8 @@ struct PlayerSessionNavigationTests { let finalDown = try #require(session.puzzle.downClues.last) session.selectClue(direction: .down, number: finalDown.number) + session.mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false) + session.mutator.setLetter("E", atRow: 1, atCol: 2, pencil: false) session.select(row: 2, col: 2) session.setDirection(.down) session.enter("H") @@ -126,6 +130,139 @@ struct PlayerSessionNavigationTests { #expect(session.selectedCol == 1) } + @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) + 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("Disabling wrap allows word-end movement with an earlier blank") + func disablingWrapAllowsNextClue() throws { + let preferences = try makePreferences() + preferences.wrapsToFirstBlank = false + 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.currentClue()?.number == 3) + #expect(session.selectedRow == 2) + #expect(session.selectedCol == 0) + } + + @Test("Disabling next-clue movement leaves cursor at completed word end") + func disablingNextClueMovementStaysAtWordEnd() throws { + let preferences = try makePreferences() + preferences.movesToNextClue = false + 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) + 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("Next clue selects its first blank square") + func nextClueSelectsFirstBlankSquare() throws { + let session = try makeNavigationSession() + session.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false) + + session.goToNextClue() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == 3) + #expect(session.selectedRow == 2) + #expect(session.selectedCol == 1) + } + + @Test("Previous clue selects its first blank square") + func previousClueSelectsFirstBlankSquare() throws { + let session = try makeNavigationSession() + session.mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false) + + session.goToPreviousClue() + + #expect(session.direction == .down) + #expect(session.currentClue()?.number == 2) + #expect(session.selectedRow == 1) + #expect(session.selectedCol == 2) + } + + @Test("Next clue skips a completed word") + func nextClueSkipsCompletedWord() throws { + let session = try makeNavigationSession() + session.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false) + session.mutator.setLetter("G", atRow: 2, atCol: 1, pencil: false) + session.mutator.setLetter("H", atRow: 2, atCol: 2, pencil: false) + + session.goToNextClue() + + #expect(session.direction == .down) + #expect(session.currentClue()?.number == 1) + #expect(session.selectedRow == 0) + #expect(session.selectedCol == 0) + } + + @Test("Next clue can land on a filled square when skipping is disabled") + func nextClueCanLandOnFilledSquareWhenSkippingDisabled() throws { + let preferences = try makePreferences() + preferences.skipsFilledCells = false + let session = try makeNavigationSession(preferences: preferences) + session.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false) + + session.goToNextClue() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == 3) + #expect(session.selectedRow == 2) + #expect(session.selectedCol == 0) + } + + @Test("Next letter stays literal at a word boundary") + func nextLetterStaysLiteralAtWordBoundary() throws { + let session = try makeNavigationSession() + session.mutator.setLetter("F", atRow: 2, atCol: 0, pencil: false) + session.select(row: 0, col: 2) + session.setDirection(.across) + + session.goToNextLetter() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == 3) + #expect(session.selectedRow == 2) + #expect(session.selectedCol == 0) + } + + @Test("Previous letter stays literal at a word boundary") + func previousLetterStaysLiteralAtWordBoundary() throws { + let session = try makeNavigationSession() + session.mutator.setLetter("C", atRow: 0, atCol: 2, pencil: false) + session.selectClue(direction: .across, number: 3) + + session.goToPreviousLetter() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == 1) + #expect(session.selectedRow == 0) + #expect(session.selectedCol == 2) + } + @Test("Overwriting a full wrong grid publishes a fresh error completion event") func overwriteFullWrongGridPublishesFreshErrorEvent() throws { let session = try makeNavigationSession() @@ -306,4 +443,9 @@ struct PlayerSessionNavigationTests { let mutator = GameMutator(game: game, gameID: UUID(), movesUpdater: nil) return PlayerSession(game: game, mutator: mutator, preferences: preferences) } + + private func makePreferences() throws -> PlayerPreferences { + let defaults = try #require(UserDefaults(suiteName: "test-pref-\(UUID().uuidString)")) + return PlayerPreferences(local: defaults, cloud: nil) + } }