commit c14bce952b7eaa0f1b3ba6b79a7811e13bd92438
parent 2a888eacf43a174f7d4b76e65692f9064da07192
Author: Michael Camilleri <[email protected]>
Date: Tue, 21 Jul 2026 18:11:46 +0900
Make filled-cell skipping configurable
Crossmate previously advanced into every cell after input, even when a
crossing answer had already filled it. This is not how some popular
crossword apps work and so caused confusion for users used to that
style.
This commit makes filled-cell skipping the default while preserving the
original behaviour through a 'Skip Filled Cells' toggle in the new
Advanced section. The preference is mirrored through iCloud key-value
storage so cursor behaviour follows the user across devices. Typed
letters and rebus commits consult it while manual navigation remains
unchanged.
Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>
Diffstat:
7 files changed, 131 insertions(+), 7 deletions(-)
diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj
@@ -74,6 +74,7 @@
449B0A09A36B276C93CFB9A4 /* GameStoreUnreadMovesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C534911020BE4ED2E5065D /* GameStoreUnreadMovesTests.swift */; };
44FF4A5334A4086DEA7D8A7B /* GameShareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 663934F0B7CA8BDD462DFAA4 /* GameShareItem.swift */; };
47584CBEF819C2F507D06DFF /* PlayerColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55FC337CF72C650373210A /* PlayerColor.swift */; };
+ 47A6EAAC144C37608AB9AE91 /* PlayerPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F1EDE21BB7D6456D6E9000 /* PlayerPreferencesTests.swift */; };
47B4FB563BA4A8A54E2C26E0 /* XDLimitsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6DB4116933BB66274AC6E8 /* XDLimitsTests.swift */; };
4819D7FBB407C9D76510EA2A /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F97B399E89BBB37730F2F1E9 /* TestHelpers.swift */; };
4A89595E3F6AB50E1D9E6BA8 /* ImportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 462CE0FD356F6137C9BFD30F /* ImportService.swift */; };
@@ -502,6 +503,7 @@
E655698481325C92EF5C348B /* FriendController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendController.swift; sourceTree = "<group>"; };
E78C275F8A90E3E3EEF190CC /* FriendPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FriendPickerView.swift; sourceTree = "<group>"; };
E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleSource.swift; sourceTree = "<group>"; };
+ E7F1EDE21BB7D6456D6E9000 /* PlayerPreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerPreferencesTests.swift; sourceTree = "<group>"; };
E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushPayloadTests.swift; sourceTree = "<group>"; };
E935CE4384F3B67CC22EEBAC /* ClueBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClueBar.swift; sourceTree = "<group>"; };
EAC61E2582D94B1E6EC67136 /* XDFileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XDFileType.swift; sourceTree = "<group>"; };
@@ -643,6 +645,7 @@
4A467BC00116EEC8500BE6A1 /* PersistenceRecoveryTests.swift */,
23FCFFF1C2C7E909DFD8FC43 /* PlayerColorTests.swift */,
5DE04D53EC3BC7D2DA0093C3 /* PlayerNamePublisherTests.swift */,
+ E7F1EDE21BB7D6456D6E9000 /* PlayerPreferencesTests.swift */,
1813630FA05C194AFF43855C /* PlayerRosterTests.swift */,
FF159746D076E051C2CB590C /* PlayerSelectionPublisherTests.swift */,
46801B570FC0B2C791ECDED3 /* PlayerSessionNavigationTests.swift */,
@@ -1115,6 +1118,7 @@
26DC22F88FA10C47BC06975E /* PersistenceRecoveryTests.swift in Sources */,
95B4083F8BC2CA465077A662 /* PlayerColorTests.swift in Sources */,
CEDF853009D0C367035F1F76 /* PlayerNamePublisherTests.swift in Sources */,
+ 47A6EAAC144C37608AB9AE91 /* PlayerPreferencesTests.swift in Sources */,
7BD1A9F69953F9C3288969AF /* PlayerRecordPresenceTests.swift in Sources */,
04062BCD473ED244159B1066 /* PlayerRosterTests.swift in Sources */,
309457EC2DFEC476253D54D2 /* PlayerSelectionPublisherTests.swift in Sources */,
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -955,7 +955,8 @@ private struct PuzzleDisplayView: View {
let newSession = PlayerSession(
game: game,
mutator: mutator,
- cursorStore: services.cursorStore
+ cursorStore: services.cursorStore,
+ preferences: preferences
)
let newRoster = services.makePlayerRoster(for: gameID, preferences: preferences)
await newRoster.preload()
diff --git a/Crossmate/Models/PlayerPreferences.swift b/Crossmate/Models/PlayerPreferences.swift
@@ -1,7 +1,8 @@
import Observation
import SwiftUI
-/// Local, per-device player preferences (colour and display name).
+/// Player preferences, persisted locally and, where appropriate, synced across
+/// the user's devices.
///
/// Persistence is layered: values are written to `UserDefaults` for fast local
/// reads and to `NSUbiquitousKeyValueStore` so they follow the user across
@@ -23,6 +24,7 @@ final class PlayerPreferences {
static let notifiesPauses = "notifiesPauses"
static let notifiesCompletions = "notifiesCompletions"
static let notifiesInvitations = "notifiesInvitations"
+ static let skipsFilledCells = "skipsFilledCells"
}
private let local: UserDefaults
@@ -79,6 +81,12 @@ 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.
+ var skipsFilledCells: Bool {
+ didSet { write(Keys.skipsFilledCells, skipsFilledCells) }
+ }
+
init(
local: UserDefaults = .standard,
cloud: NSUbiquitousKeyValueStore? = .default
@@ -98,6 +106,9 @@ final class PlayerPreferences {
self.notifiesPauses = local.object(forKey: Keys.notifiesPauses) as? Bool ?? true
self.notifiesCompletions = local.object(forKey: Keys.notifiesCompletions) as? Bool ?? true
self.notifiesInvitations = local.object(forKey: Keys.notifiesInvitations) as? Bool ?? true
+ self.skipsFilledCells = cloud?.object(forKey: Keys.skipsFilledCells) as? Bool
+ ?? local.object(forKey: Keys.skipsFilledCells) as? Bool
+ ?? true
guard let cloud else { return }
cloud.synchronize()
NotificationCenter.default.addObserver(
@@ -128,6 +139,11 @@ final class PlayerPreferences {
cloud?.set(value, forKey: key)
}
+ private func write(_ key: String, _ value: Bool) {
+ local.set(value, forKey: key)
+ cloud?.set(value, forKey: key)
+ }
+
private func pullFromCloud() {
guard let cloud else { return }
if let id = cloud.string(forKey: Keys.colorID), id != colorID {
@@ -136,6 +152,10 @@ final class PlayerPreferences {
if let newName = cloud.string(forKey: Keys.name), newName != name {
name = newName
}
+ if let newSkipsFilledCells = cloud.object(forKey: Keys.skipsFilledCells) as? Bool,
+ newSkipsFilledCells != skipsFilledCells {
+ skipsFilledCells = newSkipsFilledCells
+ }
}
private static func isUsableName(_ name: String) -> Bool {
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -19,6 +19,11 @@ final class PlayerSession {
@ObservationIgnored
private let cursorStore: GameCursorStore?
+ /// Shared preference source for typed-letter cursor movement. Optional so
+ /// isolated sessions use the app's default without creating persistence.
+ @ObservationIgnored
+ private let preferences: PlayerPreferences?
+
var selectedRow: Int {
didSet { selectionDidChange() }
}
@@ -94,10 +99,16 @@ final class PlayerSession {
@ObservationIgnored
var onRecentChangesAcknowledged: (() -> Void)?
- init(game: Game, mutator: GameMutator, cursorStore: GameCursorStore? = nil) {
+ init(
+ game: Game,
+ mutator: GameMutator,
+ cursorStore: GameCursorStore? = nil,
+ preferences: PlayerPreferences? = nil
+ ) {
self.game = game
self.mutator = mutator
self.cursorStore = cursorStore
+ self.preferences = preferences
let puzzle = game.puzzle
// Default: start at the first across clue. Fall back to the first down
@@ -433,7 +444,7 @@ final class PlayerSession {
let completionState = game.completionState
publishLocalCompletion(completionState)
if completionState != .solved {
- advance()
+ advanceAfterEntry()
}
}
@@ -479,7 +490,7 @@ final class PlayerSession {
let completionState = game.completionState
publishLocalCompletion(completionState)
if completionState != .solved {
- advance()
+ advanceAfterEntry()
}
}
@@ -599,6 +610,31 @@ final class PlayerSession {
}
}
+ /// 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.
+ 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 {
+ selectedRow = row
+ selectedCol = col
+ return
+ }
+ row += dr
+ col += dc
+ }
+ advanceToNextClue()
+ }
+
private func advanceToNextClue() {
moveClue(by: +1)
}
diff --git a/Crossmate/Views/Settings/SettingsView.swift b/Crossmate/Views/Settings/SettingsView.swift
@@ -78,6 +78,10 @@ struct SettingsView: View {
Text("These settings apply only to this device.")
}
+ Section("Advanced") {
+ Toggle("Skip Filled Cells", isOn: $preferences.skipsFilledCells)
+ }
+
Section("Sharing") {
NavigationLink("Blocked Users") {
BlockedUsersView()
diff --git a/Tests/Unit/PlayerPreferencesTests.swift b/Tests/Unit/PlayerPreferencesTests.swift
@@ -0,0 +1,32 @@
+import Foundation
+import Testing
+
+@testable import Crossmate
+
+@Suite("Player preferences", .serialized)
+@MainActor
+struct PlayerPreferencesTests {
+ @Test("Skip filled cells defaults on")
+ func skipFilledCellsDefaultsOn() throws {
+ let defaults = try makeDefaults()
+
+ let preferences = PlayerPreferences(local: defaults, cloud: nil)
+
+ #expect(preferences.skipsFilledCells)
+ }
+
+ @Test("Skip filled cells persists locally")
+ func skipFilledCellsPersistsLocally() throws {
+ let defaults = try makeDefaults()
+ let preferences = PlayerPreferences(local: defaults, cloud: nil)
+
+ preferences.skipsFilledCells = false
+ let restored = PlayerPreferences(local: defaults, cloud: nil)
+
+ #expect(!restored.skipsFilledCells)
+ }
+
+ 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
@@ -101,6 +101,31 @@ struct PlayerSessionNavigationTests {
#expect(session.currentClue()?.number == firstAcross.number)
}
+ @Test("Typing skips a filled cell by default")
+ func typingSkipsFilledCellByDefault() throws {
+ let session = try makeNavigationSession()
+ session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
+
+ session.enter("A")
+
+ #expect(session.selectedRow == 0)
+ #expect(session.selectedCol == 2)
+ }
+
+ @Test("Typing advances to a filled cell when skipping is disabled")
+ func typingDoesNotSkipFilledCellWhenDisabled() throws {
+ let defaults = try #require(UserDefaults(suiteName: "test-pref-\(UUID().uuidString)"))
+ let preferences = PlayerPreferences(local: defaults, cloud: nil)
+ preferences.skipsFilledCells = false
+ let session = try makeNavigationSession(preferences: preferences)
+ session.mutator.setLetter("B", atRow: 0, atCol: 1, pencil: false)
+
+ session.enter("A")
+
+ #expect(session.selectedRow == 0)
+ #expect(session.selectedCol == 1)
+ }
+
@Test("Overwriting a full wrong grid publishes a fresh error completion event")
func overwriteFullWrongGridPublishesFreshErrorEvent() throws {
let session = try makeNavigationSession()
@@ -257,7 +282,9 @@ struct PlayerSessionNavigationTests {
#expect(session.selectedCol == 2)
}
- private func makeNavigationSession() throws -> PlayerSession {
+ private func makeNavigationSession(
+ preferences: PlayerPreferences? = nil
+ ) throws -> PlayerSession {
let source = """
Title: Navigation Test
Author: Test
@@ -277,6 +304,6 @@ struct PlayerSessionNavigationTests {
let puzzle = Puzzle(xd: try XD.parse(source))
let game = Game(puzzle: puzzle)
let mutator = GameMutator(game: game, gameID: UUID(), movesUpdater: nil)
- return PlayerSession(game: game, mutator: mutator)
+ return PlayerSession(game: game, mutator: mutator, preferences: preferences)
}
}