crossmate

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

commit 96034e196b0ff28a0105e14ae0db7997f8d1c982
parent 17d54c154f1549bb2295713752933ef6da59bfea
Author: Michael Camilleri <[email protected]>
Date:   Sat,  6 Jun 2026 06:47:12 +0900

Render the local cursor behind the grid, not in every cell

The local player's selection lived inside CellView: isSelected,
isHighlighted, and isRelatedToFocus were threaded into all 441 cells,
and the GridView body read session's selection to compute the focused
word and related cells. So every local cursor move invalidated the body
and re-ran the whole cell ForEach — 441 CellView inits and Equatable
comparisons — even though only a handful of cells changed colour and no
letters moved. .equatable() spared the redraws, but the closure churn
still landed on the main thread. That was fine for a quiet local game,
but a live co-solve already loads that thread with engagement traffic
and the occasional Core Data read/write, and on a 120Hz iPad the frame
budget is 8.3ms; a full-grid re-evaluation competing for it on every
cursor move is waste.

This finishes the job c905609 started for peer cursors. The local cursor
now lives in its own Canvas layer, LocalCursorTints, a near-twin of
RemoteCursorTints: it reads session's selection in its own body, so a
cursor move repaints just this Canvas — the focused square (selection
fill), the rest of the word (highlight fill), and a border on related
cross-referenced cells — and never touches the cell ForEach. It sits
above RemoteCursorTints so the local cursor reads over a peer's track
where they overlap, and derives its rects from the shared
PuzzleGridGeometry so it stays aligned with the cells like the other
backing layers.

CellView is left as content only — letter, marks, cross-reference
texture, and the author tint. It drops the four selection inputs and no
longer needs the PlayerPreferences environment at all, and the GridView
body no longer reads session's selection, so a local selection change
can't invalidate it.

The one deliberate visual change, identical to the peer-tint trade in
c905609: the faint author tint (0.10) and shaded-square wash (0.22) now
sit above the local selection fill rather than below it, since the
selection moved behind the cell. This is visible only as a <=10% wash
where the user's cursor crosses a peer's letters.

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

Diffstat:
MCrossmate/Views/CellView.swift | 38++++++++------------------------------
MCrossmate/Views/GridView.swift | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
2 files changed, 97 insertions(+), 52 deletions(-)

diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift @@ -4,8 +4,6 @@ struct CellView: View, Equatable { let cell: Puzzle.Cell let entry: String let mark: CellMark - let isSelected: Bool - let isHighlighted: Bool /// Passive cross-reference texture for this cell, or `nil` if the cell /// belongs to no cross-referenced clue. Each group in the puzzle is /// assigned a distinct pattern so separate links read differently. @@ -16,28 +14,16 @@ struct CellView: View, Equatable { var gridRow: Int = 0 var gridCol: Int = 0 var gridSpacing: CGFloat = 1 - var isRelatedToFocus: Bool = false 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 } nonisolated static func == (lhs: CellView, rhs: CellView) -> Bool { lhs.cell == rhs.cell && lhs.entry == rhs.entry && lhs.mark == rhs.mark - && lhs.isSelected == rhs.isSelected - && lhs.isHighlighted == rhs.isHighlighted && lhs.crossRefPattern == rhs.crossRefPattern && lhs.gridRow == rhs.gridRow && lhs.gridCol == rhs.gridCol - && lhs.isRelatedToFocus == rhs.isRelatedToFocus && lhs.authorTint == rhs.authorTint - && lhs.selectionTint == rhs.selectionTint } var body: some View { @@ -73,10 +59,6 @@ struct CellView: View, Equatable { CornerTriangle() .fill(triangleColor) } - if isRelatedToFocus { - Rectangle() - .strokeBorder(playerColor.highlightFill, lineWidth: 3) - } } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -107,10 +89,11 @@ 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. + // Drawn over a clear base: the white cell fill, the peer cursor tints, and + // the local cursor's selection/highlight are all rendered behind the grid + // (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`), 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 { @@ -118,17 +101,12 @@ struct CellView: View, Equatable { if cell.special == .shaded { Color.black.opacity(0.22) } - // Faint background tint identifying who entered this letter - // in a shared game. Sits beneath transient highlights so peer - // and local cursors still dominate. + // Faint background tint identifying who entered this letter in a + // shared game. The cursor fills it composites over are drawn + // behind the grid (see `LocalCursorTints`/`RemoteCursorTints`). if let authorTint { authorTint.opacity(PlayerColor.authorTintOpacity) } - if isSelected { - selectionTint ?? playerColor.selectionFill - } else if isHighlighted { - playerColor.highlightFill - } } } } diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -41,27 +41,27 @@ struct GridView: View { 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, - direction: session.direction - ) let patternPalette = CrossRefPattern.allCases let cellGroups = session.puzzle.cellGroups - let currentWordCells: Set<GridPosition> = isReplaying ? [] : Set(session.puzzle.wordCells( - atRow: session.selectedRow, - col: session.selectedCol, - direction: session.direction - ).map { GridPosition(row: $0.row, col: $0.col) }) // Layered back to front: black (shows through the inter-cell gaps and - // behind blocks) -> white cell backdrop -> peer cursor tints -> cells. - // Keep these as siblings in one layout so iPad-sized proposals cannot - // give the backing Canvases a different drawing rect than the cells. + // behind blocks) -> white cell backdrop -> peer cursor tints -> local + // cursor tints -> cells. Keep these as siblings in one layout so + // iPad-sized proposals cannot give the backing Canvases a different + // drawing rect than the cells. The cursor layers each read their own + // selection state in their own body, so a cursor move repaints only a + // lightweight Canvas and never invalidates the cell `ForEach`. PuzzleGridLayerLayout(columns: width, rows: height, spacing: spacing) { GridBackdrop(puzzle: session.puzzle, spacing: spacing) if showsRemoteTints { RemoteCursorTints(roster: roster, puzzle: session.puzzle, spacing: spacing) } + LocalCursorTints( + session: session, + spacing: spacing, + isReplaying: isReplaying, + replayCursor: replayCursor, + replayPlayheadTint: playheadTint + ) PuzzleGridLayout(columns: width, rows: height, spacing: spacing) { ForEach(0..<(width * height), id: \.self) { index in let r = index / width @@ -78,21 +78,15 @@ struct GridView: View { cell: session.puzzle.cells[r][c], entry: entry, mark: mark, - isSelected: isReplaying - ? replayCursor == pos - : (session.selectedRow == r && session.selectedCol == c), - isHighlighted: currentWordCells.contains(pos), crossRefPattern: cellGroups[pos].map { patternPalette[$0 % patternPalette.count] }, gridRow: r, gridCol: c, gridSpacing: spacing, - isRelatedToFocus: relatedCells.contains(pos), authorTint: entry.isEmpty ? nil - : letterAuthorID.flatMap { authorTintByID[$0] }, - selectionTint: replayCursor == pos ? playheadTint : nil + : letterAuthorID.flatMap { authorTintByID[$0] } ) .equatable() .onTapGesture { @@ -260,8 +254,9 @@ private struct PuzzleGridLayerLayout: Layout { /// 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. +/// `Canvas` layers (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`) 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 @@ -373,3 +368,75 @@ private struct RemoteCursorTints: View { return tint.mapValues { $0.1 } } } + +/// The local player's own cursor: the focused square (selection fill), the rest +/// of the focused word (highlight fill), and a border on cross-referenced cells +/// related to the focus. Like `RemoteCursorTints`, this is the only view that +/// reads `session`'s selection, so a local cursor move repaints just this Canvas +/// — a handful of cells — instead of re-evaluating the 441-cell grid above. In +/// replay it draws only the single playhead cell in the acting author's colour; +/// the live selection is suppressed. Sits above `RemoteCursorTints` so the local +/// cursor reads over a peer's track where they overlap. +private struct LocalCursorTints: View { + let session: PlayerSession + let spacing: CGFloat + let isReplaying: Bool + let replayCursor: GridPosition? + let replayPlayheadTint: Color? + + @Environment(PlayerPreferences.self) private var preferences + + var body: some View { + let fills = cellFills() + let borders = isReplaying ? [] : relatedBorderCells() + let borderColor = preferences.color.highlightFill + Canvas { context, size in + guard !fills.isEmpty || !borders.isEmpty else { return } + let geometry = PuzzleGridGeometry( + size: size, + columns: session.puzzle.width, + rows: session.puzzle.height, + spacing: spacing + ) + for (pos, color) in fills { + context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color)) + } + for pos in borders { + // Inset by half the line width so the stroke sits inside the + // cell, matching the in-cell `strokeBorder` it replaces. + let rect = geometry.cellRect(row: pos.row, col: pos.col).insetBy(dx: 1.5, dy: 1.5) + context.stroke(Path(rect), with: .color(borderColor), lineWidth: 3) + } + } + .allowsHitTesting(false) + } + + /// The selection/highlight fills. In replay this is just the playhead cell in + /// the acting author's colour; live, it's the focused word in the highlight + /// fill with the focused square overridden to the stronger selection fill. + private func cellFills() -> [GridPosition: Color] { + if isReplaying { + guard let replayCursor, let replayPlayheadTint else { return [:] } + return [replayCursor: replayPlayheadTint] + } + let color = preferences.color + var fills: [GridPosition: Color] = [:] + for cell in session.puzzle.wordCells( + atRow: session.selectedRow, + col: session.selectedCol, + direction: session.direction + ) { + fills[GridPosition(row: cell.row, col: cell.col)] = color.highlightFill + } + fills[GridPosition(row: session.selectedRow, col: session.selectedCol)] = color.selectionFill + return fills + } + + private func relatedBorderCells() -> [GridPosition] { + Array(session.puzzle.relatedCells( + atRow: session.selectedRow, + col: session.selectedCol, + direction: session.direction + )) + } +}