commit c24065995ac2ef2412567b5e9cab5fd173a93edb
parent 97f69a88839beca6da39c9774ee50b64c81a9416
Author: Michael Camilleri <[email protected]>
Date: Sat, 30 May 2026 15:27:59 +0900
Follow the cursor on undo and redo
Undo and redo previously reversed the grid edit but left the cursor
where it was. This commit moves it to the cell the edit touched, so
undoing a typed letter lands back on that letter and redoing puts you
back on the re-applied one — the cursor reads as moving back on undo and
forward on redo. We don't record the cursor position per move, so this
infers the target from the step itself rather than restoring true
history.
JournalPlan now carries the undoable step's kind, and GameMutator.undo
and redo return the cell to focus: the single cell of an input step, or
nil for a bulk clear, which has no single sensible target and so leaves
the cursor put. PlayerSession gains undo/redo wrappers (and canUndo/
canRedo passthroughs) that place the cursor via a new helper, which
skips the same-cell direction flip select applies and flips direction
only when the destination has no word in the current one. All four
control surfaces now route through the session rather than the mutator.
It also drops the icons from the Entry menu's Undo/Redo items so they
match the plain-text entries beside them; the keyboard overflow popover
keeps its icons, where every row has one.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
5 files changed, 71 insertions(+), 27 deletions(-)
diff --git a/Crossmate/Models/PlayerSession.swift b/Crossmate/Models/PlayerSession.swift
@@ -287,6 +287,40 @@ final class PlayerSession {
mutator.clearCells(puzzle.cells.flatMap { $0 })
}
+ // MARK: - Undo / redo
+
+ var canUndo: Bool { mutator.canUndo }
+ var canRedo: Bool { mutator.canRedo }
+
+ /// Undoes the most recent move and follows the cursor to the cell it
+ /// 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)
+ }
+ }
+
+ func redo() {
+ if let target = mutator.redo() {
+ placeCursor(atRow: target.row, atCol: target.col)
+ }
+ }
+
+ /// 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
+ /// directionless.
+ private func placeCursor(atRow row: Int, atCol col: Int) {
+ guard isValid(row: row, col: col), !puzzle.cells[row][col].isBlock else { return }
+ selectedRow = row
+ selectedCol = col
+ if !hasWord(at: row, col: col, direction: direction),
+ hasWord(at: row, col: col, direction: direction.opposite) {
+ direction = direction.opposite
+ }
+ }
+
private func currentClueNumber() -> Int? {
let start = wordStart(row: selectedRow, col: selectedCol, direction: direction)
return puzzle.cells[start.row][start.col].number
diff --git a/Crossmate/Persistence/GameMutator.swift b/Crossmate/Persistence/GameMutator.swift
@@ -129,21 +129,36 @@ final class GameMutator {
/// recorded as an `undo` row. Cells a collaborator has changed since are
/// skipped via the supersession guard; if a whole step was superseded it is
/// passed over so undo lands on the next still-standing move.
- func undo() {
- guard !isAccessRevoked, let movesJournal else { return }
+ ///
+ /// 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.
+ @discardableResult
+ func undo() -> GridPosition? {
+ guard !isAccessRevoked, let movesJournal else { return nil }
while let plan = movesJournal.planUndo(gameID: gameID) {
- if applyRestores(plan.restores, kind: .undo) { return }
+ if applyRestores(plan.restores, kind: .undo) { return cursorTarget(for: plan) }
movesJournal.markUndoConsumed(stepID: plan.stepID, gameID: gameID)
}
+ return nil
}
/// Re-applies the most recently undone move. Mirror of `undo()`.
- func redo() {
- guard !isAccessRevoked, let movesJournal else { return }
+ @discardableResult
+ func redo() -> GridPosition? {
+ guard !isAccessRevoked, let movesJournal else { return nil }
while let plan = movesJournal.planRedo(gameID: gameID) {
- if applyRestores(plan.restores, kind: .redo) { return }
+ if applyRestores(plan.restores, kind: .redo) { return cursorTarget(for: plan) }
movesJournal.markRedoConsumed(stepID: plan.stepID, gameID: gameID)
}
+ return nil
+ }
+
+ /// 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
}
/// Applies the surviving cells of a plan under one batch, returning whether
diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift
@@ -76,6 +76,10 @@ struct JournalRestore: Equatable, Sendable {
struct JournalPlan: Equatable, Sendable {
let restores: [JournalRestore]
let stepID: Int64
+ /// The kind of the undoable step being reversed/re-applied (`.input` or
+ /// `.clear`). Lets the caller decide where to put the cursor — onto a
+ /// single-cell input, but not after a bulk clear.
+ let kind: JournalKind
}
/// Local, append-only log of every grid move, and the undo/redo derivation
@@ -176,7 +180,7 @@ final class MovesJournal {
targetSeq: entry.seq
)
}
- return JournalPlan(restores: restores, stepID: stepID(op))
+ return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind)
}
/// The cells to restore to redo the most recently undone step.
@@ -192,7 +196,7 @@ final class MovesJournal {
targetSeq: entry.seq
)
}
- return JournalPlan(restores: restores, stepID: stepID(op))
+ return JournalPlan(restores: restores, stepID: stepID(op), kind: op.kind)
}
/// Records that a planned undo/redo step had no surviving cells (all
diff --git a/Crossmate/Views/KeyboardView.swift b/Crossmate/Views/KeyboardView.swift
@@ -164,12 +164,12 @@ struct KeyboardView: View {
.keyWidthMultiplier(metaKeyWidthMultiplier)
.popover(isPresented: $showingOverflow) {
VStack(alignment: .leading, spacing: 0) {
- overflowItem("Undo", systemImage: "arrow.uturn.backward", isEnabled: session.mutator.canUndo) {
- session.mutator.undo()
+ overflowItem("Undo", systemImage: "arrow.uturn.backward", isEnabled: session.canUndo) {
+ session.undo()
}
- overflowItem("Redo", systemImage: "arrow.uturn.forward", isEnabled: session.mutator.canRedo) {
- session.mutator.redo()
+ overflowItem("Redo", systemImage: "arrow.uturn.forward", isEnabled: session.canRedo) {
+ session.redo()
}
Divider()
diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift
@@ -370,9 +370,9 @@ struct PuzzleView: View {
if event.keyCode == .keyboardZ, event.modifierFlags.contains(.command) {
guard !session.isRebusActive else { return false }
if event.modifierFlags.contains(.shift) {
- session.mutator.redo()
+ session.redo()
} else {
- session.mutator.undo()
+ session.undo()
}
return true
}
@@ -827,19 +827,10 @@ private struct PuzzleToolbarModifier: ViewModifier {
private var entryMenu: some View {
Menu {
Section {
- Button {
- session.mutator.undo()
- } label: {
- Label("Undo", systemImage: "arrow.uturn.backward")
- }
- .disabled(!session.mutator.canUndo)
-
- Button {
- session.mutator.redo()
- } label: {
- Label("Redo", systemImage: "arrow.uturn.forward")
- }
- .disabled(!session.mutator.canRedo)
+ Button("Undo") { session.undo() }
+ .disabled(!session.canUndo)
+ Button("Redo") { session.redo() }
+ .disabled(!session.canRedo)
}
Section {