crossmate

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

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:
MCrossmate.xcodeproj/project.pbxproj | 4++++
MCrossmate/CrossmateApp.swift | 3++-
MCrossmate/Models/PlayerPreferences.swift | 22+++++++++++++++++++++-
MCrossmate/Models/PlayerSession.swift | 42+++++++++++++++++++++++++++++++++++++++---
MCrossmate/Views/Settings/SettingsView.swift | 4++++
ATests/Unit/PlayerPreferencesTests.swift | 32++++++++++++++++++++++++++++++++
MTests/Unit/PlayerSessionNavigationTests.swift | 31+++++++++++++++++++++++++++++--
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) } }