crossmate

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

commit 49a6a35033d19b357ec1865906eb36ddc3d789e3
parent 0756081eeb4b1568b45d89569ae0975d416caf2b
Author: Michael Camilleri <[email protected]>
Date:   Sat, 25 Jul 2026 11:49:05 +0900

Keep the selected square clear of author tints

In a shared game the faint author wash stacked with the cursor fills, so
a partially filled word under the cursor showed up to four intensities
of the player's colour — highlight and selection, each with or without
the wash on top. Picking out the focused square became a judgement of
relative darkness rather than a glance. Now the focused square always
renders as the player's plain selection fill, while the rest of the word
still blends with author tints, so the attribution signal survives
everywhere except the one cell where it competed with the cursor.

This commit moves the author washes out of PuzzleCellsLayer into a new
AuthorTintsLayer beneath the cursor layers, and LocalCursorTints
repaints the focused square from white before applying its selection
fill, masking the wash on that cell alone. The new layer reads the same
CellDraw model as the cells layer, so the invalidation split is
preserved: letter changes repaint the tint layer, and a cursor move
still repaints only the lightweight cursor Canvas. The replay playhead
keeps its translucent author-coloured fill.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Views/Puzzle/GridView.swift | 82++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
1 file changed, 63 insertions(+), 19 deletions(-)

diff --git a/Crossmate/Views/Puzzle/GridView.swift b/Crossmate/Views/Puzzle/GridView.swift @@ -97,14 +97,17 @@ struct GridView: View { ) } // Layered back to front: black (shows through the inter-cell gaps and - // 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`. + // behind blocks) -> white cell backdrop -> author tints -> 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`. Author tints sit below the local cursor so the focused + // square's opaque fill can mask the tint (see `LocalCursorTints`). PuzzleGridLayerLayout(columns: width, rows: height, spacing: spacing) { GridBackdrop(puzzle: session.puzzle, spacing: spacing) + AuthorTintsLayer(cells: cellModel, columns: width, rows: height, spacing: spacing) if showsRemoteTints { RemoteCursorTints(roster: roster, puzzle: session.puzzle, spacing: spacing) } @@ -186,17 +189,50 @@ private struct CellDraw { let isPencil: Bool let triangle: TriangleKind? /// The author's base tint, if this cell carries an entry in a shared game. - /// `PlayerColor.authorTintOpacity` is applied at draw time, matching the - /// former `CellView` background. + /// `PlayerColor.authorTintOpacity` is applied at draw time (in + /// `AuthorTintsLayer`, not `PuzzleCellsLayer`), matching the former + /// `CellView` background. let authorTint: Color? } -/// Draws all cell content (author tints, shaded/circled specials, cross-ref -/// hatching, letters, corner triangles) in a single `Canvas`. This is the layer -/// that previously cost ~441 SwiftUI view subtrees on first render; as one +/// The faint author-attribution washes, split out of `PuzzleCellsLayer` so they +/// can sit *below* the cursor tints: `LocalCursorTints` paints the focused +/// square opaquely, masking the author tint on that one cell so the cursor +/// keeps a single fixed colour whether or not the square is filled. Reads the +/// same `CellDraw` model as the cells layer, so it repaints on letter changes +/// and never on cursor moves. +private struct AuthorTintsLayer: View { + let cells: [CellDraw] + let columns: Int + let rows: Int + let spacing: CGFloat + + var body: some View { + Canvas { context, size in + let geometry = PuzzleGridGeometry( + size: size, columns: columns, rows: rows, spacing: spacing + ) + guard geometry.cellSize > 0 else { return } + for cell in cells { + guard let tint = cell.authorTint else { continue } + context.fill( + Path(geometry.cellRect(row: cell.row, col: cell.col)), + with: .color(tint.opacity(PlayerColor.authorTintOpacity)) + ) + } + } + .allowsHitTesting(false) + } +} + +/// Draws all cell content (shaded/circled specials, cross-ref hatching, +/// letters, corner triangles) in a single `Canvas`. This is the layer that +/// previously cost ~441 SwiftUI view subtrees on first render; as one /// immediate-mode draw pass it builds in roughly constant time. Z-order within -/// each cell matches the former `CellView` exactly: shaded → author tint → -/// cross-ref → circle → letter → corner triangle. +/// each cell matches the former `CellView`: shaded → cross-ref → circle → +/// letter → corner triangle. Author tints are the exception — they render in +/// `AuthorTintsLayer` beneath the cursor layers so the focused square's opaque +/// selection fill can mask them. private struct PuzzleCellsLayer: View { let cells: [CellDraw] let columns: Int @@ -226,12 +262,6 @@ private struct PuzzleCellsLayer: View { if cell.special == .shaded { context.fill(Path(rect), with: .color(.black.opacity(0.22))) } - if let tint = cell.authorTint { - context.fill( - Path(rect), - with: .color(tint.opacity(PlayerColor.authorTintOpacity)) - ) - } if let crossRef = cell.crossRef { drawCrossRef(crossRef, in: rect, row: cell.row, col: cell.col, context: context) } @@ -585,6 +615,14 @@ private struct LocalCursorTints: View { let fills = cellFills() let borders = isReplaying ? [] : relatedBorderCells() let borderColor = preferences.color.highlightFill + // The focused square is repainted from white before its selection fill + // so the author tint in the layer beneath never stacks with it: the + // cursor keeps one fixed colour whether or not the square is filled, + // while the rest of the word still blends with author tints. Live play + // only — the replay playhead keeps the plain translucent fill. + let selectedCell: GridPosition? = isReplaying + ? nil + : GridPosition(row: session.selectedRow, col: session.selectedCol) Canvas { context, size in guard !fills.isEmpty || !borders.isEmpty else { return } let geometry = PuzzleGridGeometry( @@ -593,6 +631,12 @@ private struct LocalCursorTints: View { rows: session.puzzle.height, spacing: spacing ) + if let pos = selectedCell { + context.fill( + Path(geometry.cellRect(row: pos.row, col: pos.col)), + with: .color(.white) + ) + } for (pos, color) in fills { context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color)) }