crossmate

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

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 }