crossmate

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

commit 5e688df84884a75c038e09b923b2910c210a5fbb
parent 46a8f86ec2037ad71368553fd944841dae1c72b2
Author: Michael Camilleri <[email protected]>
Date:   Mon, 11 May 2026 01:18:27 +0900

Fix movement when deleting first letter of a word

Diffstat:
MCrossmate/Models/PlayerSession.swift | 43++++++++++++++++++++++++++++++++++++++++---
MTests/Unit/PlayerSessionNavigationTests.swift | 45+++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 85 insertions(+), 3 deletions(-)

diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift @@ -164,9 +164,7 @@ final class PlayerSession { // 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. - let ordered: [(Puzzle.Direction, Puzzle.Clue)] = - puzzle.acrossClues.map { (.across, $0) } - + puzzle.downClues.map { (.down, $0) } + let ordered = orderedClues() guard !ordered.isEmpty else { return } let currentNumber = currentClueNumber() @@ -181,6 +179,11 @@ final class PlayerSession { moveToClueStart(number: newClue.number) } + private func orderedClues() -> [(Puzzle.Direction, Puzzle.Clue)] { + puzzle.acrossClues.map { (.across, $0) } + + puzzle.downClues.map { (.down, $0) } + } + private func moveWord(by offset: Int) { let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues guard !clues.isEmpty else { return } @@ -257,6 +260,21 @@ final class PlayerSession { } } + private func moveToClueEnd(direction newDirection: Puzzle.Direction, number: Int) { + guard let start = puzzle.cell(numbered: number) else { return } + let (dr, dc) = step(for: newDirection) + var row = start.row + var col = start.col + while isValid(row: row + dr, col: col + dc), + !puzzle.cells[row + dr][col + dc].isBlock { + row += dr + col += dc + } + direction = newDirection + selectedRow = row + selectedCol = col + } + // MARK: - Input func enter(_ letter: String) { @@ -401,6 +419,12 @@ final class PlayerSession { } private func retreat() { + let start = wordStart(row: selectedRow, col: selectedCol, direction: direction) + if selectedRow == start.row && selectedCol == start.col { + retreatToPreviousClueEnd() + return + } + let (dr, dc) = step(for: direction) var r = selectedRow - dr var c = selectedCol - dc @@ -414,6 +438,19 @@ final class PlayerSession { } } + private func retreatToPreviousClueEnd() { + let ordered = orderedClues() + guard !ordered.isEmpty else { return } + + let currentNumber = currentClueNumber() + let currentIndex = ordered.firstIndex { + $0.0 == direction && $0.1.number == currentNumber + } ?? 0 + let previousIndex = ((currentIndex - 1) % ordered.count + ordered.count) % ordered.count + let (newDirection, newClue) = ordered[previousIndex] + moveToClueEnd(direction: newDirection, number: newClue.number) + } + private func isValid(row: Int, col: Int) -> Bool { row >= 0 && row < puzzle.height && col >= 0 && col < puzzle.width } diff --git a/Tests/Unit/PlayerSessionNavigationTests.swift b/Tests/Unit/PlayerSessionNavigationTests.swift @@ -75,6 +75,51 @@ struct PlayerSessionNavigationTests { #expect(session.currentClue()?.number == firstAcross.number) } + @Test("Backspace from first down moves to final across end") + func backspaceFromFirstDownMovesToFinalAcrossEnd() throws { + let session = try makeNavigationSession() + let finalAcross = try #require(session.puzzle.acrossClues.last) + let firstDown = try #require(session.puzzle.downClues.first) + + session.selectClue(direction: .down, number: firstDown.number) + session.deleteBackward() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == finalAcross.number) + #expect(session.selectedRow == 2) + #expect(session.selectedCol == 2) + } + + @Test("Backspace from first across moves to final down end") + func backspaceFromFirstAcrossMovesToFinalDownEnd() throws { + let session = try makeNavigationSession() + let firstAcross = try #require(session.puzzle.acrossClues.first) + let finalDown = try #require(session.puzzle.downClues.last) + + session.selectClue(direction: .across, number: firstAcross.number) + session.deleteBackward() + + #expect(session.direction == .down) + #expect(session.currentClue()?.number == finalDown.number) + #expect(session.selectedRow == 2) + #expect(session.selectedCol == 2) + } + + @Test("Backspace from final across start moves to previous across end") + func backspaceFromFinalAcrossStartMovesToPreviousAcrossEnd() throws { + let session = try makeNavigationSession() + let firstAcross = try #require(session.puzzle.acrossClues.first) + let finalAcross = try #require(session.puzzle.acrossClues.last) + + session.selectClue(direction: .across, number: finalAcross.number) + session.deleteBackward() + + #expect(session.direction == .across) + #expect(session.currentClue()?.number == firstAcross.number) + #expect(session.selectedRow == 0) + #expect(session.selectedCol == 2) + } + private func makeNavigationSession() throws -> PlayerSession { let source = """ Title: Navigation Test