commit c9056097608160cfdc031ba95348912fd46db819
parent b3cc7156efdd787de1ae70277c2ec36b199f6189
Author: Michael Camilleri <[email protected]>
Date: Wed, 3 Jun 2026 11:31:46 +0900
Render peer cursor tints behind the grid, not in every cell
A choppy clue-bar transition during live two-player solving traced back
to GridView re-evaluating its whole body on every peer cursor move. The
441-cell ForEach sat downstream of roster.remoteSelections: the body
computed a per-cell tint dictionary from it and threaded the result into
each CellView as remoteWordTint. remoteSelections is fed by the
engagement socket, so a peer sweeping the board pushed sub-second
updates, and each one invalidated the body — re-running 441 CellView
inits and Equatable comparisons on the main thread. CellView's
.equatable() spared the actual redraws, but the closure churn alone was
enough to starve the clue bar's time-based slide animation, which ticks
on that same main thread. The receiver of the denser cursor stream felt
it; the peer driving steady fills did not.
The peer tints now live in their own Canvas layer behind the grid. The
cells render over a clear base, and two .background layers supply what
used to be drawn inside each cell background: GridBackdrop paints the
white cell fills (static for the life of the game — it reads only the
block layout) and RemoteCursorTints paints each present peer's selected
word. RemoteCursorTints is the only view that observes remoteSelections,
so a peer cursor move now repaints a handful of Canvas rects and never
touches the cell ForEach above. The grid body re-evaluates only on local
selection and letter changes — the irreducible set.
All three layers derive their cell rects from a shared PuzzleGridGeometry
so the Canvases stay pixel-aligned with the laid-out cells;
PuzzleGridLayout's own placement is refactored onto the same helper to
keep them from drifting. The local selection/highlight blend is
preserved exactly, since those stay in the cell and so still composite
over the peer tint. The one deliberate change is that the faint author
tint (0.10) and shaded-square wash (0.22) now sit above the peer tint
rather than below it — visible only as a <=10% wash where a peer's cursor
crosses the other player's letters, and identical in the common case of
a peer over their own letters (same hue).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 141 insertions(+), 46 deletions(-)
diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift
@@ -17,7 +17,6 @@ struct CellView: View, Equatable {
var gridCol: Int = 0
var gridSpacing: CGFloat = 1
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
@@ -37,7 +36,6 @@ struct CellView: View, Equatable {
&& lhs.gridRow == rhs.gridRow
&& lhs.gridCol == rhs.gridCol
&& lhs.isRelatedToFocus == rhs.isRelatedToFocus
- && lhs.remoteWordTint == rhs.remoteWordTint
&& lhs.authorTint == rhs.authorTint
&& lhs.selectionTint == rhs.selectionTint
}
@@ -109,13 +107,14 @@ struct CellView: View, Equatable {
return nil
}
+ // Drawn over a clear base: the white cell fill and peer cursor tints are
+ // rendered by `GridBackdrop`/`RemoteCursorTints` behind the grid, so they
+ // show through wherever this cell draws nothing. Blocks stay fully clear,
+ // letting the grid's black show through.
@ViewBuilder
private var background: some View {
- if cell.isBlock {
- Color.black
- } else {
+ if !cell.isBlock {
ZStack {
- Color.white
if cell.special == .shaded {
Color.black.opacity(0.22)
}
@@ -125,11 +124,6 @@ struct CellView: View, Equatable {
if let authorTint {
authorTint.opacity(PlayerColor.authorTintOpacity)
}
- // Peer word tint sits beneath self highlight/selection so the
- // local cursor always reads as the dominant focus.
- if let remoteWordTint {
- remoteWordTint
- }
if isSelected {
selectionTint ?? playerColor.selectionFill
} else if isHighlighted {
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -24,8 +24,10 @@ struct GridView: View {
let replayCells = replayFrame?.cells
let isReplaying = replayFrame != nil
let replayCursor = replayFrame?.cursor
- let tintByCell: [GridPosition: Color] =
- (showsSharedAnnotations && showsPeerCursors && !isReplaying) ? remoteTrackTints() : [:]
+ // Peer cursor tints are rendered in a separate Canvas layer (see
+ // `RemoteCursorTints`) so this 441-cell grid no longer re-evaluates on
+ // every peer cursor move — only on local selection and letter changes.
+ let showsRemoteTints = showsSharedAnnotations && showsPeerCursors && !isReplaying
// Author colours are shown for shared live play and for any replay
// (the rewind reads as coloured per author, matching the scoreboard).
let authorTintByID: [String: Color] = (showsSharedAnnotations || isReplaying)
@@ -78,7 +80,6 @@ struct GridView: View {
gridCol: c,
gridSpacing: spacing,
isRelatedToFocus: relatedCells.contains(pos),
- remoteWordTint: tintByCell[pos],
authorTint: entry.isEmpty
? nil
: letterAuthorID.flatMap { authorTintByID[$0] },
@@ -91,26 +92,21 @@ struct GridView: View {
}
}
}
- .background(Color.black)
- }
-
- /// Builds remote word-tint overlays from each peer's persisted cursor
- /// track. Every cell in the peer's selected answer is filled with their
- /// selection colour so the track reads as a coloured run; the exact
- /// focused square is intentionally local-only.
- private func remoteTrackTints() -> [GridPosition: Color] {
- var tint: [GridPosition: (Date, Color)] = [:]
- for (_, sel) in roster.remoteSelections {
- for cell in session.puzzle.wordCells(
- atRow: sel.row, col: sel.col, direction: sel.direction
- ) {
- let pos = GridPosition(row: cell.row, col: cell.col)
- if tint[pos].map({ $0.0 < sel.updatedAt }) ?? true {
- tint[pos] = (sel.updatedAt, sel.color.selectionFill)
- }
+ // Layered behind the cells, back to front: black (shows through the
+ // inter-cell gaps and behind blocks) → white cell backdrop → peer
+ // cursor tints. The cells draw with a clear base so these show through.
+ // Each layer reads its own observable state in its own body, so peer
+ // cursor moves repaint only the lightweight `RemoteCursorTints` Canvas
+ // and never invalidate the cell `ForEach` above.
+ .background(alignment: .center) {
+ if showsRemoteTints {
+ RemoteCursorTints(roster: roster, puzzle: session.puzzle, spacing: spacing)
}
}
- return tint.mapValues { $0.1 }
+ .background(alignment: .center) {
+ GridBackdrop(puzzle: session.puzzle, spacing: spacing)
+ }
+ .background(Color.black)
}
}
@@ -133,7 +129,7 @@ private enum PuzzleGridMetrics {
)
}
- private static func cellSize(
+ static func cellSize(
availableWidth: CGFloat?,
availableHeight: CGFloat?,
columns: Int,
@@ -187,28 +183,133 @@ private struct PuzzleGridLayout: Layout {
subviews: Subviews,
cache: inout ()
) {
- let cellSize = PuzzleGridMetrics.cellSize(
- for: ProposedViewSize(width: bounds.width, height: bounds.height),
+ let geometry = PuzzleGridGeometry(
+ size: bounds.size,
columns: columns,
rows: rows,
spacing: spacing
)
- let gridWidth = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1)
- let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1)
- let originX = bounds.minX + (bounds.width - gridWidth) / 2
- let originY = bounds.minY + (bounds.height - gridHeight) / 2
-
- let cellProposal = ProposedViewSize(width: cellSize, height: cellSize)
+ let cellProposal = ProposedViewSize(
+ width: geometry.cellSize,
+ height: geometry.cellSize
+ )
for (index, subview) in subviews.enumerated() {
- let r = index / columns
- let c = index % columns
- let x = originX + spacing + CGFloat(c) * (cellSize + spacing)
- let y = originY + spacing + CGFloat(r) * (cellSize + spacing)
+ let rect = geometry.cellRect(row: index / columns, col: index % columns)
subview.place(
- at: CGPoint(x: x, y: y),
+ at: CGPoint(x: bounds.minX + rect.minX, y: bounds.minY + rect.minY),
anchor: .topLeading,
proposal: cellProposal
)
}
}
}
+
+/// Shared cell geometry for the puzzle grid: given a container `size`, computes
+/// the uniform cell size (via `PuzzleGridMetrics`) and the centred origin, then
+/// hands back the frame of any `(row, col)`. `PuzzleGridLayout` and the backing
+/// `Canvas` layers (`GridBackdrop`, `RemoteCursorTints`) all derive their cell
+/// rects from this, so the layers stay pixel-aligned with the cells above them.
+private struct PuzzleGridGeometry {
+ let cellSize: CGFloat
+ let spacing: CGFloat
+ private let originX: CGFloat
+ private let originY: CGFloat
+
+ init(size: CGSize, columns: Int, rows: Int, spacing: CGFloat) {
+ let cellSize = PuzzleGridMetrics.cellSize(
+ for: ProposedViewSize(width: size.width, height: size.height),
+ columns: columns,
+ rows: rows,
+ spacing: spacing
+ )
+ let gridWidth = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1)
+ let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1)
+ self.cellSize = cellSize
+ self.spacing = spacing
+ self.originX = (size.width - gridWidth) / 2
+ self.originY = (size.height - gridHeight) / 2
+ }
+
+ func cellRect(row: Int, col: Int) -> CGRect {
+ CGRect(
+ x: originX + spacing + CGFloat(col) * (cellSize + spacing),
+ y: originY + spacing + CGFloat(row) * (cellSize + spacing),
+ width: cellSize,
+ height: cellSize
+ )
+ }
+}
+
+// MARK: - Backing layers
+
+/// The opaque white cell backdrop, drawn behind the (clear-based) cells and
+/// beneath the peer cursor tints. Depends only on the puzzle's block layout, so
+/// it is static for the life of the game and never repaints during play. Blocks
+/// are skipped, leaving the container's black to show through.
+private struct GridBackdrop: View {
+ let puzzle: Puzzle
+ let spacing: CGFloat
+
+ var body: some View {
+ Canvas { context, size in
+ let geometry = PuzzleGridGeometry(
+ size: size,
+ columns: puzzle.width,
+ rows: puzzle.height,
+ spacing: spacing
+ )
+ for r in 0..<puzzle.height {
+ for c in 0..<puzzle.width where !puzzle.cells[r][c].isBlock {
+ context.fill(Path(geometry.cellRect(row: r, col: c)), with: .color(.white))
+ }
+ }
+ }
+ .allowsHitTesting(false)
+ }
+}
+
+/// Every present peer's selected answer, filled with that peer's selection
+/// colour so the track reads as a coloured run; the exact focused square is
+/// intentionally local-only. This is the only layer that observes the
+/// high-frequency `roster.remoteSelections` stream, so a peer cursor move
+/// repaints just this Canvas — drawing a handful of word cells — instead of
+/// re-evaluating the cell grid above.
+private struct RemoteCursorTints: View {
+ let roster: PlayerRoster
+ let puzzle: Puzzle
+ let spacing: CGFloat
+
+ var body: some View {
+ let tints = remoteTrackTints()
+ Canvas { context, size in
+ guard !tints.isEmpty else { return }
+ let geometry = PuzzleGridGeometry(
+ size: size,
+ columns: puzzle.width,
+ rows: puzzle.height,
+ spacing: spacing
+ )
+ for (pos, color) in tints {
+ context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color))
+ }
+ }
+ .allowsHitTesting(false)
+ }
+
+ /// Resolves each peer's cursor track to a per-cell fill colour, latest
+ /// write winning where two peers' words overlap a cell.
+ private func remoteTrackTints() -> [GridPosition: Color] {
+ var tint: [GridPosition: (Date, Color)] = [:]
+ for (_, sel) in roster.remoteSelections {
+ for cell in puzzle.wordCells(
+ atRow: sel.row, col: sel.col, direction: sel.direction
+ ) {
+ let pos = GridPosition(row: cell.row, col: cell.col)
+ if tint[pos].map({ $0.0 < sel.updatedAt }) ?? true {
+ tint[pos] = (sel.updatedAt, sel.color.selectionFill)
+ }
+ }
+ }
+ return tint.mapValues { $0.1 }
+ }
+}