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 }