crossmate

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

GridView.swift (22763B)


      1 import SwiftUI
      2 
      3 struct GridView: View {
      4     @Bindable var session: PlayerSession
      5     let roster: PlayerRoster
      6     let showsSharedAnnotations: Bool
      7     /// Whether to render peers' live cursor tracks. Off for a solved puzzle —
      8     /// the game is no longer a live session, so the other player's cursor is
      9     /// dropped while author tints stay to colour the finished grid.
     10     var showsPeerCursors: Bool = true
     11     /// The finish-banner replay frame to render. When non-nil (the user has
     12     /// scrubbed back from the end), the grid renders this reconstructed history
     13     /// instead of the live `Game`: each touched cell shows its after-state,
     14     /// blanks elsewhere. Live selection, word highlight, and taps are
     15     /// suppressed, and the playhead cell is tinted in the acting author's
     16     /// colour. `nil` (the default, or a scrubber at rest) leaves normal play.
     17     var replayFrame: ReplayFrame? = nil
     18 
     19     private let spacing: CGFloat = 1
     20 
     21     var body: some View {
     22         let width = session.puzzle.width
     23         let height = session.puzzle.height
     24         let replayCells = replayFrame?.cells
     25         let isReplaying = replayFrame != nil
     26         let replayCursor = replayFrame?.cursor
     27         // Once the puzzle is solved, rebus squares show their canonical fill
     28         // (e.g. a Schrödinger square reads "MTWA" rather than the "WA" the
     29         // solver typed for the down answer). Purely a render-time substitution
     30         // — the stored entry, its authorship, and marks are left untouched.
     31         let showsCanonicalRebus = !isReplaying && session.game.completionState == .solved
     32         // Peer cursor tints are rendered in a separate Canvas layer (see
     33         // `RemoteCursorTints`) so this 441-cell grid no longer re-evaluates on
     34         // every peer cursor move — only on local selection and letter changes.
     35         let showsRemoteTints = showsSharedAnnotations && showsPeerCursors && !isReplaying
     36         // Author colours are shown for shared live play and for any replay
     37         // (the rewind reads as coloured per author, matching the scoreboard).
     38         let authorTintByID: [String: Color] = (showsSharedAnnotations || isReplaying)
     39             ? Dictionary(
     40                 uniqueKeysWithValues: roster.entries.map { ($0.authorID, $0.color.tint) }
     41             )
     42             : [:]
     43         // Colour for the replay playhead: the acting author's selection fill,
     44         // so a rewound move reads in that player's colour (the local fill for
     45         // our own moves). `nil` outside replay leaves the local cursor as-is.
     46         let playheadTint: Color? = replayFrame?.cursorAuthorID
     47             .flatMap { id in roster.entries.first { $0.authorID == id } }
     48             .map { $0.color.selectionFill }
     49         let patternPalette = CrossRefPattern.allCases
     50         let cellGroups = session.puzzle.cellGroups
     51         // Layered back to front: black (shows through the inter-cell gaps and
     52         // behind blocks) -> white cell backdrop -> peer cursor tints -> local
     53         // cursor tints -> cells. Keep these as siblings in one layout so
     54         // iPad-sized proposals cannot give the backing Canvases a different
     55         // drawing rect than the cells. The cursor layers each read their own
     56         // selection state in their own body, so a cursor move repaints only a
     57         // lightweight Canvas and never invalidates the cell `ForEach`.
     58         PuzzleGridLayerLayout(columns: width, rows: height, spacing: spacing) {
     59             GridBackdrop(puzzle: session.puzzle, spacing: spacing)
     60             if showsRemoteTints {
     61                 RemoteCursorTints(roster: roster, puzzle: session.puzzle, spacing: spacing)
     62             }
     63             LocalCursorTints(
     64                 session: session,
     65                 spacing: spacing,
     66                 isReplaying: isReplaying,
     67                 replayCursor: replayCursor,
     68                 replayPlayheadTint: playheadTint
     69             )
     70             if !isReplaying {
     71                 RecentChangeBorders(session: session, roster: roster, spacing: spacing)
     72             }
     73             PuzzleGridLayout(columns: width, rows: height, spacing: spacing) {
     74                 ForEach(0..<(width * height), id: \.self) { index in
     75                     let r = index / width
     76                     let c = index % width
     77                     let pos = GridPosition(row: r, col: c)
     78                     let cell = session.puzzle.cells[r][c]
     79                     let square = session.game.squares[r][c]
     80                     // During replay the cell's letter/mark/author come from the
     81                     // reconstructed history, not the live square.
     82                     let replayCell = replayCells?[pos]
     83                     let entry = isReplaying ? (replayCell?.letter ?? "") : square.entry
     84                     let displayEntry = canonicalRebusFill(for: cell, when: showsCanonicalRebus) ?? entry
     85                     let mark = isReplaying ? (replayCell?.mark ?? .none) : square.mark
     86                     let letterAuthorID = isReplaying ? replayCell?.cellAuthorID : square.letterAuthorID
     87                     CellView(
     88                         cell: cell,
     89                         entry: displayEntry,
     90                         mark: mark,
     91                         crossRefPattern: cellGroups[pos].map {
     92                             patternPalette[$0 % patternPalette.count]
     93                         },
     94                         gridRow: r,
     95                         gridCol: c,
     96                         gridSpacing: spacing,
     97                         authorTint: entry.isEmpty
     98                             ? nil
     99                             : letterAuthorID.flatMap { authorTintByID[$0] }
    100                     )
    101                     .equatable()
    102                     .onTapGesture {
    103                         guard !isReplaying else { return }
    104                         session.select(row: r, col: c)
    105                     }
    106                 }
    107             }
    108         }
    109     }
    110 
    111     /// The canonical fill to display for a rebus square on a solved puzzle, or
    112     /// `nil` to fall back to the stored entry. Only multi-character solutions
    113     /// (rebus/Schrödinger squares) differ from what the solver typed; ordinary
    114     /// single-letter cells return `nil` and render their entry unchanged.
    115     private func canonicalRebusFill(for cell: Puzzle.Cell, when isSolved: Bool) -> String? {
    116         guard isSolved, let solution = cell.solution, solution.count > 1 else { return nil }
    117         return solution
    118     }
    119 
    120 }
    121 
    122 // MARK: - Layout
    123 
    124 private enum PuzzleGridMetrics {
    125     static func cellSize(
    126         for proposal: ProposedViewSize,
    127         columns: Int,
    128         rows: Int,
    129         spacing: CGFloat
    130     ) -> CGFloat {
    131         cellSize(
    132             availableWidth: proposal.width,
    133             availableHeight: proposal.height,
    134             columns: columns,
    135             rows: rows,
    136             spacing: spacing
    137         )
    138     }
    139 
    140     static func cellSize(
    141         availableWidth: CGFloat?,
    142         availableHeight: CGFloat?,
    143         columns: Int,
    144         rows: Int,
    145         spacing: CGFloat
    146     ) -> CGFloat {
    147         let cols = CGFloat(columns)
    148         let rs = CGFloat(rows)
    149         let width = availableWidth ?? .infinity
    150         let height = availableHeight ?? .infinity
    151         let widthBased = width.isFinite
    152             ? (width - spacing * (cols + 1)) / cols
    153             : .infinity
    154         let heightBased = height.isFinite
    155             ? (height - spacing * (rs + 1)) / rs
    156             : .infinity
    157         return max(0, min(widthBased, heightBased))
    158     }
    159 }
    160 
    161 /// Lays out puzzle cells in a `rows × columns` grid with a uniform square
    162 /// cell size. The cell size is chosen to fit the proposed container, with
    163 /// `spacing` points between every cell and around the outer edge (the black
    164 /// grid border shows through the gaps). The laid-out grid reports its tight
    165 /// size via `sizeThatFits`, so the parent view sizes us to exactly the grid
    166 /// dimensions — no `.aspectRatio` modifier needed.
    167 private struct PuzzleGridLayout: Layout {
    168     let columns: Int
    169     let rows: Int
    170     let spacing: CGFloat
    171 
    172     func sizeThatFits(
    173         proposal: ProposedViewSize,
    174         subviews: Subviews,
    175         cache: inout ()
    176     ) -> CGSize {
    177         let cellSize = PuzzleGridMetrics.cellSize(
    178             for: proposal,
    179             columns: columns,
    180             rows: rows,
    181             spacing: spacing
    182         )
    183         let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1)
    184         let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1)
    185         return CGSize(width: width, height: height)
    186     }
    187 
    188     func placeSubviews(
    189         in bounds: CGRect,
    190         proposal: ProposedViewSize,
    191         subviews: Subviews,
    192         cache: inout ()
    193     ) {
    194         let geometry = PuzzleGridGeometry(
    195             size: bounds.size,
    196             columns: columns,
    197             rows: rows,
    198             spacing: spacing
    199         )
    200         let cellProposal = ProposedViewSize(
    201             width: geometry.cellSize,
    202             height: geometry.cellSize
    203         )
    204         for (index, subview) in subviews.enumerated() {
    205             let rect = geometry.cellRect(row: index / columns, col: index % columns)
    206             subview.place(
    207                 at: CGPoint(x: bounds.minX + rect.minX, y: bounds.minY + rect.minY),
    208                 anchor: .topLeading,
    209                 proposal: cellProposal
    210             )
    211         }
    212     }
    213 }
    214 
    215 /// Stacks the grid's backing layers and cell layout into one measured surface.
    216 /// Using `.background` for the Canvases can let SwiftUI hand those layers a
    217 /// different size from the custom cell layout on iPad, which makes their
    218 /// derived rects drift. This layout makes the Canvases draw from the same
    219 /// surface size and places the cell grid at the exact rect derived from it.
    220 private struct PuzzleGridLayerLayout: Layout {
    221     let columns: Int
    222     let rows: Int
    223     let spacing: CGFloat
    224 
    225     func sizeThatFits(
    226         proposal: ProposedViewSize,
    227         subviews: Subviews,
    228         cache: inout ()
    229     ) -> CGSize {
    230         let cellSize = PuzzleGridMetrics.cellSize(
    231             for: proposal,
    232             columns: columns,
    233             rows: rows,
    234             spacing: spacing
    235         )
    236         let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1)
    237         let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1)
    238         return CGSize(width: width, height: height)
    239     }
    240 
    241     func placeSubviews(
    242         in bounds: CGRect,
    243         proposal: ProposedViewSize,
    244         subviews: Subviews,
    245         cache: inout ()
    246     ) {
    247         guard let cellGrid = subviews.last else { return }
    248         let geometry = PuzzleGridGeometry(
    249             size: bounds.size,
    250             columns: columns,
    251             rows: rows,
    252             spacing: spacing
    253         )
    254         let layerProposal = ProposedViewSize(width: bounds.width, height: bounds.height)
    255         for subview in subviews.dropLast() {
    256             subview.place(
    257                 at: CGPoint(x: bounds.minX, y: bounds.minY),
    258                 anchor: .topLeading,
    259                 proposal: layerProposal
    260             )
    261         }
    262         // The view builder must emit backing layers first and the cell grid
    263         // last; the cell grid is the only subview placed on the tight grid rect.
    264         let cellGridRect = geometry.gridRect.offsetBy(dx: bounds.minX, dy: bounds.minY)
    265         cellGrid.place(
    266             at: cellGridRect.origin,
    267             anchor: .topLeading,
    268             proposal: ProposedViewSize(width: cellGridRect.width, height: cellGridRect.height)
    269         )
    270     }
    271 }
    272 
    273 /// Shared cell geometry for the puzzle grid: given a container `size`, computes
    274 /// the uniform cell size (via `PuzzleGridMetrics`) and the centred origin, then
    275 /// hands back the frame of any `(row, col)`. `PuzzleGridLayout` and the backing
    276 /// `Canvas` layers (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`) all
    277 /// derive their cell rects from this, so the layers stay pixel-aligned with the
    278 /// cells above them.
    279 private struct PuzzleGridGeometry {
    280     let cellSize: CGFloat
    281     let spacing: CGFloat
    282     let gridSize: CGSize
    283     private let originX: CGFloat
    284     private let originY: CGFloat
    285 
    286     init(size: CGSize, columns: Int, rows: Int, spacing: CGFloat) {
    287         let cellSize = PuzzleGridMetrics.cellSize(
    288             for: ProposedViewSize(width: size.width, height: size.height),
    289             columns: columns,
    290             rows: rows,
    291             spacing: spacing
    292         )
    293         let gridWidth = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1)
    294         let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1)
    295         self.cellSize = cellSize
    296         self.spacing = spacing
    297         self.gridSize = CGSize(width: gridWidth, height: gridHeight)
    298         self.originX = (size.width - gridWidth) / 2
    299         self.originY = (size.height - gridHeight) / 2
    300     }
    301 
    302     var gridRect: CGRect {
    303         CGRect(origin: CGPoint(x: originX, y: originY), size: gridSize)
    304     }
    305 
    306     func cellRect(row: Int, col: Int) -> CGRect {
    307         CGRect(
    308             x: originX + spacing + CGFloat(col) * (cellSize + spacing),
    309             y: originY + spacing + CGFloat(row) * (cellSize + spacing),
    310             width: cellSize,
    311             height: cellSize
    312         )
    313     }
    314 }
    315 
    316 // MARK: - Backing layers
    317 
    318 /// The opaque white cell backdrop, drawn behind the (clear-based) cells and
    319 /// beneath the peer cursor tints. Depends only on the puzzle's block layout, so
    320 /// it is static for the life of the game and never repaints during play. Blocks
    321 /// are skipped, leaving the container's black to show through.
    322 private struct GridBackdrop: View {
    323     let puzzle: Puzzle
    324     let spacing: CGFloat
    325 
    326     var body: some View {
    327         Canvas { context, size in
    328             let geometry = PuzzleGridGeometry(
    329                 size: size,
    330                 columns: puzzle.width,
    331                 rows: puzzle.height,
    332                 spacing: spacing
    333             )
    334             context.fill(Path(geometry.gridRect), with: .color(.black))
    335             for r in 0..<puzzle.height {
    336                 for c in 0..<puzzle.width where !puzzle.cells[r][c].isBlock {
    337                     context.fill(Path(geometry.cellRect(row: r, col: c)), with: .color(.white))
    338                 }
    339             }
    340         }
    341         .allowsHitTesting(false)
    342     }
    343 }
    344 
    345 /// Every present peer's selected answer, filled with that peer's selection
    346 /// colour so the track reads as a coloured run; the exact focused square is
    347 /// intentionally local-only. This is the only layer that observes the
    348 /// high-frequency `roster.remoteSelections` stream, so a peer cursor move
    349 /// repaints just this Canvas — drawing a handful of word cells — instead of
    350 /// re-evaluating the cell grid above.
    351 private struct RemoteCursorTints: View {
    352     let roster: PlayerRoster
    353     let puzzle: Puzzle
    354     let spacing: CGFloat
    355 
    356     var body: some View {
    357         let tints = remoteTrackTints()
    358         Canvas { context, size in
    359             guard !tints.isEmpty else { return }
    360             let geometry = PuzzleGridGeometry(
    361                 size: size,
    362                 columns: puzzle.width,
    363                 rows: puzzle.height,
    364                 spacing: spacing
    365             )
    366             for (pos, color) in tints {
    367                 context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color))
    368             }
    369         }
    370         .allowsHitTesting(false)
    371     }
    372 
    373     /// Resolves each peer's cursor track to a per-cell fill colour, latest
    374     /// write winning where two peers' words overlap a cell.
    375     private func remoteTrackTints() -> [GridPosition: Color] {
    376         var tint: [GridPosition: (Date, Color)] = [:]
    377         for (_, sel) in roster.remoteSelections {
    378             for cell in puzzle.wordCells(
    379                 atRow: sel.row, col: sel.col, direction: sel.direction
    380             ) {
    381                 let pos = GridPosition(row: cell.row, col: cell.col)
    382                 if tint[pos].map({ $0.0 < sel.updatedAt }) ?? true {
    383                     tint[pos] = (sel.updatedAt, sel.color.selectionFill)
    384                 }
    385             }
    386         }
    387         return tint.mapValues { $0.1 }
    388     }
    389 }
    390 
    391 /// The local player's own cursor: the focused square (selection fill), the rest
    392 /// of the focused word (highlight fill), and a border on cross-referenced cells
    393 /// related to the focus. Like `RemoteCursorTints`, this is the only view that
    394 /// reads `session`'s selection, so a local cursor move repaints just this Canvas
    395 /// — a handful of cells — instead of re-evaluating the 441-cell grid above. In
    396 /// replay it draws only the single playhead cell in the acting author's colour;
    397 /// the live selection is suppressed. Sits above `RemoteCursorTints` so the local
    398 /// cursor reads over a peer's track where they overlap.
    399 private struct LocalCursorTints: View {
    400     let session: PlayerSession
    401     let spacing: CGFloat
    402     let isReplaying: Bool
    403     let replayCursor: GridPosition?
    404     let replayPlayheadTint: Color?
    405 
    406     @Environment(PlayerPreferences.self) private var preferences
    407 
    408     var body: some View {
    409         let fills = cellFills()
    410         let borders = isReplaying ? [] : relatedBorderCells()
    411         let borderColor = preferences.color.highlightFill
    412         Canvas { context, size in
    413             guard !fills.isEmpty || !borders.isEmpty else { return }
    414             let geometry = PuzzleGridGeometry(
    415                 size: size,
    416                 columns: session.puzzle.width,
    417                 rows: session.puzzle.height,
    418                 spacing: spacing
    419             )
    420             for (pos, color) in fills {
    421                 context.fill(Path(geometry.cellRect(row: pos.row, col: pos.col)), with: .color(color))
    422             }
    423             for pos in borders {
    424                 // Inset by half the line width so the stroke sits inside the
    425                 // cell, matching the in-cell `strokeBorder` it replaces.
    426                 let rect = geometry.cellRect(row: pos.row, col: pos.col).insetBy(dx: 1.5, dy: 1.5)
    427                 context.stroke(Path(rect), with: .color(borderColor), lineWidth: 3)
    428             }
    429         }
    430         .allowsHitTesting(false)
    431     }
    432 
    433     /// The selection/highlight fills. In replay this is just the playhead cell in
    434     /// the acting author's colour; live, it's the focused word in the highlight
    435     /// fill with the focused square overridden to the stronger selection fill.
    436     private func cellFills() -> [GridPosition: Color] {
    437         if isReplaying {
    438             guard let replayCursor, let replayPlayheadTint else { return [:] }
    439             return [replayCursor: replayPlayheadTint]
    440         }
    441         let color = preferences.color
    442         var fills: [GridPosition: Color] = [:]
    443         for cell in session.puzzle.wordCells(
    444             atRow: session.selectedRow,
    445             col: session.selectedCol,
    446             direction: session.direction
    447         ) {
    448             fills[GridPosition(row: cell.row, col: cell.col)] = color.highlightFill
    449         }
    450         fills[GridPosition(row: session.selectedRow, col: session.selectedCol)] = color.selectionFill
    451         return fills
    452     }
    453 
    454     private func relatedBorderCells() -> [GridPosition] {
    455         Array(session.puzzle.relatedCells(
    456             atRow: session.selectedRow,
    457             col: session.selectedCol,
    458             direction: session.direction
    459         ))
    460     }
    461 }
    462 
    463 /// Fading author-coloured borders on cells a peer filled or cleared since this
    464 /// player last viewed the puzzle. The set is captured once on the open arm beat
    465 /// (`PlayerSession.recentChanges`, populated by `PuzzleView`); this layer reveals
    466 /// it and fades it out when the player's first interaction clears the set. Like
    467 /// the cursor-tint layers it reads only its own slice of `session`, so it is the
    468 /// only view that repaints when the change set changes. Drawn over the cursor
    469 /// tints but behind the cells, matching `LocalCursorTints`' related-cell borders.
    470 private struct RecentChangeBorders: View {
    471     let session: PlayerSession
    472     let roster: PlayerRoster
    473     let spacing: CGFloat
    474 
    475     /// The resolved strokes currently drawn. Held locally so they stay on
    476     /// screen through the fade-out after `recentChanges` is cleared.
    477     @State private var shown: [GridPosition: Color] = [:]
    478     @State private var visible = false
    479 
    480     var body: some View {
    481         Canvas { context, size in
    482             guard !shown.isEmpty else { return }
    483             let geometry = PuzzleGridGeometry(
    484                 size: size,
    485                 columns: session.puzzle.width,
    486                 rows: session.puzzle.height,
    487                 spacing: spacing
    488             )
    489             for (pos, color) in shown {
    490                 let rect = geometry.cellRect(row: pos.row, col: pos.col)
    491                 let path = Path(rect.insetBy(dx: 1.5, dy: 1.5))
    492                 context.stroke(
    493                     path,
    494                     with: .color(color.opacity(0.10)),
    495                     lineWidth: 6
    496                 )
    497                 context.stroke(
    498                     path,
    499                     with: .color(color.opacity(0.30)),
    500                     lineWidth: 2
    501                 )
    502             }
    503         }
    504         .opacity(visible ? 1 : 0)
    505         .animation(.easeOut(duration: 0.45), value: visible)
    506         .allowsHitTesting(false)
    507         .onAppear { apply(session.recentChanges) }
    508         .onChange(of: session.recentChanges) { _, changes in apply(changes) }
    509     }
    510 
    511     private func apply(_ changes: [GridPosition: String]) {
    512         if changes.isEmpty {
    513             // Fade out, then drop the strokes once the animation has finished so
    514             // they remain visible while the opacity animates down.
    515             visible = false
    516             Task {
    517                 try? await Task.sleep(for: .milliseconds(500))
    518                 if session.recentChanges.isEmpty { shown = [:] }
    519             }
    520         } else {
    521             shown = resolve(changes)
    522             visible = true
    523         }
    524     }
    525 
    526     /// Maps each changed cell to its writer's colour, dropping out-of-bounds or
    527     /// block positions defensively. A writer no longer in the roster falls back
    528     /// to a neutral border rather than being skipped.
    529     private func resolve(_ changes: [GridPosition: String]) -> [GridPosition: Color] {
    530         let colorByAuthor = Dictionary(
    531             roster.entries.map { ($0.authorID, $0.color.tint) },
    532             uniquingKeysWith: { first, _ in first }
    533         )
    534         let width = session.puzzle.width
    535         let height = session.puzzle.height
    536         var result: [GridPosition: Color] = [:]
    537         for (pos, authorID) in changes {
    538             guard pos.row >= 0, pos.row < height, pos.col >= 0, pos.col < width else { continue }
    539             guard !session.puzzle.cells[pos.row][pos.col].isBlock else { continue }
    540             result[pos] = colorByAuthor[authorID] ?? Color.secondary
    541         }
    542         return result
    543     }
    544 }