crossmate

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

GridView.swift (40632B)


      1 import SwiftUI
      2 
      3 struct GridView: View {
      4     @Bindable var session: PlayerSession
      5     let roster: PlayerRoster
      6     /// Owns the reveal confirmation alert (presented by `PuzzleView`); the
      7     /// grid's VoiceOver Reveal Square action requests through it so a reveal
      8     /// is never applied without the standard confirmation.
      9     let revealConfirmation: RevealConfirmation
     10     let showsSharedAnnotations: Bool
     11     /// Whether to render peers' live cursor tracks. Off for a solved puzzle —
     12     /// the game is no longer a live session, so the other player's cursor is
     13     /// dropped while author tints stay to colour the finished grid.
     14     var showsPeerCursors: Bool = true
     15     /// The finish-banner replay frame to render. When non-nil (the user has
     16     /// scrubbed back from the end), the grid renders this reconstructed history
     17     /// instead of the live `Game`: each touched cell shows its after-state,
     18     /// blanks elsewhere. Live selection, word highlight, and taps are
     19     /// suppressed, and the playhead cell is tinted in the acting author's
     20     /// colour. `nil` (the default, or a scrubber at rest) leaves normal play.
     21     var replayFrame: ReplayFrame? = nil
     22 
     23     private let spacing: CGFloat = 1
     24 
     25     /// The grid container's resolved size, captured so the single tap recogniser
     26     /// can map a tap location back to a cell via `PuzzleGridGeometry`.
     27     @State private var gridSize: CGSize = .zero
     28 
     29     /// Decoded `data=` decoration tiles, keyed by payload. Resolved once per
     30     /// puzzle rather than in `body`, which re-runs on every keystroke.
     31     @State private var decorationImages: [String: Image] = [:]
     32 
     33     var body: some View {
     34         let width = session.puzzle.width
     35         let height = session.puzzle.height
     36         let replayCells = replayFrame?.cells
     37         let isReplaying = replayFrame != nil
     38         let replayCursor = replayFrame?.cursor
     39         // Once the puzzle is solved, rebus squares show their canonical fill
     40         // (e.g. a Schrödinger square reads "MTWA" rather than the "WA" the
     41         // solver typed for the down answer). Purely a render-time substitution
     42         // — the stored entry, its authorship, and marks are left untouched.
     43         let showsCanonicalRebus = !isReplaying && session.game.completionState == .solved
     44         // Peer cursor tints are rendered in a separate Canvas layer (see
     45         // `RemoteCursorTints`) so this 441-cell grid no longer re-evaluates on
     46         // every peer cursor move — only on local selection and letter changes.
     47         let showsRemoteTints = showsSharedAnnotations && showsPeerCursors && !isReplaying
     48         // Author colours are shown for shared live play and for any replay
     49         // (the rewind reads as coloured per author, matching the scoreboard).
     50         let authorTintByID: [String: Color] = (showsSharedAnnotations || isReplaying)
     51             ? Dictionary(
     52                 roster.entries.map { ($0.authorID, $0.color.tint) },
     53                 uniquingKeysWith: { first, _ in first }
     54             )
     55             : [:]
     56         // Colour for the replay playhead: the acting author's selection fill,
     57         // so a rewound move reads in that player's colour (the local fill for
     58         // our own moves). `nil` outside replay leaves the local cursor as-is.
     59         let playheadTint: Color? = replayFrame?.cursorAuthorID
     60             .flatMap { id in roster.entries.first { $0.authorID == id } }
     61             .map { $0.color.selectionFill }
     62         let patternPalette = CrossRefPattern.allCases
     63         let cellGroups = session.puzzle.cellGroups
     64         // Flatten the grid to a plain draw model read here in `body` (so it
     65         // tracks the observable game state), then hand it to a single `Canvas`
     66         // that paints all cell content in one pass — replacing the former
     67         // 441-view `ForEach`, which dominated first-render cost. Block cells
     68         // carry no content; they're skipped at draw time but kept in the array
     69         // so indices map directly to positions.
     70         let cellModel: [CellDraw] = (0..<(width * height)).map { index in
     71             let r = index / width
     72             let c = index % width
     73             let cell = session.puzzle.cells[r][c]
     74             let visibleDecorations = session.puzzle.decorations[GridPosition(row: r, col: c)]?
     75                 .filter { $0.isVisible(solved: showsCanonicalRebus) } ?? []
     76             guard !cell.isBlock else {
     77                 return CellDraw(row: r, col: c, isBlock: true, special: nil,
     78                                 crossRef: nil, entry: "", isPencil: false,
     79                                 triangle: nil, authorTint: nil,
     80                                 decorations: visibleDecorations)
     81             }
     82             let pos = GridPosition(row: r, col: c)
     83             let square = session.game.squares[r][c]
     84             // During replay the cell's letter/mark/author come from the
     85             // reconstructed history, not the live square.
     86             let replayCell = replayCells?[pos]
     87             let entry = isReplaying ? (replayCell?.letter ?? "") : square.entry
     88             let displayEntry = canonicalRebusFill(for: cell, when: showsCanonicalRebus) ?? entry
     89             let mark = isReplaying ? (replayCell?.mark ?? .none) : square.mark
     90             let letterAuthorID = isReplaying ? replayCell?.cellAuthorID : square.letterAuthorID
     91             let triangle: TriangleKind? = mark.isRevealed
     92                 ? .revealed
     93                 : (mark.isCheckedWrong ? .wrong : nil)
     94             return CellDraw(
     95                 row: r,
     96                 col: c,
     97                 isBlock: false,
     98                 special: cell.special,
     99                 crossRef: cellGroups[pos].map { patternPalette[$0 % patternPalette.count] },
    100                 entry: displayEntry,
    101                 isPencil: mark.isPencil,
    102                 triangle: triangle,
    103                 authorTint: entry.isEmpty ? nil : letterAuthorID.flatMap { authorTintByID[$0] },
    104                 decorations: visibleDecorations
    105             )
    106         }
    107         // Layered back to front: black (shows through the inter-cell gaps and
    108         // behind blocks) -> white cell backdrop -> author tints -> peer cursor
    109         // tints -> local cursor tints -> cells. Keep these as siblings in one
    110         // layout so iPad-sized proposals cannot give the backing Canvases a
    111         // different drawing rect than the cells. The cursor layers each read
    112         // their own selection state in their own body, so a cursor move
    113         // repaints only a lightweight Canvas and never invalidates the cell
    114         // `ForEach`. Author tints sit below the local cursor so the focused
    115         // square's opaque fill can mask the tint (see `LocalCursorTints`).
    116         PuzzleGridLayerLayout(columns: width, rows: height, spacing: spacing) {
    117             GridBackdrop(puzzle: session.puzzle, spacing: spacing)
    118             AuthorTintsLayer(cells: cellModel, columns: width, rows: height, spacing: spacing)
    119             if showsRemoteTints {
    120                 RemoteCursorTints(roster: roster, puzzle: session.puzzle, spacing: spacing)
    121             }
    122             LocalCursorTints(
    123                 session: session,
    124                 spacing: spacing,
    125                 isReplaying: isReplaying,
    126                 replayCursor: replayCursor,
    127                 replayPlayheadTint: playheadTint
    128             )
    129             if !isReplaying {
    130                 RecentChangeBorders(session: session, roster: roster, spacing: spacing)
    131             }
    132             PuzzleCellsLayer(
    133                 cells: cellModel,
    134                 columns: width,
    135                 rows: height,
    136                 spacing: spacing,
    137                 decorationImages: decorationImages
    138             )
    139         }
    140         // One tap recogniser for the whole grid instead of 441 per-cell ones:
    141         // with uniform cells the tapped cell is pure arithmetic on the tap
    142         // location (see `PuzzleGridGeometry.cell(at:)`). `select` already guards
    143         // blocks and out-of-range, so any in-grid tap is safe to forward.
    144         .contentShape(Rectangle())
    145         .onTapGesture(coordinateSpace: .local) { location in
    146             guard !isReplaying, gridSize != .zero else { return }
    147             let geometry = PuzzleGridGeometry(
    148                 size: gridSize, columns: width, rows: height, spacing: spacing
    149             )
    150             if let (r, c) = geometry.cell(at: location) {
    151                 session.select(row: r, col: c)
    152             }
    153         }
    154         .onGeometryChange(for: CGSize.self) { $0.size } action: { gridSize = $0 }
    155         // Decode once per puzzle. Keyed on the game so switching puzzles inside
    156         // one grid view re-resolves rather than drawing the previous puzzle's
    157         // tiles; a puzzle with no `data=` decorations resolves to an empty map
    158         // and costs a single dictionary walk.
    159         .task(id: session.mutator.gameID) {
    160             decorationImages = DecorationImages.decode(session.puzzle.decorations)
    161         }
    162         // Synthetic VoiceOver elements over the Canvas layers; built only
    163         // while VoiceOver is running (see GridAccessibility.swift).
    164         .puzzleGridAccessibility(
    165             session: session,
    166             roster: roster,
    167             revealConfirmation: revealConfirmation,
    168             showsSharedAnnotations: showsSharedAnnotations,
    169             isReplaying: isReplaying,
    170             gridSize: gridSize,
    171             spacing: spacing
    172         )
    173     }
    174 
    175     /// The canonical fill to display for a rebus square on a solved puzzle, or
    176     /// `nil` to fall back to the stored entry. Only multi-character solutions
    177     /// (rebus/Schrödinger squares) differ from what the solver typed; ordinary
    178     /// single-letter cells return `nil` and render their entry unchanged.
    179     private func canonicalRebusFill(for cell: Puzzle.Cell, when isSolved: Bool) -> String? {
    180         guard isSolved, let solution = cell.solution, solution.count > 1 else { return nil }
    181         return solution
    182     }
    183 
    184 }
    185 
    186 // MARK: - Cell content
    187 
    188 /// Which corner-triangle marker a cell carries, if any. Mirrors the former
    189 /// `CellView.cornerTriangleColor`: revealed cells are yellow, checked-wrong red.
    190 private enum TriangleKind {
    191     case revealed
    192     case wrong
    193 }
    194 
    195 /// A flat, value-type snapshot of everything one cell draws. Built in
    196 /// `GridView.body` (so it tracks observable state) and rendered by
    197 /// `PuzzleCellsLayer`. Replaces the per-cell `CellView`.
    198 private struct CellDraw {
    199     let row: Int
    200     let col: Int
    201     let isBlock: Bool
    202     let special: Puzzle.Special?
    203     let crossRef: CrossRefPattern?
    204     let entry: String
    205     let isPencil: Bool
    206     let triangle: TriangleKind?
    207     /// The author's base tint, if this cell carries an entry in a shared game.
    208     /// `PlayerColor.authorTintOpacity` is applied at draw time (in
    209     /// `AuthorTintsLayer`, not `PuzzleCellsLayer`), matching the former
    210     /// `CellView` background.
    211     let authorTint: Color?
    212     /// Decoration layers currently visible on this cell, in paint order. Already
    213     /// filtered by phase, so the draw path never has to know whether the puzzle
    214     /// is solved. Unlike every other field here this is also populated for
    215     /// blocks, which is the whole point — an after-solve reveal routinely lands
    216     /// on black squares.
    217     let decorations: [Puzzle.Decoration]
    218 }
    219 
    220 /// The faint author-attribution washes, split out of `PuzzleCellsLayer` so they
    221 /// can sit *below* the cursor tints: `LocalCursorTints` paints the focused
    222 /// square opaquely, masking the author tint on that one cell so the cursor
    223 /// keeps a single fixed colour whether or not the square is filled. Reads the
    224 /// same `CellDraw` model as the cells layer, so it repaints on letter changes
    225 /// and never on cursor moves.
    226 private struct AuthorTintsLayer: View {
    227     let cells: [CellDraw]
    228     let columns: Int
    229     let rows: Int
    230     let spacing: CGFloat
    231 
    232     var body: some View {
    233         Canvas { context, size in
    234             let geometry = PuzzleGridGeometry(
    235                 size: size, columns: columns, rows: rows, spacing: spacing
    236             )
    237             guard geometry.cellSize > 0 else { return }
    238             for cell in cells {
    239                 guard let tint = cell.authorTint else { continue }
    240                 context.fill(
    241                     Path(geometry.cellRect(row: cell.row, col: cell.col)),
    242                     with: .color(tint.opacity(PlayerColor.authorTintOpacity))
    243                 )
    244             }
    245         }
    246         .allowsHitTesting(false)
    247     }
    248 }
    249 
    250 /// Draws all cell content (shaded/circled specials, cross-ref hatching,
    251 /// letters, corner triangles) in a single `Canvas`. This is the layer that
    252 /// previously cost ~441 SwiftUI view subtrees on first render; as one
    253 /// immediate-mode draw pass it builds in roughly constant time. Z-order within
    254 /// each cell matches the former `CellView`: shaded → cross-ref → circle →
    255 /// letter → corner triangle. Author tints are the exception — they render in
    256 /// `AuthorTintsLayer` beneath the cursor layers so the focused square's opaque
    257 /// selection fill can mask them.
    258 private struct PuzzleCellsLayer: View {
    259     let cells: [CellDraw]
    260     let columns: Int
    261     let rows: Int
    262     let spacing: CGFloat
    263     /// Decoded `data=` tiles, keyed by payload (see `DecorationImages`).
    264     var decorationImages: [String: Image] = [:]
    265     @Environment(\.colorScheme) private var colorScheme
    266 
    267     /// Base size before the per-cell fit-scale; mirrors the former
    268     /// `CellView` letter font (`.system(size: 34, weight: .semibold,
    269     /// design: .rounded)`).
    270     private let baseFontSize: CGFloat = 34
    271 
    272     var body: some View {
    273         Canvas { context, size in
    274             let geometry = PuzzleGridGeometry(
    275                 size: size, columns: columns, rows: rows, spacing: spacing
    276             )
    277             guard geometry.cellSize > 0 else { return }
    278 
    279             // Resolved letters are cached per (entry, pencil) for this draw: a
    280             // full grid has ~26 distinct glyphs, so almost every cell is a hit
    281             // and `resolve`/`measure` runs a couple of dozen times, not 441.
    282             var glyphCache: [String: (text: GraphicsContext.ResolvedText, size: CGSize)] = [:]
    283             var decorationTextCache:
    284                 [String: (text: GraphicsContext.ResolvedText, size: CGSize)] = [:]
    285             // Resolved tiles are cached for this draw the same way glyphs are:
    286             // a grid art puzzle repeats a handful of motifs across many cells.
    287             var imageCache: [String: GraphicsContext.ResolvedImage] = [:]
    288 
    289             // Decorations run over every cell, blocks included — an after-solve
    290             // reveal routinely lands on black squares, which the content loop
    291             // below skips entirely.
    292             for cell in cells where !cell.decorations.isEmpty {
    293                 drawDecorations(
    294                     cell,
    295                     in: geometry.cellRect(row: cell.row, col: cell.col),
    296                     textCache: &decorationTextCache,
    297                     cache: &imageCache,
    298                     context: context
    299                 )
    300             }
    301 
    302             for cell in cells where !cell.isBlock {
    303                 let rect = geometry.cellRect(row: cell.row, col: cell.col)
    304 
    305                 if cell.special == .shaded {
    306                     context.fill(Path(rect), with: .color(.black.opacity(0.22)))
    307                 }
    308                 if let crossRef = cell.crossRef {
    309                     drawCrossRef(crossRef, in: rect, row: cell.row, col: cell.col, context: context)
    310                 }
    311                 if cell.special == .circled {
    312                     context.stroke(
    313                         Path(ellipseIn: rect.insetBy(dx: 1.5, dy: 1.5)),
    314                         with: .color(.black.opacity(0.55)),
    315                         lineWidth: 1
    316                     )
    317                 }
    318                 if !cell.entry.isEmpty {
    319                     drawLetter(cell, in: rect, cache: &glyphCache, context: context)
    320                 }
    321                 if let triangle = cell.triangle {
    322                     let side = min(rect.width, rect.height) * 0.3
    323                     var path = Path()
    324                     path.move(to: CGPoint(x: rect.maxX - side, y: rect.minY))
    325                     path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
    326                     path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + side))
    327                     path.closeSubpath()
    328                     context.fill(path, with: .color(triangle == .revealed ? .yellow : .red))
    329                 }
    330             }
    331         }
    332     }
    333 
    334     /// Paints a cell's decoration layers in definition order — the format's
    335     /// stacking rule is that a repeated design character's lines paint in the
    336     /// order they were written.
    337     ///
    338     /// Two kinds are deliberately not drawn here. A `before`-phase `mark` is
    339     /// already folded into `Cell.special` by `Puzzle.init`, so drawing it again
    340     /// would double-stroke every circle; only an `after` mark needs handling.
    341     /// And `fg-*` colours belong to the entry glyph, so they're applied in
    342     /// `drawLetter` rather than as a layer of their own.
    343     private func drawDecorations(
    344         _ cell: CellDraw,
    345         in rect: CGRect,
    346         textCache: inout [String: (text: GraphicsContext.ResolvedText, size: CGSize)],
    347         cache: inout [String: GraphicsContext.ResolvedImage],
    348         context: GraphicsContext
    349     ) {
    350         for decoration in cell.decorations {
    351             switch decoration.content {
    352             case .mark(let special) where decoration.phase == .after:
    353                 switch special {
    354                 case .shaded:
    355                     context.fill(Path(rect), with: .color(.black.opacity(0.22)))
    356                 case .circled:
    357                     context.stroke(
    358                         Path(ellipseIn: rect.insetBy(dx: 1.5, dy: 1.5)),
    359                         with: .color(.black.opacity(0.55)),
    360                         lineWidth: 1
    361                     )
    362                 }
    363             case .mark:
    364                 continue
    365             case .color(.background, let light, let dark):
    366                 guard let color = Puzzle.Decoration.Content.color(
    367                     light: light, dark: dark, for: colorScheme
    368                 ) else {
    369                     continue
    370                 }
    371                 context.fill(Path(rect), with: .color(color))
    372             case .color:
    373                 continue
    374             case .text(let value):
    375                 let key = "\(value)|\(cell.isBlock)"
    376                 let resolved: GraphicsContext.ResolvedText
    377                 let natural: CGSize
    378                 if let hit = textCache[key] {
    379                     resolved = hit.text
    380                     natural = hit.size
    381                 } else {
    382                     let text = context.resolve(
    383                         Text(value)
    384                             .font(
    385                                 .system(
    386                                     size: baseFontSize * 0.8,
    387                                     weight: .semibold,
    388                                     design: .rounded
    389                                 )
    390                             )
    391                             .foregroundStyle(cell.isBlock ? Color.white : Color.black)
    392                     )
    393                     let measured = text.measure(
    394                         in: CGSize(width: CGFloat.infinity, height: CGFloat.infinity)
    395                     )
    396                     textCache[key] = (text, measured)
    397                     resolved = text
    398                     natural = measured
    399                 }
    400                 guard natural.width > 0, natural.height > 0 else { continue }
    401                 let availableWidth = rect.width - 4
    402                 let scale = min(1, availableWidth / natural.width, rect.height / natural.height)
    403                 context.drawLayer { layer in
    404                     layer.translateBy(x: rect.midX, y: rect.midY)
    405                     layer.scaleBy(x: scale, y: scale)
    406                     layer.draw(resolved, at: .zero, anchor: .center)
    407                 }
    408             case .data(_, _, let payload):
    409                 guard let image = decorationImages[payload] else { continue }
    410                 let resolved: GraphicsContext.ResolvedImage
    411                 if let hit = cache[payload] {
    412                     resolved = hit
    413                 } else {
    414                     resolved = context.resolve(image)
    415                     cache[payload] = resolved
    416                 }
    417                 context.draw(resolved, in: rect)
    418             }
    419         }
    420     }
    421 
    422     /// The entry colour override from an `fg-*` decoration, if one applies.
    423     private func foregroundOverride(for cell: CellDraw) -> Color? {
    424         for decoration in cell.decorations {
    425             guard case .color(.foreground, let light, let dark) = decoration.content,
    426                   let color = Puzzle.Decoration.Content.color(
    427                       light: light, dark: dark, for: colorScheme
    428                   ) else {
    429                 continue
    430             }
    431             return color
    432         }
    433         return nil
    434     }
    435 
    436     /// Draws one entry centred in its cell, scaled down to fit exactly as the
    437     /// former `.minimumScaleFactor(0.1)` did: the largest size ≤ base that fits
    438     /// the cell minus the 2pt horizontal padding. Single letters never scale (a
    439     /// 4-char rebus does), and the per-cell scale is pure arithmetic on one
    440     /// cached measurement.
    441     private func drawLetter(
    442         _ cell: CellDraw,
    443         in rect: CGRect,
    444         cache: inout [String: (text: GraphicsContext.ResolvedText, size: CGSize)],
    445         context: GraphicsContext
    446     ) {
    447         let foreground = foregroundOverride(for: cell)
    448         // The colour joins the cache key: two cells can share an entry and
    449         // differ only in an `fg-*` decoration, and caching on the entry alone
    450         // would paint the second in the first one's colour.
    451         let key = "\(cell.entry)|\(cell.isPencil)|\(foreground.map(String.init(describing:)) ?? "")"
    452         let resolved: GraphicsContext.ResolvedText
    453         let natural: CGSize
    454         if let hit = cache[key] {
    455             resolved = hit.text
    456             natural = hit.size
    457         } else {
    458             let color: Color = foreground ?? (cell.isPencil ? .black.opacity(0.5) : .black)
    459             let text = context.resolve(
    460                 Text(cell.entry)
    461                     .font(.system(size: baseFontSize, weight: .semibold, design: .rounded))
    462                     .foregroundStyle(color)
    463             )
    464             let measured = text.measure(in: CGSize(width: CGFloat.infinity, height: CGFloat.infinity))
    465             cache[key] = (text, measured)
    466             resolved = text
    467             natural = measured
    468         }
    469         guard natural.width > 0, natural.height > 0 else { return }
    470         let availableWidth = rect.width - 4  // matches .padding(.horizontal, 2)
    471         let scale = min(1, availableWidth / natural.width, rect.height / natural.height)
    472         context.drawLayer { layer in
    473             layer.translateBy(x: rect.midX, y: rect.midY)
    474             layer.scaleBy(x: scale, y: scale)
    475             layer.draw(resolved, at: .zero, anchor: .center)
    476         }
    477     }
    478 
    479     /// Strokes the passive cross-reference hatching for one cell, reusing the
    480     /// `CrossRefLines` lattice so lines stay continuous across cell borders.
    481     /// The path is generated in cell-local space, then clipped to the cell and
    482     /// translated into place.
    483     private func drawCrossRef(
    484         _ pattern: CrossRefPattern,
    485         in rect: CGRect,
    486         row: Int,
    487         col: Int,
    488         context: GraphicsContext
    489     ) {
    490         let ink = Color.black.opacity(0.20)
    491         let localRect = CGRect(x: 0, y: 0, width: rect.width, height: rect.height)
    492         func stroke(_ slope: CrossRefLines.Slope) {
    493             let path = CrossRefLines(slope: slope, row: row, col: col, spacing: spacing)
    494                 .path(in: localRect)
    495             var layer = context
    496             layer.clip(to: Path(rect))
    497             layer.translateBy(x: rect.minX, y: rect.minY)
    498             layer.stroke(path, with: .color(ink), lineWidth: 1)
    499         }
    500         switch pattern {
    501         case .diagonalDown: stroke(.down)
    502         case .diagonalUp: stroke(.up)
    503         case .crosshatch: stroke(.down); stroke(.up)
    504         case .horizontal: stroke(.horizontal)
    505         case .vertical: stroke(.vertical)
    506         }
    507     }
    508 }
    509 
    510 // MARK: - Layout
    511 
    512 private enum PuzzleGridMetrics {
    513     static func cellSize(
    514         for proposal: ProposedViewSize,
    515         columns: Int,
    516         rows: Int,
    517         spacing: CGFloat
    518     ) -> CGFloat {
    519         cellSize(
    520             availableWidth: proposal.width,
    521             availableHeight: proposal.height,
    522             columns: columns,
    523             rows: rows,
    524             spacing: spacing
    525         )
    526     }
    527 
    528     static func cellSize(
    529         availableWidth: CGFloat?,
    530         availableHeight: CGFloat?,
    531         columns: Int,
    532         rows: Int,
    533         spacing: CGFloat
    534     ) -> CGFloat {
    535         let cols = CGFloat(columns)
    536         let rs = CGFloat(rows)
    537         let width = availableWidth ?? .infinity
    538         let height = availableHeight ?? .infinity
    539         let widthBased = width.isFinite
    540             ? (width - spacing * (cols + 1)) / cols
    541             : .infinity
    542         let heightBased = height.isFinite
    543             ? (height - spacing * (rs + 1)) / rs
    544             : .infinity
    545         return max(0, min(widthBased, heightBased))
    546     }
    547 }
    548 
    549 /// Stacks the grid's backing layers and cell layout into one measured surface.
    550 /// Using `.background` for the Canvases can let SwiftUI hand those layers a
    551 /// different size from the custom cell layout on iPad, which makes their
    552 /// derived rects drift. This layout makes the Canvases draw from the same
    553 /// surface size and places the cell grid at the exact rect derived from it.
    554 private struct PuzzleGridLayerLayout: Layout {
    555     let columns: Int
    556     let rows: Int
    557     let spacing: CGFloat
    558 
    559     func sizeThatFits(
    560         proposal: ProposedViewSize,
    561         subviews: Subviews,
    562         cache: inout ()
    563     ) -> CGSize {
    564         let cellSize = PuzzleGridMetrics.cellSize(
    565             for: proposal,
    566             columns: columns,
    567             rows: rows,
    568             spacing: spacing
    569         )
    570         let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1)
    571         let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1)
    572         return CGSize(width: width, height: height)
    573     }
    574 
    575     func placeSubviews(
    576         in bounds: CGRect,
    577         proposal: ProposedViewSize,
    578         subviews: Subviews,
    579         cache: inout ()
    580     ) {
    581         guard let cellGrid = subviews.last else { return }
    582         let geometry = PuzzleGridGeometry(
    583             size: bounds.size,
    584             columns: columns,
    585             rows: rows,
    586             spacing: spacing
    587         )
    588         let layerProposal = ProposedViewSize(width: bounds.width, height: bounds.height)
    589         for subview in subviews.dropLast() {
    590             subview.place(
    591                 at: CGPoint(x: bounds.minX, y: bounds.minY),
    592                 anchor: .topLeading,
    593                 proposal: layerProposal
    594             )
    595         }
    596         // The view builder must emit backing layers first and the cell grid
    597         // last; the cell grid is the only subview placed on the tight grid rect.
    598         let cellGridRect = geometry.gridRect.offsetBy(dx: bounds.minX, dy: bounds.minY)
    599         cellGrid.place(
    600             at: cellGridRect.origin,
    601             anchor: .topLeading,
    602             proposal: ProposedViewSize(width: cellGridRect.width, height: cellGridRect.height)
    603         )
    604     }
    605 }
    606 
    607 /// Shared cell geometry for the puzzle grid: given a container `size`, computes
    608 /// the uniform cell size (via `PuzzleGridMetrics`) and the centred origin, then
    609 /// hands back the frame of any `(row, col)`. `PuzzleCellsLayer` and the backing
    610 /// `Canvas` layers (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`) all
    611 /// derive their cell rects from this, so every layer stays pixel-aligned; it
    612 /// also inverts the mapping (`cell(at:)`) for the grid's single tap recogniser.
    613 /// Internal (not private) so `GridAccessibility` can frame its synthetic
    614 /// VoiceOver elements on the same lattice.
    615 struct PuzzleGridGeometry {
    616     let cellSize: CGFloat
    617     let spacing: CGFloat
    618     let gridSize: CGSize
    619     private let columns: Int
    620     private let rows: Int
    621     private let originX: CGFloat
    622     private let originY: CGFloat
    623 
    624     init(size: CGSize, columns: Int, rows: Int, spacing: CGFloat) {
    625         let cellSize = PuzzleGridMetrics.cellSize(
    626             for: ProposedViewSize(width: size.width, height: size.height),
    627             columns: columns,
    628             rows: rows,
    629             spacing: spacing
    630         )
    631         let gridWidth = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1)
    632         let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1)
    633         self.cellSize = cellSize
    634         self.spacing = spacing
    635         self.columns = columns
    636         self.rows = rows
    637         self.gridSize = CGSize(width: gridWidth, height: gridHeight)
    638         self.originX = (size.width - gridWidth) / 2
    639         self.originY = (size.height - gridHeight) / 2
    640     }
    641 
    642     var gridRect: CGRect {
    643         CGRect(origin: CGPoint(x: originX, y: originY), size: gridSize)
    644     }
    645 
    646     func cellRect(row: Int, col: Int) -> CGRect {
    647         CGRect(
    648             x: originX + spacing + CGFloat(col) * (cellSize + spacing),
    649             y: originY + spacing + CGFloat(row) * (cellSize + spacing),
    650             width: cellSize,
    651             height: cellSize
    652         )
    653     }
    654 
    655     /// Inverse of `cellRect`: maps a tap point (in the grid's coordinate space)
    656     /// to the cell it falls in. Returns `nil` for points outside the grid (the
    657     /// centring margin / black border). The uniform lattice makes this pure
    658     /// arithmetic — the reason a single grid-wide tap recogniser can replace a
    659     /// per-cell one. Points landing in an inter-cell gap clamp to the nearest
    660     /// cell so the whole grid surface is tappable.
    661     func cell(at point: CGPoint) -> (row: Int, col: Int)? {
    662         guard gridRect.contains(point) else { return nil }
    663         let stride = cellSize + spacing
    664         guard stride > 0 else { return nil }
    665         let col = min(max(Int((point.x - originX - spacing) / stride), 0), columns - 1)
    666         let row = min(max(Int((point.y - originY - spacing) / stride), 0), rows - 1)
    667         return (row, col)
    668     }
    669 }
    670 
    671 // MARK: - Backing layers
    672 
    673 /// The opaque white cell backdrop, drawn behind the (clear-based) cells and
    674 /// beneath the peer cursor tints. Depends only on the puzzle's block layout, so
    675 /// it is static for the life of the game and never repaints during play. Blocks
    676 /// are skipped, leaving the container's black to show through.
    677 private struct GridBackdrop: View {
    678     let puzzle: Puzzle
    679     let spacing: CGFloat
    680 
    681     var body: some View {
    682         Canvas { context, size in
    683             let geometry = PuzzleGridGeometry(
    684                 size: size,
    685                 columns: puzzle.width,
    686                 rows: puzzle.height,
    687                 spacing: spacing
    688             )
    689             context.fill(Path(geometry.gridRect), with: .color(.black))
    690             for r in 0..<puzzle.height {
    691                 for c in 0..<puzzle.width where !puzzle.cells[r][c].isBlock {
    692                     context.fill(Path(geometry.cellRect(row: r, col: c)), with: .color(.white))
    693                 }
    694             }
    695         }
    696         .allowsHitTesting(false)
    697     }
    698 }
    699 
    700 /// Every present peer's selected answer, filled with that peer's selection
    701 /// colour so the track reads as a coloured run; the exact focused square is
    702 /// intentionally local-only. This is the only layer that observes the
    703 /// high-frequency `roster.remoteSelections` stream, so a peer cursor move
    704 /// repaints just this Canvas — drawing a handful of word cells — instead of
    705 /// re-evaluating the cell grid above.
    706 private struct RemoteCursorTints: View {
    707     let roster: PlayerRoster
    708     let puzzle: Puzzle
    709     let spacing: CGFloat
    710 
    711     var body: some View {
    712         let tints = remoteTrackTints()
    713         Canvas { context, size in
    714             guard !tints.isEmpty else { return }
    715             let geometry = PuzzleGridGeometry(
    716                 size: size,
    717                 columns: puzzle.width,
    718                 rows: puzzle.height,
    719                 spacing: spacing
    720             )
    721             for (pos, color) in tints {
    722                 context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color))
    723             }
    724         }
    725         .allowsHitTesting(false)
    726     }
    727 
    728     /// Resolves each peer's cursor track to a per-cell fill colour, latest
    729     /// write winning where two peers' words overlap a cell.
    730     private func remoteTrackTints() -> [GridPosition: Color] {
    731         var tint: [GridPosition: (Date, Color)] = [:]
    732         for (_, sel) in roster.remoteSelections {
    733             for cell in puzzle.wordCells(
    734                 atRow: sel.row, col: sel.col, direction: sel.direction
    735             ) {
    736                 let pos = GridPosition(row: cell.row, col: cell.col)
    737                 if tint[pos].map({ $0.0 < sel.updatedAt }) ?? true {
    738                     tint[pos] = (sel.updatedAt, sel.color.selectionFill)
    739                 }
    740             }
    741         }
    742         return tint.mapValues { $0.1 }
    743     }
    744 }
    745 
    746 /// The local player's own cursor: the focused square (selection fill), the rest
    747 /// of the focused word (highlight fill), and a border on cross-referenced cells
    748 /// related to the focus. Like `RemoteCursorTints`, this is the only view that
    749 /// reads `session`'s selection, so a local cursor move repaints just this Canvas
    750 /// — a handful of cells — instead of re-evaluating the 441-cell grid above. In
    751 /// replay it draws only the single playhead cell in the acting author's colour;
    752 /// the live selection is suppressed. Sits above `RemoteCursorTints` so the local
    753 /// cursor reads over a peer's track where they overlap.
    754 private struct LocalCursorTints: View {
    755     let session: PlayerSession
    756     let spacing: CGFloat
    757     let isReplaying: Bool
    758     let replayCursor: GridPosition?
    759     let replayPlayheadTint: Color?
    760 
    761     @Environment(PlayerPreferences.self) private var preferences
    762 
    763     var body: some View {
    764         let fills = cellFills()
    765         let borders = isReplaying ? [] : relatedBorderCells()
    766         let borderColor = preferences.color.highlightFill
    767         // The focused square is repainted from white before its selection fill
    768         // so the author tint in the layer beneath never stacks with it: the
    769         // cursor keeps one fixed colour whether or not the square is filled,
    770         // while the rest of the word still blends with author tints. Live play
    771         // only — the replay playhead keeps the plain translucent fill.
    772         let selectedCell: GridPosition? = isReplaying
    773             ? nil
    774             : GridPosition(row: session.selectedRow, col: session.selectedCol)
    775         Canvas { context, size in
    776             guard !fills.isEmpty || !borders.isEmpty else { return }
    777             let geometry = PuzzleGridGeometry(
    778                 size: size,
    779                 columns: session.puzzle.width,
    780                 rows: session.puzzle.height,
    781                 spacing: spacing
    782             )
    783             if let pos = selectedCell {
    784                 context.fill(
    785                     Path(geometry.cellRect(row: pos.row, col: pos.col)),
    786                     with: .color(.white)
    787                 )
    788             }
    789             for (pos, color) in fills {
    790                 context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color))
    791             }
    792             for pos in borders {
    793                 // Inset by half the line width so the stroke sits inside the
    794                 // cell, matching the in-cell `strokeBorder` it replaces.
    795                 let rect = geometry.cellRect(row: pos.row, col: pos.col).insetBy(dx: 1.5, dy: 1.5)
    796                 context.stroke(Path(rect), with: .color(borderColor), lineWidth: 3)
    797             }
    798         }
    799         .allowsHitTesting(false)
    800     }
    801 
    802     /// The selection/highlight fills. In replay this is just the playhead cell in
    803     /// the acting author's colour; live, it's the focused word in the highlight
    804     /// fill with the focused square overridden to the stronger selection fill.
    805     private func cellFills() -> [GridPosition: Color] {
    806         if isReplaying {
    807             guard let replayCursor, let replayPlayheadTint else { return [:] }
    808             return [replayCursor: replayPlayheadTint]
    809         }
    810         let color = preferences.color
    811         var fills: [GridPosition: Color] = [:]
    812         for cell in session.puzzle.wordCells(
    813             atRow: session.selectedRow,
    814             col: session.selectedCol,
    815             direction: session.direction
    816         ) {
    817             fills[GridPosition(row: cell.row, col: cell.col)] = color.highlightFill
    818         }
    819         fills[GridPosition(row: session.selectedRow, col: session.selectedCol)] = color.selectionFill
    820         return fills
    821     }
    822 
    823     private func relatedBorderCells() -> [GridPosition] {
    824         Array(session.puzzle.relatedCells(
    825             atRow: session.selectedRow,
    826             col: session.selectedCol,
    827             direction: session.direction
    828         ))
    829     }
    830 }
    831 
    832 /// Fading author-coloured borders on cells a peer filled or cleared since this
    833 /// player last viewed the puzzle. The set is captured once on the open arm beat
    834 /// (`PlayerSession.recentChanges`, populated by `PuzzleView`); this layer reveals
    835 /// it and fades it out when the player's first interaction clears the set. Like
    836 /// the cursor-tint layers it reads only its own slice of `session`, so it is the
    837 /// only view that repaints when the change set changes. Drawn over the cursor
    838 /// tints but behind the cells, matching `LocalCursorTints`' related-cell borders.
    839 private struct RecentChangeBorders: View {
    840     let session: PlayerSession
    841     let roster: PlayerRoster
    842     let spacing: CGFloat
    843 
    844     /// The resolved strokes currently drawn. Held locally so they stay on
    845     /// screen through the fade-out after `recentChanges` is cleared.
    846     @State private var shown: [GridPosition: Color] = [:]
    847     @State private var visible = false
    848 
    849     var body: some View {
    850         Canvas { context, size in
    851             guard !shown.isEmpty else { return }
    852             let geometry = PuzzleGridGeometry(
    853                 size: size,
    854                 columns: session.puzzle.width,
    855                 rows: session.puzzle.height,
    856                 spacing: spacing
    857             )
    858             for (pos, color) in shown {
    859                 let rect = geometry.cellRect(row: pos.row, col: pos.col)
    860                 let path = Path(rect.insetBy(dx: 1.5, dy: 1.5))
    861                 context.stroke(
    862                     path,
    863                     with: .color(color.opacity(0.10)),
    864                     lineWidth: 6
    865                 )
    866                 context.stroke(
    867                     path,
    868                     with: .color(color.opacity(0.30)),
    869                     lineWidth: 2
    870                 )
    871             }
    872         }
    873         .opacity(visible ? 1 : 0)
    874         .animation(.easeOut(duration: 0.45), value: visible)
    875         .allowsHitTesting(false)
    876         .onAppear { apply(session.recentChanges) }
    877         .onChange(of: session.recentChanges) { _, changes in apply(changes) }
    878     }
    879 
    880     private func apply(_ changes: [GridPosition: String]) {
    881         if changes.isEmpty {
    882             // Fade out, then drop the strokes once the animation has finished so
    883             // they remain visible while the opacity animates down.
    884             visible = false
    885             Task {
    886                 try? await Task.sleep(for: .milliseconds(500))
    887                 if session.recentChanges.isEmpty { shown = [:] }
    888             }
    889         } else {
    890             shown = resolve(changes)
    891             visible = true
    892         }
    893     }
    894 
    895     /// Maps each changed cell to its writer's colour, dropping out-of-bounds or
    896     /// block positions defensively. A writer no longer in the roster falls back
    897     /// to a neutral border rather than being skipped.
    898     private func resolve(_ changes: [GridPosition: String]) -> [GridPosition: Color] {
    899         let colorByAuthor = Dictionary(
    900             roster.entries.map { ($0.authorID, $0.color.tint) },
    901             uniquingKeysWith: { first, _ in first }
    902         )
    903         let width = session.puzzle.width
    904         let height = session.puzzle.height
    905         var result: [GridPosition: Color] = [:]
    906         for (pos, authorID) in changes {
    907             guard pos.row >= 0, pos.row < height, pos.col >= 0, pos.col < width else { continue }
    908             guard !session.puzzle.cells[pos.row][pos.col].isBlock else { continue }
    909             result[pos] = colorByAuthor[authorID] ?? Color.secondary
    910         }
    911         return result
    912     }
    913 }