commit 01e25da243c14f1bdde4ba58636af01007d085f7
parent 002eff09abc718780159be29e0c828fd829f578a
Author: Michael Camilleri <[email protected]>
Date: Sun, 31 May 2026 09:02:21 +0900
Restore the typing direction when undoing a letter
Undo followed the cursor back to the cell it touched but left the
direction alone. PlayerSession.undo handed mutator.undo's GridPosition
to placeCursor, which kept whatever way the cursor currently faced and
flipped only when that direction had no word at the destination. On a
crossing cell — which has both an across and a down word — the guard
never fired, so undoing a letter you typed going down could leave you
pointing across. The grid state rewound faithfully; the cursor's
orientation did not.
The direction a letter was typed in lives in the selection, not the
cell, so the journal — which only logged grid state — had nowhere to
recover it from. This commit records it. JournalValue and JournalEntity
gain an optional direction (0=across, 1=down, matching GameCursorStore),
threaded from PlayerSession's enter/commitRebus/deleteBackward through
setLetter/clearLetter into the journal row. It is meaningful only for
input steps; clears, help gestures and the undo/redo rows themselves
carry none, since an undo's cursor direction comes from the input op it
reverses. planUndo/planRedo read it off that op, and mutator.undo/redo
now return a CursorLanding (position plus direction) in place of a bare
position.
placeCursor adopts the recorded direction when the destination has a
word in it, falling back to the existing keep-or-flip heuristic so the
cursor still never lands directionless and pre-existing journal rows
degrade gracefully. The attribute is local Core Data only — direction is
device-local cursor UX, irrelevant to grid replay, so JournalCodec never
encodes it and lightweight migration adds the column with no dashboard
step.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
7 files changed, 126 insertions(+), 35 deletions(-)
diff --git a/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents b/Crossmate/Models/CrossmateModel.xcdatamodeld/CrossmateModel.xcdatamodel/contents
@@ -124,6 +124,7 @@
<attribute name="batchID" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="cellAuthorID" optional="YES" attributeType="String"/>
<attribute name="col" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
+ <attribute name="dir" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
<attribute name="gameID" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="kind" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="letter" attributeType="String" defaultValueString=""/>
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -296,25 +296,31 @@ final class PlayerSession {
/// touched, so reversing a typed letter lands back on that letter. A bulk
/// clear returns no target, so the cursor stays where it is.
func undo() {
- if let target = mutator.undo() {
- placeCursor(atRow: target.row, atCol: target.col)
+ if let landing = mutator.undo() {
+ placeCursor(atRow: landing.position.row, atCol: landing.position.col, preferring: landing.direction)
}
}
func redo() {
- if let target = mutator.redo() {
- placeCursor(atRow: target.row, atCol: target.col)
+ if let landing = mutator.redo() {
+ placeCursor(atRow: landing.position.row, atCol: landing.position.col, preferring: landing.direction)
}
}
/// Moves the cursor onto `(row, col)` without the same-cell direction flip
- /// `select(row:col:)` applies. The direction is flipped only if the current
- /// one has no word at the destination, so the cursor never lands
+ /// `select(row:col:)` applies. When `preferred` is given and that direction
+ /// has a word at the destination, the cursor adopts it — so undoing a letter
+ /// restores the orientation it was typed in. Otherwise the current direction
+ /// is kept, flipped only if it has no word here, so the cursor never lands
/// directionless.
- private func placeCursor(atRow row: Int, atCol col: Int) {
+ private func placeCursor(atRow row: Int, atCol col: Int, preferring preferred: Puzzle.Direction? = nil) {
guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return }
selectedRow = row
selectedCol = col
+ if let preferred, hasWord(at: row, col: col, direction: preferred) {
+ direction = preferred
+ return
+ }
if !hasWord(at: row, col: col, direction: direction),
hasWord(at: row, col: col, direction: direction.opposite) {
direction = direction.opposite
@@ -358,7 +364,7 @@ final class PlayerSession {
let inputCol = selectedCol
let cell = puzzle.cells[inputRow][inputCol]
guard !cell.isBlock else { return }
- mutator.setLetter(letter, atRow: inputRow, atCol: inputCol, pencil: isPencilMode)
+ mutator.setLetter(letter, atRow: inputRow, atCol: inputCol, pencil: isPencilMode, direction: direction)
let completionState = game.completionState
publishTerminalCompletionState(completionState)
if completionState != .solved {
@@ -377,7 +383,7 @@ final class PlayerSession {
if currentEmpty || currentMark.isRevealed {
retreat()
}
- mutator.clearLetter(atRow: selectedRow, atCol: selectedCol)
+ mutator.clearLetter(atRow: selectedRow, atCol: selectedCol, direction: direction)
}
// MARK: - Rebus
@@ -404,7 +410,7 @@ final class PlayerSession {
let inputCol = selectedCol
isRebusActive = false
rebusBuffer = ""
- mutator.setLetter(value, atRow: inputRow, atCol: inputCol, pencil: isPencilMode)
+ mutator.setLetter(value, atRow: inputRow, atCol: inputCol, pencil: isPencilMode, direction: direction)
let completionState = game.completionState
publishTerminalCompletionState(completionState)
if completionState != .solved {
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -57,16 +57,16 @@ final class GameMutator {
// MARK: - Single-cell mutations
- func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool) {
+ func setLetter(_ letter: String, atRow row: Int, atCol col: Int, pencil: Bool, direction: Puzzle.Direction? = nil) {
let before = cellState(atRow: row, atCol: col)
game.setLetter(letter, atRow: row, atCol: col, pencil: pencil, authorID: authorIDProvider?())
- emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col))
+ emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col), direction: direction)
}
- func clearLetter(atRow row: Int, atCol col: Int) {
+ func clearLetter(atRow row: Int, atCol col: Int, direction: Puzzle.Direction? = nil) {
let before = cellState(atRow: row, atCol: col)
game.clearLetter(atRow: row, atCol: col)
- emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col))
+ emitMove(atRow: row, atCol: col, journalKind: kind(.input, ifChangedFrom: before, atRow: row, atCol: col), direction: direction)
}
// MARK: - Bulk mutations
@@ -111,6 +111,15 @@ final class GameMutator {
// MARK: - Undo / redo
+ /// Where the cursor should land after an undo/redo, and which way it should
+ /// point. `direction` is the way the reversed letter was originally typed,
+ /// or `nil` when that wasn't recorded (older entries) so the caller keeps
+ /// its current orientation.
+ struct CursorLanding {
+ let position: GridPosition
+ let direction: Puzzle.Direction?
+ }
+
/// `true` when there is a still-undoable move by this user. Reading these
/// is cheap (a derivation pass over the in-memory journal) and drives the
/// enabled state of the undo/redo controls.
@@ -130,11 +139,11 @@ final class GameMutator {
/// skipped via the supersession guard; if a whole step was superseded it is
/// passed over so undo lands on the next still-standing move.
///
- /// Returns the cell the cursor should follow to — the single cell of an
- /// `input` step — or `nil` for a bulk `clear` (no single target) or when
- /// nothing was undone.
+ /// Returns where the cursor should follow to — the single cell of an
+ /// `input` step, with the direction it was typed in — or `nil` for a bulk
+ /// `clear` (no single target) or when nothing was undone.
@discardableResult
- func undo() -> GridPosition? {
+ func undo() -> CursorLanding? {
guard !isAccessRevoked, let movesJournal else { return nil }
while let plan = movesJournal.planUndo(gameID: gameID) {
if applyRestores(plan.restores, kind: .undo) { return cursorTarget(for: plan) }
@@ -145,7 +154,7 @@ final class GameMutator {
/// Re-applies the most recently undone move. Mirror of `undo()`.
@discardableResult
- func redo() -> GridPosition? {
+ func redo() -> CursorLanding? {
guard !isAccessRevoked, let movesJournal else { return nil }
while let plan = movesJournal.planRedo(gameID: gameID) {
if applyRestores(plan.restores, kind: .redo) { return cursorTarget(for: plan) }
@@ -155,10 +164,11 @@ final class GameMutator {
}
/// The cell the cursor should move to after applying `plan`: a single-cell
- /// `input` step focuses its cell, while a bulk `clear` leaves the cursor put.
- private func cursorTarget(for plan: JournalPlan) -> GridPosition? {
- guard plan.kind == .input else { return nil }
- return plan.restores.first?.position
+ /// `input` step focuses its cell (oriented to how it was typed), while a
+ /// bulk `clear` leaves the cursor put.
+ private func cursorTarget(for plan: JournalPlan) -> CursorLanding? {
+ guard plan.kind == .input, let position = plan.restores.first?.position else { return nil }
+ return CursorLanding(position: position, direction: plan.direction)
}
/// Applies the surviving cells of a plan under one batch, returning whether
@@ -211,7 +221,8 @@ final class GameMutator {
atCol col: Int,
journalKind: JournalKind? = nil,
batchID: UUID? = nil,
- targetSeq: Int64? = nil
+ targetSeq: Int64? = nil,
+ direction: Puzzle.Direction? = nil
) {
guard !isAccessRevoked else { return }
let square = game.squares[row][col]
@@ -235,7 +246,8 @@ final class GameMutator {
actingAuthorID: actingAuthorID,
kind: journalKind,
targetSeq: targetSeq,
- batchID: batchID
+ batchID: batchID,
+ direction: direction
)
}
diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift
@@ -56,6 +56,11 @@ struct JournalValue: Equatable, Sendable {
let targetSeq: Int64?
let batchID: UUID?
let prevSeqAtCell: Int64?
+ /// The cursor direction at the moment of input, so undo/redo can land the
+ /// cursor pointing the way the letter was originally typed. Only meaningful
+ /// for `.input` entries; `nil` for clears, help gestures, and undo/redo
+ /// rows (whose cursor direction comes from the input op they reverse).
+ let direction: Puzzle.Direction?
}
/// One cell to rewrite as part of an undo or redo, with the guard value the
@@ -80,6 +85,10 @@ struct JournalPlan: Equatable, Sendable {
/// `.clear`). Lets the caller decide where to put the cursor — onto a
/// single-cell input, but not after a bulk clear.
let kind: JournalKind
+ /// The direction the reversed/re-applied input step was typed in, so the
+ /// caller can orient the cursor to match. `nil` for `.clear` (no single
+ /// direction) and for older entries recorded before direction was tracked.
+ let direction: Puzzle.Direction?
}
/// Local, append-only log of every grid move, and the undo/redo derivation
@@ -134,7 +143,8 @@ final class MovesJournal {
actingAuthorID: String?,
kind: JournalKind,
targetSeq: Int64?,
- batchID: UUID?
+ batchID: UUID?,
+ direction: Puzzle.Direction? = nil
) -> JournalValue {
ensureLoaded(gameID)
let value = JournalValue(
@@ -146,7 +156,8 @@ final class MovesJournal {
kind: kind,
targetSeq: targetSeq,
batchID: batchID,
- prevSeqAtCell: lastSeqAtCell[position]
+ prevSeqAtCell: lastSeqAtCell[position],
+ direction: direction
)
nextSeq += 1
entries.append(value)
@@ -192,7 +203,7 @@ final class MovesJournal {
targetSeq: entry.seq
)
}
- return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind)
+ return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind, direction: op.entries.first?.direction)
}
/// The cells to restore to redo the most recently undone step.
@@ -208,7 +219,7 @@ final class MovesJournal {
targetSeq: entry.seq
)
}
- return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind)
+ return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind, direction: op.entries.first?.direction)
}
/// Records that a planned undo/redo step had no surviving cells (all
@@ -366,6 +377,7 @@ final class MovesJournal {
entity.targetSeq = value.targetSeq.map { NSNumber(value: $0) }
entity.batchID = value.batchID
entity.prevSeqAtCell = value.prevSeqAtCell.map { NSNumber(value: $0) }
+ entity.dir = value.direction.map { NSNumber(value: $0 == .down ? 1 : 0) }
let gameReq = NSFetchRequest<GameEntity>(entityName: "GameEntity")
gameReq.predicate = NSPredicate(format: "id == %@", gameID as CVarArg)
@@ -398,7 +410,8 @@ final class MovesJournal {
kind: JournalKind(rawValue: entity.kind) ?? .input,
targetSeq: entity.targetSeq?.int64Value,
batchID: entity.batchID,
- prevSeqAtCell: entity.prevSeqAtCell?.int64Value
+ prevSeqAtCell: entity.prevSeqAtCell?.int64Value,
+ direction: entity.dir.map { $0.int16Value == 1 ? Puzzle.Direction.down : .across }
)
}
}
@@ -514,7 +527,10 @@ enum JournalCodec {
kind: JournalKind(rawValue: entry.kind) ?? .input,
targetSeq: entry.targetSeq,
batchID: entry.batchID,
- prevSeqAtCell: entry.prevSeqAtCell
+ prevSeqAtCell: entry.prevSeqAtCell,
+ // Cursor direction is device-local UX, not part of the replay
+ // wire format — it is never encoded, so decoded entries carry none.
+ direction: nil
)
}
}
diff --git a/Tests/Unit/JournalReplayTests.swift b/Tests/Unit/JournalReplayTests.swift
@@ -28,7 +28,8 @@ struct JournalReplayTests {
kind: kind,
targetSeq: nil,
batchID: nil,
- prevSeqAtCell: nil
+ prevSeqAtCell: nil,
+ direction: nil
)
}
diff --git a/Tests/Unit/JournalUploadTests.swift b/Tests/Unit/JournalUploadTests.swift
@@ -37,7 +37,8 @@ struct JournalCodecTests {
kind: kind,
targetSeq: targetSeq,
batchID: batchID,
- prevSeqAtCell: prevSeqAtCell
+ prevSeqAtCell: prevSeqAtCell,
+ direction: nil
)
}
@@ -148,7 +149,8 @@ struct RecordSerializerJournalTests {
kind: .input,
targetSeq: nil,
batchID: nil,
- prevSeqAtCell: nil
+ prevSeqAtCell: nil,
+ direction: nil
),
JournalValue(
seq: 1,
@@ -159,7 +161,8 @@ struct RecordSerializerJournalTests {
kind: .reveal,
targetSeq: nil,
batchID: UUID(),
- prevSeqAtCell: nil
+ prevSeqAtCell: nil,
+ direction: nil
),
]
let updatedAt = Date(timeIntervalSince1970: 1_700_000_010)
diff --git a/Tests/Unit/MovesJournalTests.swift b/Tests/Unit/MovesJournalTests.swift
@@ -82,6 +82,58 @@ struct MovesJournalTests {
#expect(!mutator.canRedo)
}
+ // MARK: - Cursor direction follows the entry
+
+ @Test("undo and redo report the direction the letter was typed in")
+ func undoRedoCarryEntryDirection() throws {
+ let (_, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, direction: .down)
+
+ let undoLanding = mutator.undo()
+ #expect(undoLanding?.position == GridPosition(row: 0, col: 0))
+ #expect(undoLanding?.direction == .down)
+
+ let redoLanding = mutator.redo()
+ #expect(redoLanding?.position == GridPosition(row: 0, col: 0))
+ #expect(redoLanding?.direction == .down)
+ }
+
+ @Test("a bulk clear undo reports no single direction")
+ func clearUndoHasNoDirection() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+
+ mutator.setLetter("A", atRow: 0, atCol: 0, pencil: false, direction: .across)
+ mutator.clearCells([game.puzzle.cells[0][0]])
+
+ // Undoing the clear restores the letter but offers no cursor target.
+ let landing = mutator.undo()
+ #expect(landing == nil)
+ #expect(game.squares[0][0].entry == "A")
+ }
+
+ @Test("undo reorients the cursor to how the letter was typed")
+ func undoReorientsCursor() throws {
+ let (game, mutator, _, _) = try makeTestGame()
+ let session = PlayerSession(game: game, mutator: mutator)
+
+ // Type into the (0,0) crossing cell while pointing down.
+ session.select(row: 0, col: 0)
+ session.setDirection(.down)
+ session.enter("X")
+
+ // Move away and face across — (0,0) has an across word too, so the
+ // keep-current fallback alone would *not* flip back to down.
+ session.select(row: 2, col: 0)
+ session.setDirection(.across)
+ #expect(session.direction == .across)
+
+ session.undo()
+ #expect(session.selectedRow == 0)
+ #expect(session.selectedCol == 0)
+ #expect(session.direction == .down)
+ }
+
// MARK: - Checks and reveals are not undoable
@Test("checking a cell creates no undo step")