commit c0cbf61d65e8ca7fe7dae9b581fbd631dfafe02b
parent 3c0109fc51617fb3e7e469531d5c044c5b38d32a
Author: Michael Camilleri <[email protected]>
Date: Sun, 17 May 2026 21:32:35 +0900
Mark cross-referenced squares with a passive pattern
Crossword puzzles often include cross-references between clues. Prior to this
commit, a clue's cross-reference relationships were only visible through the
active isRelatedToFocus border, which lights up just the linked entries and
only while a clue in the group is focused. A solver scanning the grid had no
way to see that a square participates in a 'See 11-Down' / 'With 31- and
43-Down' link without first selecting into it, and a puzzle's separate
cross-reference sets were indistinguishable from one another even once
highlighted.
Puzzle now derives crossReferenceGroupByCell once at init: it walks every clue
in every crossReferenceGroups set from its numbered start cell to the next
block/edge — mirroring the run-walk in relatedCells but without the focus gate
— and tags each visited cell with its group's index. The index, not a bare
membership flag, is the point: it lets each independent cross-reference set
carry its own texture so distinct links read differently at a glance. A cell
shared by clues in two groups keeps the first group encountered, so the mapping
is deterministic regardless of set iteration order.
GridView resolves each cell's group index through a fixed CaseIterable palette
(palette[index % count], wrapping if a puzzle has more groups than patterns)
and passes the resolved CrossRefPattern into CellView, which also feeds the
.equatable() fast path so cells refresh correctly. The texture is drawn in the
cell ZStack above the background tints — so it survives selection, author, and
peer-word colour — but below the number, letter, and corner markers, so it
never competes with legibility. It is monochrome by design: a neutral ink that
layers over any colour-coded state rather than fighting the app's colour
vocabulary, clipped to the cell and non-interactive.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
Diffstat:
3 files changed, 166 insertions(+), 1 deletion(-)
diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift
@@ -37,6 +37,15 @@ struct Puzzle: Sendable {
/// the solver works them out.
let crossReferenceGroups: [Set<ClueRef>]
+ /// Maps each cell that belongs to a cross-referenced clue to the
+ /// index of its group within `crossReferenceGroups`. Unlike
+ /// `relatedCells`, this is focus-independent: callers mark these
+ /// squares passively (always visible), and the group index lets each
+ /// distinct cross-reference set carry its own visual pattern. A cell
+ /// shared by clues in two groups keeps the first group encountered.
+ /// Built once at init.
+ let crossReferenceGroupByCell: [GridPosition: Int]
+
struct ClueRef: Hashable, Sendable {
let number: Int
let direction: Direction
@@ -122,10 +131,55 @@ struct Puzzle: Sendable {
let downClues = xd.downClues.map { Clue(number: $0.number, text: $0.text) }
self.acrossClues = acrossClues
self.downClues = downClues
- self.crossReferenceGroups = Self.buildCrossReferenceGroups(
+ let groups = Self.buildCrossReferenceGroups(
across: acrossClues,
down: downClues
)
+ self.crossReferenceGroups = groups
+ self.crossReferenceGroupByCell = Self.buildCrossReferenceGroupByCell(
+ groups: groups,
+ cells: cells
+ )
+ }
+
+ /// Walks every clue in every cross-reference group from its numbered
+ /// start cell to the next block/edge, tagging each visited cell with
+ /// its group's index. Mirrors the run-walk in `relatedCells` but has
+ /// no focus gate, so the result is stable for the whole puzzle. Group
+ /// order follows `crossReferenceGroups`; the first group to claim a
+ /// shared cell wins, keeping the mapping deterministic.
+ private static func buildCrossReferenceGroupByCell(
+ groups: [Set<ClueRef>],
+ cells: [[Cell]]
+ ) -> [GridPosition: Int] {
+ guard !groups.isEmpty else { return [:] }
+ let height = cells.count
+ let width = cells.first?.count ?? 0
+ var starts: [Int: (row: Int, col: Int)] = [:]
+ for row in cells {
+ for cell in row {
+ if let number = cell.number {
+ starts[number] = (cell.row, cell.col)
+ }
+ }
+ }
+ var result: [GridPosition: Int] = [:]
+ for (index, group) in groups.enumerated() {
+ for clue in group {
+ guard let start = starts[clue.number] else { continue }
+ var r = start.row
+ var c = start.col
+ while r >= 0, r < height, c >= 0, c < width, !cells[r][c].isBlock {
+ let pos = GridPosition(row: r, col: c)
+ if result[pos] == nil { result[pos] = index }
+ switch clue.direction {
+ case .across: c += 1
+ case .down: r += 1
+ }
+ }
+ }
+ }
+ return result
}
/// Derives cross-reference groups from the clue text itself. NYT-style
diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift
@@ -6,6 +6,10 @@ struct CellView: View, Equatable {
let mark: CellMark
let isSelected: Bool
let isHighlighted: Bool
+ /// Passive cross-reference texture for this cell, or `nil` if the cell
+ /// belongs to no cross-referenced clue. Each group in the puzzle is
+ /// assigned a distinct pattern so separate links read differently.
+ var crossRefPattern: CrossRefPattern? = nil
var isRelatedToFocus: Bool = false
var remoteWordTint: Color? = nil
var authorTint: Color? = nil
@@ -19,6 +23,7 @@ struct CellView: View, Equatable {
&& lhs.mark == rhs.mark
&& lhs.isSelected == rhs.isSelected
&& lhs.isHighlighted == rhs.isHighlighted
+ && lhs.crossRefPattern == rhs.crossRefPattern
&& lhs.isRelatedToFocus == rhs.isRelatedToFocus
&& lhs.remoteWordTint == rhs.remoteWordTint
&& lhs.authorTint == rhs.authorTint
@@ -28,6 +33,13 @@ struct CellView: View, Equatable {
ZStack(alignment: .topLeading) {
background
if !cell.isBlock {
+ // Passive cross-reference texture. Sits above the
+ // background tints (so it survives selection/author
+ // colour) but below the number, letter and corner
+ // markers so it never fights legibility.
+ if let crossRefPattern {
+ CrossRefPatternView(pattern: crossRefPattern)
+ }
if cell.special == .circled {
Circle()
.stroke(Color.black.opacity(0.55), lineWidth: 1)
@@ -114,6 +126,100 @@ struct CellView: View, Equatable {
}
}
+/// The ordered palette of passive cross-reference textures. A puzzle can
+/// have several independent cross-reference groups; each is assigned the
+/// next pattern in this sequence (wrapping if there are more groups than
+/// patterns) so distinct links read differently at a glance. Monochrome
+/// by design — the texture layers over any cursor/author colour without
+/// competing with the app's colour-coded state.
+enum CrossRefPattern: CaseIterable, Sendable {
+ case diagonalDown
+ case diagonalUp
+ case crosshatch
+ case horizontal
+ case vertical
+}
+
+/// Renders one `CrossRefPattern` filling the cell. Diagonal/line patterns
+/// are clipped to the cell bounds; the whole overlay is non-interactive.
+private struct CrossRefPatternView: View {
+ let pattern: CrossRefPattern
+
+ /// Neutral ink that reads on white and over the (lightly tinted)
+ /// selection/author fills alike. Tunable while we evaluate the look.
+ private let ink = Color.black.opacity(0.25)
+ private let lineWidth: CGFloat = 1
+
+ var body: some View {
+ Group {
+ switch pattern {
+ case .diagonalDown:
+ CrossRefLines(slope: .down).stroke(ink, lineWidth: lineWidth)
+ case .diagonalUp:
+ CrossRefLines(slope: .up).stroke(ink, lineWidth: lineWidth)
+ case .crosshatch:
+ ZStack {
+ CrossRefLines(slope: .down).stroke(ink, lineWidth: lineWidth)
+ CrossRefLines(slope: .up).stroke(ink, lineWidth: lineWidth)
+ }
+ case .horizontal:
+ CrossRefLines(slope: .horizontal).stroke(ink, lineWidth: lineWidth)
+ case .vertical:
+ CrossRefLines(slope: .vertical).stroke(ink, lineWidth: lineWidth)
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .clipped()
+ .allowsHitTesting(false)
+ }
+}
+
+/// Parallel lines across the cell at one of four orientations. Diagonal
+/// runs are generated past the edges and clipped by the caller so the
+/// hatching stays continuous to the cell border.
+private struct CrossRefLines: Shape {
+ enum Slope { case down, up, horizontal, vertical }
+ var slope: Slope
+ var spacingFraction: CGFloat = 0.24
+
+ func path(in rect: CGRect) -> Path {
+ var path = Path()
+ let side = min(rect.width, rect.height)
+ let step = max(2, side * spacingFraction)
+ switch slope {
+ case .down:
+ var x = rect.minX - rect.height
+ while x <= rect.maxX {
+ path.move(to: CGPoint(x: x, y: rect.minY))
+ path.addLine(to: CGPoint(x: x + rect.height, y: rect.maxY))
+ x += step
+ }
+ case .up:
+ var x = rect.minX - rect.height
+ while x <= rect.maxX {
+ path.move(to: CGPoint(x: x, y: rect.maxY))
+ path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
+ x += step
+ }
+ case .horizontal:
+ var y = rect.minY + step / 2
+ while y < rect.maxY {
+ path.move(to: CGPoint(x: rect.minX, y: y))
+ path.addLine(to: CGPoint(x: rect.maxX, y: y))
+ y += step
+ }
+ case .vertical:
+ var x = rect.minX + step / 2
+ while x < rect.maxX {
+ path.move(to: CGPoint(x: x, y: rect.minY))
+ path.addLine(to: CGPoint(x: x, y: rect.maxY))
+ x += step
+ }
+ }
+ return path
+ }
+}
+
/// Small dot pinned to the top-leading corner of the cell, sized as a
/// fraction of the shorter cell dimension. Marks cells that start a word.
private struct CornerDot: Shape {
diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift
@@ -21,6 +21,8 @@ struct GridView: View {
col: session.selectedCol,
direction: session.direction
)
+ let patternPalette = CrossRefPattern.allCases
+ let crossRefGroupByCell = session.puzzle.crossReferenceGroupByCell
let currentWordCells = Set(session.puzzle.wordCells(
atRow: session.selectedRow,
col: session.selectedCol,
@@ -38,6 +40,9 @@ struct GridView: View {
mark: square.mark,
isSelected: session.selectedRow == r && session.selectedCol == c,
isHighlighted: currentWordCells.contains(pos),
+ crossRefPattern: crossRefGroupByCell[pos].map {
+ patternPalette[$0 % patternPalette.count]
+ },
isRelatedToFocus: relatedCells.contains(pos),
remoteWordTint: tintByCell[pos],
authorTint: square.entry.isEmpty