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:
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)
+ }
}