crossmate

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

commit ccfb9287beb5bb0c1b6bd3b0757efac786389305
parent c0cbf61d65e8ca7fe7dae9b581fbd631dfafe02b
Author: Michael Camilleri <[email protected]>
Date:   Sun, 17 May 2026 21:53:44 +0900

Share the clue cell-walk behind a built-once start index

The cross-reference feature in c0cbf61 left two copies of the same run-walk —
'from a clue's numbered start cell, advance in its direction until a block or
the grid edge'. One lived in the focus-gated relatedCells, the other in
buildCrossReferenceGroupByCell; keeping a passive marker and the active
highlight in agreement depended on those two loops staying byte-identical.
relatedCells also resolved each linked clue's origin through cell(numbered:), a
full-grid scan, on every render of the focused word — and cell(numbered:)
itself scanned the grid for its four PlayerSession navigation callers.

The run-walk is now a single Self.runCells(from:direction:cells:) that both
relatedCells and the cross-reference index call, so the two can no longer drift
apart. Numbered start cells are indexed once at init into a new numberStarts
([Int: GridPosition]) stored property; relatedCells, the cross-reference
builder, and cell(numbered:) all resolve a clue's origin by O(1) lookup instead
of re-scanning the grid. cell(numbered:) keeps its signature and semantics —
there is exactly one numbered cell per number, so the dictionary is a faithful
replacement — so its navigation callers are unaffected. The only added cost is
one extra O(width·height) pass over the grid at load to populate numberStarts,
paid once; the per-render scan in the active highlight path is gone and no
asymptotic cost was added anywhere.

The map's awkward crossReferenceGroupByCell / buildCrossReferenceGroupByCell
pair is renamed to cellGroups / buildCellGroups, dropping the ByCell suffix no
other dictionary here carries (numberStarts is likewise just keyed by number)
and following the build<Property> convention the file already uses. GridView's
local, previously the inconsistent crossRefGroupByCell, is unified to
cellGroups too.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Diffstat:
MCrossmate/Models/Puzzle.swift | 112+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
MCrossmate/Views/GridView.swift | 4++--
2 files changed, 74 insertions(+), 42 deletions(-)

diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -37,6 +37,12 @@ struct Puzzle: Sendable { /// the solver works them out. let crossReferenceGroups: [Set<ClueRef>] + /// Maps each clue number to the position of its numbered start cell. + /// Built once so callers (`cell(numbered:)`, the per-render + /// `relatedCells`, the cross-reference walk) resolve a clue's origin + /// by O(1) lookup instead of re-scanning the grid every time. + let numberStarts: [Int: GridPosition] + /// 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 @@ -44,7 +50,7 @@ struct Puzzle: Sendable { /// 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] + let cellGroups: [GridPosition: Int] struct ClueRef: Hashable, Sendable { let number: Int @@ -136,46 +142,78 @@ struct Puzzle: Sendable { down: downClues ) self.crossReferenceGroups = groups - self.crossReferenceGroupByCell = Self.buildCrossReferenceGroupByCell( + let numberStarts = Self.buildNumberStarts(cells) + self.numberStarts = numberStarts + self.cellGroups = Self.buildCellGroups( groups: groups, + starts: numberStarts, cells: cells ) } + /// Indexes every numbered start cell by its clue number. There is + /// exactly one numbered cell per number, so a plain dictionary is a + /// faithful, scan-free replacement for `cell(numbered:)`. + private static func buildNumberStarts( + _ cells: [[Cell]] + ) -> [Int: GridPosition] { + var starts: [Int: GridPosition] = [:] + for row in cells { + for cell in row { + if let number = cell.number { + starts[number] = GridPosition(row: cell.row, col: cell.col) + } + } + } + return starts + } + + /// Walks the run of cells for a clue, starting at `start` and + /// advancing in `direction` until a block or the grid edge. The + /// single source of truth for "which cells does this clue occupy", + /// shared by `relatedCells` and the cross-reference index so the two + /// can't drift apart. + private static func runCells( + from start: GridPosition, + direction: Direction, + cells: [[Cell]] + ) -> [GridPosition] { + let height = cells.count + let width = cells.first?.count ?? 0 + var positions: [GridPosition] = [] + var r = start.row + var c = start.col + while r >= 0, r < height, c >= 0, c < width, !cells[r][c].isBlock { + positions.append(GridPosition(row: r, col: c)) + switch direction { + case .across: c += 1 + case .down: r += 1 + } + } + return positions + } + /// 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 + /// start cell, tagging each visited cell with its group's index. 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( + private static func buildCellGroups( groups: [Set<ClueRef>], + starts: [Int: GridPosition], 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 - } + for pos in runCells( + from: start, + direction: clue.direction, + cells: cells + ) where result[pos] == nil { + result[pos] = index } } } @@ -283,13 +321,11 @@ struct Puzzle: Sendable { } /// Returns the cell labelled with the given clue number, if any. + /// Resolved via the prebuilt `numberStarts` index, so this is an O(1) + /// lookup rather than a grid scan. func cell(numbered number: Int) -> Cell? { - for row in cells { - for cell in row where cell.number == number { - return cell - } - } - return nil + guard let pos = numberStarts[number] else { return nil } + return cells[pos.row][pos.col] } /// Returns every open cell that belongs to the word containing @@ -342,16 +378,12 @@ struct Puzzle: Sendable { var related: Set<GridPosition> = [] for group in crossReferenceGroups where group.contains(focusClue) { for clue in group where clue != focusClue { - guard let startCell = cell(numbered: clue.number) else { continue } - var r = startCell.row - var c = startCell.col - while r >= 0, r < height, c >= 0, c < width, !cells[r][c].isBlock { - related.insert(GridPosition(row: r, col: c)) - switch clue.direction { - case .across: c += 1 - case .down: r += 1 - } - } + guard let start = numberStarts[clue.number] else { continue } + related.formUnion(Self.runCells( + from: start, + direction: clue.direction, + cells: cells + )) } } return related diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -22,7 +22,7 @@ struct GridView: View { direction: session.direction ) let patternPalette = CrossRefPattern.allCases - let crossRefGroupByCell = session.puzzle.crossReferenceGroupByCell + let cellGroups = session.puzzle.cellGroups let currentWordCells = Set(session.puzzle.wordCells( atRow: session.selectedRow, col: session.selectedCol, @@ -40,7 +40,7 @@ struct GridView: View { mark: square.mark, isSelected: session.selectedRow == r && session.selectedCol == c, isHighlighted: currentWordCells.contains(pos), - crossRefPattern: crossRefGroupByCell[pos].map { + crossRefPattern: cellGroups[pos].map { patternPalette[$0 % patternPalette.count] }, isRelatedToFocus: relatedCells.contains(pos),