CellView.swift (6376B)
1 import SwiftUI 2 3 struct CellView: View, Equatable { 4 let cell: Puzzle.Cell 5 let entry: String 6 let mark: CellMark 7 let isSelected: Bool 8 let isHighlighted: Bool 9 var isRelatedToFocus: Bool = false 10 let specialKind: Puzzle.Special? 11 var remoteWordTint: Color? = nil 12 var remoteOutline: Color? = nil 13 var authorTint: Color? = nil 14 15 @Environment(PlayerPreferences.self) private var preferences 16 private var playerColor: PlayerColor { preferences.color } 17 18 nonisolated static func == (lhs: CellView, rhs: CellView) -> Bool { 19 lhs.cell == rhs.cell 20 && lhs.entry == rhs.entry 21 && lhs.mark == rhs.mark 22 && lhs.isSelected == rhs.isSelected 23 && lhs.isHighlighted == rhs.isHighlighted 24 && lhs.isRelatedToFocus == rhs.isRelatedToFocus 25 && lhs.specialKind == rhs.specialKind 26 && lhs.remoteWordTint == rhs.remoteWordTint 27 && lhs.remoteOutline == rhs.remoteOutline 28 && lhs.authorTint == rhs.authorTint 29 } 30 31 var body: some View { 32 ZStack(alignment: .topLeading) { 33 background 34 if !cell.isBlock { 35 if cell.isSpecial && specialKind == .circled { 36 Circle() 37 .stroke(Color.black.opacity(0.55), lineWidth: 1) 38 .padding(1.5) 39 } 40 if cell.number != nil { 41 CornerDot() 42 .fill(Color(.systemGray3)) 43 } 44 Text(entry) 45 .font(.system(size: 34, weight: .semibold, design: .rounded)) 46 .foregroundStyle(entryStyle) 47 .lineLimit(1) 48 .minimumScaleFactor(0.2) 49 .allowsTightening(true) 50 .padding(.horizontal, 2) 51 .frame(maxWidth: .infinity, maxHeight: .infinity) 52 if let triangleColor = cornerTriangleColor { 53 CornerTriangle() 54 .fill(triangleColor) 55 } 56 if isRelatedToFocus { 57 Rectangle() 58 .strokeBorder(playerColor.highlightFill, lineWidth: 3) 59 } 60 if let remoteOutline { 61 Rectangle() 62 .strokeBorder(remoteOutline, lineWidth: 2) 63 } 64 } 65 } 66 .frame(maxWidth: .infinity, maxHeight: .infinity) 67 .contentShape(Rectangle()) 68 } 69 70 /// Foreground style for the main entry letter. Pencil entries use the 71 /// hierarchical `.secondary` style so they render lighter and respect 72 /// dark mode; everything else — including revealed and checkedWrong 73 /// cells — uses the primary label colour. Reveal/wrong state is shown 74 /// purely via the corner triangle, not by recolouring the letter. 75 private var entryStyle: AnyShapeStyle { 76 switch mark { 77 case .pencil: 78 return AnyShapeStyle(HierarchicalShapeStyle.secondary) 79 case .none, .pen, .revealed: 80 return AnyShapeStyle(HierarchicalShapeStyle.primary) 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 @ViewBuilder 95 private var background: some View { 96 if cell.isBlock { 97 Color.black 98 } else { 99 ZStack { 100 Color.white 101 if cell.isSpecial && specialKind == .shaded { 102 Color.black.opacity(0.22) 103 } 104 // Faint background tint identifying who entered this letter 105 // in a shared game. Sits beneath transient highlights so peer 106 // and local cursors still dominate. 107 if let authorTint { 108 authorTint.opacity(0.10) 109 } 110 // Peer word tint sits beneath self highlight/selection so the 111 // local cursor always reads as the dominant focus. 112 if let remoteWordTint { 113 remoteWordTint 114 } 115 if isSelected { 116 playerColor.selectionFill 117 } else if isHighlighted { 118 playerColor.highlightFill 119 } 120 } 121 } 122 } 123 } 124 125 /// Small dot pinned to the top-leading corner of the cell, sized as a 126 /// fraction of the shorter cell dimension. Marks cells that start a word. 127 private struct CornerDot: Shape { 128 var diameterFraction: CGFloat = 0.15 129 var insetFraction: CGFloat = 0.05 130 131 func path(in rect: CGRect) -> Path { 132 let side = min(rect.width, rect.height) 133 let diameter = side * diameterFraction 134 let inset = side * insetFraction 135 var path = Path() 136 path.addEllipse(in: CGRect( 137 x: rect.minX + inset, 138 y: rect.minY + inset, 139 width: diameter, 140 height: diameter 141 )) 142 return path 143 } 144 } 145 146 /// Right-angled triangle pinned to the top-right corner of the cell, sized 147 /// as a fraction of the shorter cell dimension. Used as a small marker for 148 /// revealed and checkedWrong cells. 149 private struct CornerTriangle: Shape { 150 enum Corner { case topLeading, topTrailing } 151 var corner: Corner = .topTrailing 152 var fraction: CGFloat = 0.3 153 154 func path(in rect: CGRect) -> Path { 155 let side = min(rect.width, rect.height) * fraction 156 var path = Path() 157 switch corner { 158 case .topTrailing: 159 path.move(to: CGPoint(x: rect.maxX - side, y: rect.minY)) 160 path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) 161 path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + side)) 162 case .topLeading: 163 path.move(to: CGPoint(x: rect.minX, y: rect.minY)) 164 path.addLine(to: CGPoint(x: rect.minX + side, y: rect.minY)) 165 path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + side)) 166 } 167 path.closeSubpath() 168 return path 169 } 170 }