crossmate

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

CellView.swift (11394B)


      1 import SwiftUI
      2 
      3 struct CellView: View, Equatable {
      4     let cell: Puzzle.Cell
      5     let entry: String
      6     let mark: CellMark
      7     /// Passive cross-reference texture for this cell, or `nil` if the cell
      8     /// belongs to no cross-referenced clue. Each group in the puzzle is
      9     /// assigned a distinct pattern so separate links read differently.
     10     var crossRefPattern: CrossRefPattern? = nil
     11     /// This cell's grid position and the grid's inter-cell spacing. Passed
     12     /// through to the cross-reference texture so its line family is phased
     13     /// to a grid-wide lattice and stays continuous across cell borders.
     14     var gridRow: Int = 0
     15     var gridCol: Int = 0
     16     var gridSpacing: CGFloat = 1
     17     var authorTint: Color? = nil
     18 
     19     nonisolated static func == (lhs: CellView, rhs: CellView) -> Bool {
     20         lhs.cell == rhs.cell
     21             && lhs.entry == rhs.entry
     22             && lhs.mark == rhs.mark
     23             && lhs.crossRefPattern == rhs.crossRefPattern
     24             && lhs.gridRow == rhs.gridRow
     25             && lhs.gridCol == rhs.gridCol
     26             && lhs.authorTint == rhs.authorTint
     27     }
     28 
     29     var body: some View {
     30         ZStack(alignment: .topLeading) {
     31             background
     32             if !cell.isBlock {
     33                 // Passive cross-reference texture. Sits above the
     34                 // background tints (so it survives selection/author
     35                 // colour) but below the letter and corner markers so it
     36                 // never fights legibility.
     37                 if let crossRefPattern {
     38                     CrossRefPatternView(
     39                         pattern: crossRefPattern,
     40                         row: gridRow,
     41                         col: gridCol,
     42                         spacing: gridSpacing
     43                     )
     44                 }
     45                 if cell.special == .circled {
     46                     Circle()
     47                         .stroke(Color.black.opacity(0.55), lineWidth: 1)
     48                         .padding(1.5)
     49                 }
     50                 Text(entry)
     51                     .font(.system(size: 34, weight: .semibold, design: .rounded))
     52                     .foregroundStyle(entryStyle)
     53                     .lineLimit(1)
     54                     .minimumScaleFactor(0.1)
     55                     .allowsTightening(true)
     56                     .padding(.horizontal, 2)
     57                     .frame(maxWidth: .infinity, maxHeight: .infinity)
     58                 if let triangleColor = cornerTriangleColor {
     59                     CornerTriangle()
     60                         .fill(triangleColor)
     61                 }
     62             }
     63         }
     64         .frame(maxWidth: .infinity, maxHeight: .infinity)
     65         .contentShape(Rectangle())
     66     }
     67 
     68     /// Foreground style for the main entry letter. The cell fill is always
     69     /// white regardless of system appearance, so the letter ink is always
     70     /// black — it must not follow Dark Mode or it would vanish against the
     71     /// white cell. Pencil entries render as a lighter black; everything else
     72     /// — including revealed and checkedWrong cells — uses solid black. Reveal/
     73     /// wrong state is shown purely via the corner triangle, not by recolouring
     74     /// the letter.
     75     private var entryStyle: AnyShapeStyle {
     76         switch mark {
     77         case .pencil:
     78             return AnyShapeStyle(Color.black.opacity(0.5))
     79         case .none, .pen, .revealed:
     80             return AnyShapeStyle(Color.black)
     81         }
     82     }
     83 
     84     /// Fill colour for the top-right corner triangle: yellow for revealed
     85     /// cells, red for checkedWrong, nothing otherwise. Revealed and
     86     /// checkedWrong are mutually exclusive (a revealed cell can't be wrong),
     87     /// so the order of the checks doesn't matter.
     88     private var cornerTriangleColor: Color? {
     89         if mark.isRevealed { return .yellow }
     90         if mark.isCheckedWrong { return .red }
     91         return nil
     92     }
     93 
     94     // Drawn over a clear base: the white cell fill, the peer cursor tints, and
     95     // the local cursor's selection/highlight are all rendered behind the grid
     96     // (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`), so they show
     97     // through wherever this cell draws nothing. Blocks stay fully clear, letting
     98     // the grid's black show through.
     99     @ViewBuilder
    100     private var background: some View {
    101         if !cell.isBlock {
    102             ZStack {
    103                 if cell.special == .shaded {
    104                     Color.black.opacity(0.22)
    105                 }
    106                 // Faint background tint identifying who entered this letter in a
    107                 // shared game. The cursor fills it composites over are drawn
    108                 // behind the grid (see `LocalCursorTints`/`RemoteCursorTints`).
    109                 if let authorTint {
    110                     authorTint.opacity(PlayerColor.authorTintOpacity)
    111                 }
    112             }
    113         }
    114     }
    115 }
    116 
    117 /// The ordered palette of passive cross-reference textures. A puzzle can
    118 /// have several independent cross-reference groups; each is assigned the
    119 /// next pattern in this sequence (wrapping if there are more groups than
    120 /// patterns) so distinct links read differently at a glance. Monochrome
    121 /// by design — the texture layers over any cursor/author colour without
    122 /// competing with the app's colour-coded state.
    123 enum CrossRefPattern: CaseIterable, Sendable {
    124     case diagonalDown
    125     case diagonalUp
    126     case crosshatch
    127     case horizontal
    128     case vertical
    129 }
    130 
    131 /// Renders one `CrossRefPattern` filling the cell. Diagonal/line patterns
    132 /// are clipped to the cell bounds; the whole overlay is non-interactive.
    133 private struct CrossRefPatternView: View {
    134     let pattern: CrossRefPattern
    135     /// Grid position and inter-cell spacing, forwarded to `CrossRefLines`
    136     /// so the hatching is phased to a grid-wide lattice.
    137     let row: Int
    138     let col: Int
    139     let spacing: CGFloat
    140 
    141     /// Neutral ink that reads on white and over the (lightly tinted)
    142     /// selection/author fills alike. Tunable while we evaluate the look.
    143     private let ink = Color.black.opacity(0.20)
    144     private let lineWidth: CGFloat = 1
    145 
    146     private func lines(_ slope: CrossRefLines.Slope) -> CrossRefLines {
    147         CrossRefLines(slope: slope, row: row, col: col, spacing: spacing)
    148     }
    149 
    150     var body: some View {
    151         Group {
    152             switch pattern {
    153             case .diagonalDown:
    154                 lines(.down).stroke(ink, lineWidth: lineWidth)
    155             case .diagonalUp:
    156                 lines(.up).stroke(ink, lineWidth: lineWidth)
    157             case .crosshatch:
    158                 ZStack {
    159                     lines(.down).stroke(ink, lineWidth: lineWidth)
    160                     lines(.up).stroke(ink, lineWidth: lineWidth)
    161                 }
    162             case .horizontal:
    163                 lines(.horizontal).stroke(ink, lineWidth: lineWidth)
    164             case .vertical:
    165                 lines(.vertical).stroke(ink, lineWidth: lineWidth)
    166             }
    167         }
    168         .frame(maxWidth: .infinity, maxHeight: .infinity)
    169         .clipped()
    170         .allowsHitTesting(false)
    171     }
    172 }
    173 
    174 /// Parallel lines across the cell at one of four orientations. The line
    175 /// family is anchored to a grid-wide lattice derived from the cell's
    176 /// position rather than its own origin, so adjacent cells draw the same
    177 /// family and the hatching meets exactly at every border. Runs are
    178 /// generated past the edges and clipped by the caller.
    179 private struct CrossRefLines: Shape {
    180     enum Slope { case down, up, horizontal, vertical }
    181     var slope: Slope
    182     /// This cell's grid position and the grid's inter-cell spacing. The
    183     /// lattice phase is computed from these so every cell, at whatever
    184     /// size the layout currently gives it, lands on the same global
    185     /// family of lines.
    186     var row: Int
    187     var col: Int
    188     var spacing: CGFloat
    189     var spacingFraction: CGFloat = 0.24
    190 
    191     func path(in rect: CGRect) -> Path {
    192         var path = Path()
    193         let side = min(rect.width, rect.height)
    194         let step = max(2, side * spacingFraction)
    195         // Cells are square and uniformly pitched, so one pitch value
    196         // covers both axes. Computed from the live rect, so it tracks
    197         // any change in cell size automatically.
    198         let pitch = side + spacing
    199         let gx = CGFloat(col) * pitch
    200         let gy = CGFloat(row) * pitch
    201 
    202         switch slope {
    203         case .down:
    204             // Lines of constant (globalX − globalY); snap the generator
    205             // so that quantity is a multiple of `step` grid-wide.
    206             let lower = rect.minX - rect.height
    207             var x = firstAligned(at: lower, congruentTo: rect.minX - (gx - gy), step: step)
    208             while x <= rect.maxX {
    209                 path.move(to: CGPoint(x: x, y: rect.minY))
    210                 path.addLine(to: CGPoint(x: x + rect.height, y: rect.maxY))
    211                 x += step
    212             }
    213         case .up:
    214             // Lines of constant (globalX + globalY).
    215             let lower = rect.minX - rect.height
    216             var x = firstAligned(at: lower, congruentTo: lower - (gx + gy), step: step)
    217             while x <= rect.maxX {
    218                 path.move(to: CGPoint(x: x, y: rect.maxY))
    219                 path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
    220                 x += step
    221             }
    222         case .horizontal:
    223             var y = firstAligned(at: rect.minY, congruentTo: rect.minY - gy, step: step)
    224             while y < rect.maxY {
    225                 path.move(to: CGPoint(x: rect.minX, y: y))
    226                 path.addLine(to: CGPoint(x: rect.maxX, y: y))
    227                 y += step
    228             }
    229         case .vertical:
    230             var x = firstAligned(at: rect.minX, congruentTo: rect.minX - gx, step: step)
    231             while x < rect.maxX {
    232                 path.move(to: CGPoint(x: x, y: rect.minY))
    233                 path.addLine(to: CGPoint(x: x, y: rect.maxY))
    234                 x += step
    235             }
    236         }
    237         return path
    238     }
    239 
    240     /// Smallest value ≥ `lower` that is congruent to `target` modulo
    241     /// `step`. Starting each run here puts every cell's lines on one
    242     /// shared global lattice, so they line up across cell borders.
    243     private func firstAligned(
    244         at lower: CGFloat,
    245         congruentTo target: CGFloat,
    246         step: CGFloat
    247     ) -> CGFloat {
    248         let delta = (target - lower).truncatingRemainder(dividingBy: step)
    249         let offset = delta >= 0 ? delta : delta + step
    250         return lower + offset
    251     }
    252 }
    253 
    254 /// Right-angled triangle pinned to the top-right corner of the cell, sized
    255 /// as a fraction of the shorter cell dimension. Used as a small marker for
    256 /// revealed and checkedWrong cells.
    257 private struct CornerTriangle: Shape {
    258     enum Corner { case topLeading, topTrailing }
    259     var corner: Corner = .topTrailing
    260     var fraction: CGFloat = 0.3
    261 
    262     func path(in rect: CGRect) -> Path {
    263         let side = min(rect.width, rect.height) * fraction
    264         var path = Path()
    265         switch corner {
    266         case .topTrailing:
    267             path.move(to: CGPoint(x: rect.maxX - side, y: rect.minY))
    268             path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
    269             path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + side))
    270         case .topLeading:
    271             path.move(to: CGPoint(x: rect.minX, y: rect.minY))
    272             path.addLine(to: CGPoint(x: rect.minX + side, y: rect.minY))
    273             path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + side))
    274         }
    275         path.closeSubpath()
    276         return path
    277     }
    278 }