crossmate

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

CellPatterns.swift (4105B)


      1 import SwiftUI
      2 
      3 /// The ordered palette of passive cross-reference textures. A puzzle can
      4 /// have several independent cross-reference groups; each is assigned the
      5 /// next pattern in this sequence (wrapping if there are more groups than
      6 /// patterns) so distinct links read differently at a glance. Monochrome
      7 /// by design — the texture layers over any cursor/author colour without
      8 /// competing with the app's colour-coded state.
      9 ///
     10 /// Cell content is painted by `PuzzleCellsLayer` (a single `Canvas`); this
     11 /// type and `CrossRefLines` are the drawing primitives it reuses.
     12 enum CrossRefPattern: CaseIterable, Sendable {
     13     case diagonalDown
     14     case diagonalUp
     15     case crosshatch
     16     case horizontal
     17     case vertical
     18 }
     19 
     20 /// Parallel lines across a cell at one of four orientations. The line
     21 /// family is anchored to a grid-wide lattice derived from the cell's
     22 /// position rather than its own origin, so adjacent cells draw the same
     23 /// family and the hatching meets exactly at every border. Runs are
     24 /// generated past the edges and clipped by the caller.
     25 struct CrossRefLines: Shape {
     26     enum Slope { case down, up, horizontal, vertical }
     27     var slope: Slope
     28     /// This cell's grid position and the grid's inter-cell spacing. The
     29     /// lattice phase is computed from these so every cell, at whatever
     30     /// size the layout currently gives it, lands on the same global
     31     /// family of lines.
     32     var row: Int
     33     var col: Int
     34     var spacing: CGFloat
     35     var spacingFraction: CGFloat = 0.24
     36 
     37     func path(in rect: CGRect) -> Path {
     38         var path = Path()
     39         let side = min(rect.width, rect.height)
     40         let step = max(2, side * spacingFraction)
     41         // Cells are square and uniformly pitched, so one pitch value
     42         // covers both axes. Computed from the live rect, so it tracks
     43         // any change in cell size automatically.
     44         let pitch = side + spacing
     45         let gx = CGFloat(col) * pitch
     46         let gy = CGFloat(row) * pitch
     47 
     48         switch slope {
     49         case .down:
     50             // Lines of constant (globalX − globalY); snap the generator
     51             // so that quantity is a multiple of `step` grid-wide.
     52             let lower = rect.minX - rect.height
     53             var x = firstAligned(at: lower, congruentTo: rect.minX - (gx - gy), step: step)
     54             while x <= rect.maxX {
     55                 path.move(to: CGPoint(x: x, y: rect.minY))
     56                 path.addLine(to: CGPoint(x: x + rect.height, y: rect.maxY))
     57                 x += step
     58             }
     59         case .up:
     60             // Lines of constant (globalX + globalY).
     61             let lower = rect.minX - rect.height
     62             var x = firstAligned(at: lower, congruentTo: lower - (gx + gy), step: step)
     63             while x <= rect.maxX {
     64                 path.move(to: CGPoint(x: x, y: rect.maxY))
     65                 path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
     66                 x += step
     67             }
     68         case .horizontal:
     69             var y = firstAligned(at: rect.minY, congruentTo: rect.minY - gy, step: step)
     70             while y < rect.maxY {
     71                 path.move(to: CGPoint(x: rect.minX, y: y))
     72                 path.addLine(to: CGPoint(x: rect.maxX, y: y))
     73                 y += step
     74             }
     75         case .vertical:
     76             var x = firstAligned(at: rect.minX, congruentTo: rect.minX - gx, step: step)
     77             while x < rect.maxX {
     78                 path.move(to: CGPoint(x: x, y: rect.minY))
     79                 path.addLine(to: CGPoint(x: x, y: rect.maxY))
     80                 x += step
     81             }
     82         }
     83         return path
     84     }
     85 
     86     /// Smallest value ≥ `lower` that is congruent to `target` modulo
     87     /// `step`. Starting each run here puts every cell's lines on one
     88     /// shared global lattice, so they line up across cell borders.
     89     private func firstAligned(
     90         at lower: CGFloat,
     91         congruentTo target: CGFloat,
     92         step: CGFloat
     93     ) -> CGFloat {
     94         let delta = (target - lower).truncatingRemainder(dividingBy: step)
     95         let offset = delta >= 0 ? delta : delta + step
     96         return lower + offset
     97     }
     98 }