crossmate

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

commit 59dce84264011210c01ca422a659c95075cd1b79
parent 7ad5a2aa4a014be581bdbd89cfb50e866c768e16
Author: Michael Camilleri <[email protected]>
Date:   Mon,  1 Jun 2026 08:13:24 +0900

Tint the replay playhead with the acting author's colour

The finish-banner scrubber highlighted the rewound cell in the local
player's selection colour regardless of who made the move, so a shared
game's replay read as one colour rather than each player's moves in
turn.

ReplayTimeline.actingAuthor(ofStep:) pairs with focus(ofStep:) to name
who performed a single-cell step and CellView gains a selectionTint
that overrides the selection fill when set. GridView maps the playhead's
author to that author's roster colour and passes it through, falling
back to the local fill for our own moves and for steps that carry no
author.

The three scrubber outputs GridView needs — the grid override, the
playhead cell, and now its author — are bundled into an immutable
ReplayFrame value, exposed as ReplayController.frame (nil exactly when
at rest).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

Diffstat:
MCrossmate/Persistence/JournalReplay.swift | 9+++++++++
MCrossmate/Views/CellView.swift | 7++++++-
MCrossmate/Views/GridView.swift | 29++++++++++++++++++-----------
MCrossmate/Views/PuzzleView.swift | 3+--
MCrossmate/Views/ReplayController.swift | 30++++++++++++++++++++++++++++++
5 files changed, 64 insertions(+), 14 deletions(-)

diff --git a/Crossmate/Persistence/JournalReplay.swift b/Crossmate/Persistence/JournalReplay.swift @@ -101,6 +101,15 @@ struct ReplayTimeline: Sendable, Equatable { let step = steps[index] return step.count == 1 ? step.first?.position : nil } + + /// Who performed a single-cell step — pairs with `focus(ofStep:)` so the + /// playhead can be tinted in the acting author's colour. `nil` for a + /// batched gesture (no single playhead) or an entry that carries no author. + func actingAuthor(ofStep index: Int) -> String? { + guard steps.indices.contains(index) else { return nil } + let step = steps[index] + return step.count == 1 ? step.first?.actingAuthorID : nil + } } /// Composes a `JournalReplayFetch` with this device's *live* journal into a diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift @@ -19,6 +19,10 @@ struct CellView: View, Equatable { var isRelatedToFocus: Bool = false var remoteWordTint: Color? = nil var authorTint: Color? = nil + /// Overrides the selection fill when `isSelected`. The replay playhead + /// passes the acting author's colour here so a rewound move reads in that + /// player's colour; `nil` falls back to the local player's selection fill. + var selectionTint: Color? = nil @Environment(PlayerPreferences.self) private var preferences private var playerColor: PlayerColor { preferences.color } @@ -35,6 +39,7 @@ struct CellView: View, Equatable { && lhs.isRelatedToFocus == rhs.isRelatedToFocus && lhs.remoteWordTint == rhs.remoteWordTint && lhs.authorTint == rhs.authorTint + && lhs.selectionTint == rhs.selectionTint } var body: some View { @@ -126,7 +131,7 @@ struct CellView: View, Equatable { remoteWordTint } if isSelected { - playerColor.selectionFill + selectionTint ?? playerColor.selectionFill } else if isHighlighted { playerColor.highlightFill } diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -8,22 +8,22 @@ struct GridView: View { /// the game is no longer a live session, so the other player's cursor is /// dropped while author tints stay to colour the finished grid. var showsPeerCursors: Bool = true - /// When non-nil, the grid renders this reconstructed history (finish-banner - /// replay) instead of the live `Game`: each touched cell shows the - /// after-state at the current scrub position, blanks elsewhere. Live - /// selection, word highlight, and taps are suppressed; the `replayCursor` - /// cell is highlighted as the playhead. `nil` (the default) leaves normal - /// play untouched. - var replayCells: [GridPosition: JournalCellState]? = nil - var replayCursor: GridPosition? = nil + /// The finish-banner replay frame to render. When non-nil (the user has + /// scrubbed back from the end), the grid renders this reconstructed history + /// instead of the live `Game`: each touched cell shows its after-state, + /// blanks elsewhere. Live selection, word highlight, and taps are + /// suppressed, and the playhead cell is tinted in the acting author's + /// colour. `nil` (the default, or a scrubber at rest) leaves normal play. + var replayFrame: ReplayFrame? = nil private let spacing: CGFloat = 1 - private var isReplaying: Bool { replayCells != nil } - var body: some View { let width = session.puzzle.width let height = session.puzzle.height + let replayCells = replayFrame?.cells + let isReplaying = replayFrame != nil + let replayCursor = replayFrame?.cursor let tintByCell: [GridPosition: Color] = (showsSharedAnnotations && showsPeerCursors && !isReplaying) ? remoteTrackTints() : [:] // Author colours are shown for shared live play and for any replay @@ -33,6 +33,12 @@ struct GridView: View { uniqueKeysWithValues: roster.entries.map { ($0.authorID, $0.color.tint) } ) : [:] + // Colour for the replay playhead: the acting author's selection fill, + // so a rewound move reads in that player's colour (the local fill for + // our own moves). `nil` outside replay leaves the local cursor as-is. + let playheadTint: Color? = replayFrame?.cursorAuthorID + .flatMap { id in roster.entries.first { $0.authorID == id } } + .map { $0.color.selectionFill } let relatedCells = isReplaying ? [] : session.puzzle.relatedCells( atRow: session.selectedRow, col: session.selectedCol, @@ -75,7 +81,8 @@ struct GridView: View { remoteWordTint: tintByCell[pos], authorTint: entry.isEmpty ? nil - : letterAuthorID.flatMap { authorTintByID[$0] } + : letterAuthorID.flatMap { authorTintByID[$0] }, + selectionTint: replayCursor == pos ? playheadTint : nil ) .equatable() .onTapGesture { diff --git a/Crossmate/Views/PuzzleView.swift b/Crossmate/Views/PuzzleView.swift @@ -296,8 +296,7 @@ struct PuzzleView: View { roster: roster, showsSharedAnnotations: session.mutator.isShared, showsPeerCursors: !isSolved, - replayCells: replay.gridOverride, - replayCursor: replay.cursor + replayFrame: replay.frame ) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Crossmate/Views/ReplayController.swift b/Crossmate/Views/ReplayController.swift @@ -1,6 +1,20 @@ import Foundation import Observation +/// An immutable snapshot of the scrubber at one position — exactly what +/// `GridView` needs to render a rewound frame, decoupled from the controller's +/// mutable scrub/load state. A `nil` frame means "render the live grid". +struct ReplayFrame: Equatable { + /// Each touched cell's after-state at this position; cells absent here + /// render blank. + let cells: [GridPosition: JournalCellState] + /// The cell the most recent step changed — the playhead — or `nil` for a + /// batched gesture, which has no single focus to highlight. + let cursor: GridPosition? + /// Who made that move, so the playhead can take their colour. + let cursorAuthorID: String? +} + /// View-model for the finish-banner replay scrubber. Loads a finished game's /// merged journal (Phase 2b) once the banner appears, then exposes a scrub /// position and the per-cell grid override that `GridView` renders while the @@ -59,6 +73,22 @@ final class ReplayController { return timeline.focus(ofStep: position - 1) } + /// The author whose move the playhead highlights, used to tint it in that + /// author's colour so the rewind reads as each player's moves in turn. + /// Tracks `cursor`: `nil` whenever there is no playhead. + var cursorAuthorID: String? { + guard let timeline, position > 0, position < timeline.count else { return nil } + return timeline.actingAuthor(ofStep: position - 1) + } + + /// The frame `GridView` should render, bundling the grid override with the + /// playhead and its author. `nil` exactly when `gridOverride` is — i.e. at + /// rest (head hard-right), where the live finished grid shows instead. + var frame: ReplayFrame? { + guard let cells = gridOverride else { return nil } + return ReplayFrame(cells: cells, cursor: cursor, cursorAuthorID: cursorAuthorID) + } + /// Loads the replay via the caller's loader. Idempotent for an in-flight or /// ready load; a `.waiting` / `.unavailable` result stays retryable, so /// `retry()` (driven by the inbound-journal sync signal, or the manual