crossmate

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

commit 5b8c9d9ec1fbf4a5299c023e769d85503d345128
parent e35b9b643c28010cdfaee42e38ebd36a861888d4
Author: Michael Camilleri <[email protected]>
Date:   Sun, 28 Jun 2026 22:20:16 +0900

Speed up puzzle openings

Opening a puzzle — and especially returning to one just opened — left
the loading spinner on screen longer than it should, a brief delay most
apparent on larger grids. The cost was almost entirely synchronous work
on the main actor before the grid could appear, in three independent
places.

This commit removes that work. XD.parse derived each cell's clue number
by rescanning the whole grid, and the answer-application passes repeated
that for every cell in both directions, making a parse quadratic in the
number of cells; the numbering is now computed once into a Numbering
lookup and read in constant time. The grid also built one view per cell
so first render alone cost over a tenth of a second; cell content is now
painted in a single Canvas instead, with letters, author tints, shaded
and circled specials, cross-reference hatching, and reveal and wrong
markers collapsed into one draw pass. And since every cell is the same
size, per-cell tap recognisers give way to one recogniser that maps a
tap to its cell arithmetically through PuzzleGridGeometry.

The Canvas reproduces the former rendering exactly, including per-square
letter sizing — single letters fill the square and multi-character rebus
entries shrink to fit — with resolved glyphs cached so a redraw touches
only the few distinct letters. CellView is no longer needed; its drawing
primitives CrossRefPattern and CrossRefLines move to CellPatterns.swift.

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

Diffstat:
MCrossmate.xcodeproj/project.pbxproj | 8++++----
MCrossmate/Models/Puzzle.swift | 6++++++
MCrossmate/Models/XD.swift | 144+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
ACrossmate/Views/Puzzle/CellPatterns.swift | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
DCrossmate/Views/Puzzle/CellView.swift | 278-------------------------------------------------------------------------------
MCrossmate/Views/Puzzle/GridView.swift | 340++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
6 files changed, 443 insertions(+), 431 deletions(-)

diff --git a/Crossmate.xcodeproj/project.pbxproj b/Crossmate.xcodeproj/project.pbxproj @@ -140,7 +140,6 @@ 9FFD01CF6767220EEA20C0E4 /* GamePushCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78919F44C3035C48410FC894 /* GamePushCredentials.swift */; }; A0977C7B0B0D928DA569C326 /* NicknameDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3111803C8FFFB0C839217482 /* NicknameDirectory.swift */; }; A133A4B4A0C95AF8708BD7E6 /* PushClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A9F9E7ED4E1AF02F0C71051 /* PushClient.swift */; }; - A22113A51213068FBF708A56 /* CellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D25D12FF374F83BF4DB83DD /* CellView.swift */; }; A458AF9CA8579AB51B695B08 /* PendingChangeReapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEDA3C3765CD8D8897FE5D5 /* PendingChangeReapTests.swift */; }; A65F99414F8CF6704567BB07 /* Archive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C18E9B47668E008BE4CF86 /* Archive.swift */; }; A78FF09708EDED7ED50BB55B /* PushPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E87E28DC9402A4369647DE50 /* PushPayloadTests.swift */; }; @@ -202,6 +201,7 @@ E6A13F8736ABF41F6346E301 /* ParticipantSummaries.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED7C2528355BC007E48B7EF /* ParticipantSummaries.swift */; }; E81F92AAB2968997C3D68809 /* PuzzleNotificationText.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7CEB8980A9664BAAA5D196 /* PuzzleNotificationText.swift */; }; E91FB8101E1927CA567DE825 /* PuzzleSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7AFD37B03A1C2E23E5766E6 /* PuzzleSource.swift */; }; + E9A65F3548DD062FD36A19E1 /* CellPatterns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F13534332699C80382FE682 /* CellPatterns.swift */; }; EA0AA522F6C383034C4572F4 /* AccountPushCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03D59EA74A4BA084AD97478D /* AccountPushCoordinator.swift */; }; EB6E99226D5EE27668787008 /* BadgeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5B1E8E12B86DF6CA478F65 /* BadgeCoordinator.swift */; }; ECC1A5C3623F50B67185CFFB /* RecordSerializerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4DEAF9F7887CBB46A99E8E /* RecordSerializerTests.swift */; }; @@ -381,7 +381,7 @@ 9A49C3C31F49A85764B84A15 /* SyncState+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SyncState+Helpers.swift"; sourceTree = "<group>"; }; 9A56778AF8190F0D7EB2E27E /* GameStorePushAddressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameStorePushAddressTests.swift; sourceTree = "<group>"; }; 9AF6157D97271205626E207C /* MovesUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovesUpdaterTests.swift; sourceTree = "<group>"; }; - 9D25D12FF374F83BF4DB83DD /* CellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellView.swift; sourceTree = "<group>"; }; + 9F13534332699C80382FE682 /* CellPatterns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CellPatterns.swift; sourceTree = "<group>"; }; 9F8D856707B4D76FDBF4AE69 /* FriendEntity+DisplayName.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FriendEntity+DisplayName.swift"; sourceTree = "<group>"; }; A253416F4FEA271A80B22A73 /* NYTAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NYTAuthService.swift; sourceTree = "<group>"; }; A3A251D89028B3CA065DE053 /* PuzzleScoreboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PuzzleScoreboard.swift; sourceTree = "<group>"; }; @@ -696,7 +696,7 @@ 895088B5D0214046158C6D24 /* Puzzle */ = { isa = PBXGroup; children = ( - 9D25D12FF374F83BF4DB83DD /* CellView.swift */, + 9F13534332699C80382FE682 /* CellPatterns.swift */, E935CE4384F3B67CC22EEBAC /* ClueBar.swift */, 7B8B65482CA1739A3863A99E /* ClueList.swift */, ED48AD9C3A7A113D101BBD21 /* GridView.swift */, @@ -1057,7 +1057,7 @@ 4D9E2C35893E68E47F790994 /* BundledBrowseView.swift in Sources */, F627D68B521FEA85EB80A850 /* CalendarDayCell.swift in Sources */, 6BE7E91158F4DF1F71247C6D /* CellMark.swift in Sources */, - A22113A51213068FBF708A56 /* CellView.swift in Sources */, + E9A65F3548DD062FD36A19E1 /* CellPatterns.swift in Sources */, 5FB26F40F5DB52111E3D1BDC /* CheckResult.swift in Sources */, E15A40AA623B60279E8DDF43 /* CloudDiagnostics.swift in Sources */, 1016604FBD4D63A0B9AAE503 /* CloudQuery.swift in Sources */, diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -303,6 +303,12 @@ struct Puzzle: Sendable { /// A trailing `Across`/`Down` applies to every number in that list /// segment, matching NYT's convention. private static func parseCrossReferences(in text: String) -> [ClueRef]? { + // Both patterns below require the literal direction word, so a clue + // that mentions neither can never yield a cross-reference. The vast + // majority of clues fall here — this cheap substring check skips the + // expensive regex scan (run once per clue) for all of them. + guard text.contains("Across") || text.contains("Down") else { return nil } + var refs: [ClueRef] = [] var seen: Set<ClueRef> = [] diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -156,8 +156,14 @@ struct XD: Sendable { specialsHeader: metadata.first("Specials") ) let (across, down) = try parseClues(sections[2]) - let solvedCells = try applyClueAnswers(cells: rawCells, across: across, down: down) - let cells = applyAcceptedAnswers(cells: solvedCells, across: across, down: down) + // Clue numbering is pure grid topology (block layout), which never + // changes once the grid is parsed — so compute it once here instead of + // re-deriving a cell's number with a full grid rescan inside the + // per-cell answer-application passes below (which made those passes + // O(cells²)). + let numbering = Numbering.build(rawCells) + let solvedCells = try applyClueAnswers(cells: rawCells, across: across, down: down, numbering: numbering) + let cells = applyAcceptedAnswers(cells: solvedCells, across: across, down: down, numbering: numbering) return XD( title: metadata.first("Title"), @@ -501,7 +507,13 @@ struct XD: Sendable { throw ParseError.malformedClue(line) } - if let match = line.firstMatch(of: /^([AD])(\d+)\s+\^([^:]+):\s*(.*)$/) { + // The metadata-clue form is `A1 ^Format: value`; the `^` marker is + // what distinguishes it from an ordinary `A1. clue` line. Gate the + // regex on its presence so the common case (no `^`) skips the + // per-line match entirely — the pattern requires `\^` anyway, so a + // line without it can never match. + if line.contains("^"), + let match = line.firstMatch(of: /^([AD])(\d+)\s+\^([^:]+):\s*(.*)$/) { guard let number = Int(match.2) else { throw ParseError.malformedClue(line) } let key = ClueKey(number: number, direction: Character(String(match.1))) let metadataKey = String(match.3).trimmingCharacters(in: .whitespaces) @@ -569,7 +581,8 @@ struct XD: Sendable { private static func applyAcceptedAnswers( cells: [[Cell]], across: [Clue], - down: [Clue] + down: [Clue], + numbering: Numbering ) -> [[Cell]] { var cells = cells let acrossByNumber = Dictionary(uniqueKeysWithValues: across.map { ($0.number, $0) }) @@ -577,7 +590,8 @@ struct XD: Sendable { let positionsOutsideExactCellAnswers = positionsOutsideExactCellAnswers( cells: cells, across: acrossByNumber, - down: downByNumber + down: downByNumber, + numbering: numbering ) for r in cells.indices { @@ -591,7 +605,8 @@ struct XD: Sendable { col: c, direction: .across, cells: cells, - cluesByNumber: acrossByNumber + cluesByNumber: acrossByNumber, + numbering: numbering ) { merged.formUnion(accepted) } @@ -600,7 +615,8 @@ struct XD: Sendable { col: c, direction: .down, cells: cells, - cluesByNumber: downByNumber + cluesByNumber: downByNumber, + numbering: numbering ) { merged.formUnion(accepted) } @@ -616,14 +632,15 @@ struct XD: Sendable { private static func applyClueAnswers( cells: [[Cell]], across: [Clue], - down: [Clue] + down: [Clue], + numbering: Numbering ) throws -> [[Cell]] { var cells = cells for clue in across { - try applyClueAnswer(clue, direction: .across, cells: &cells) + try applyClueAnswer(clue, direction: .across, cells: &cells, numbering: numbering) } for clue in down { - try applyClueAnswer(clue, direction: .down, cells: &cells) + try applyClueAnswer(clue, direction: .down, cells: &cells, numbering: numbering) } for r in cells.indices { @@ -639,10 +656,11 @@ struct XD: Sendable { private static func applyClueAnswer( _ clue: Clue, direction: Direction, - cells: inout [[Cell]] + cells: inout [[Cell]], + numbering: Numbering ) throws { guard let answer = clue.answer, - let word = wordCells(forClueNumber: clue.number, direction: direction, cells: cells) + let word = wordCells(forClueNumber: clue.number, direction: direction, cells: cells, numbering: numbering) else { return } guard wordNeedsAnswerProjection(word, cells: cells) else { return } @@ -737,7 +755,8 @@ struct XD: Sendable { private static func positionsOutsideExactCellAnswers( cells: [[Cell]], across: [Int: Clue], - down: [Int: Clue] + down: [Int: Clue], + numbering: Numbering ) -> Set<Position> { var positions: Set<Position> = [] var seenWords: Set<WordKey> = [] @@ -750,7 +769,8 @@ struct XD: Sendable { direction: .across, cells: cells, cluesByNumber: across, - seenWords: &seenWords + seenWords: &seenWords, + numbering: numbering ) ) positions.formUnion( @@ -760,7 +780,8 @@ struct XD: Sendable { direction: .down, cells: cells, cluesByNumber: down, - seenWords: &seenWords + seenWords: &seenWords, + numbering: numbering ) ) } @@ -780,14 +801,15 @@ struct XD: Sendable { direction: Direction, cells: [[Cell]], cluesByNumber: [Int: Clue], - seenWords: inout Set<WordKey> + seenWords: inout Set<WordKey>, + numbering: Numbering ) -> Set<Position> { let word = wordCells(fromRow: row, col: col, direction: direction, cells: cells) guard let first = word.first else { return [] } let key = WordKey(direction: direction, row: first.row, col: first.col) guard seenWords.insert(key).inserted, word.count > 1, - let number = clueNumber(forWord: word, cells: cells), + let number = clueNumber(forWord: word, numbering: numbering), let clue = cluesByNumber[number] else { return [] } @@ -815,11 +837,12 @@ struct XD: Sendable { col: Int, direction: Direction, cells: [[Cell]], - cluesByNumber: [Int: Clue] + cluesByNumber: [Int: Clue], + numbering: Numbering ) -> Set<String>? { let word = wordCells(fromRow: row, col: col, direction: direction, cells: cells) guard let wordIndex = word.firstIndex(where: { $0.row == row && $0.col == col }) else { return nil } - guard let number = clueNumber(forWord: word, cells: cells), + guard let number = clueNumber(forWord: word, numbering: numbering), let clue = cluesByNumber[number], !clue.acceptedAnswers.isEmpty else { return nil } @@ -920,43 +943,26 @@ struct XD: Sendable { } } - private static func clueNumber(atRow row: Int, col: Int, direction: Direction, cells: [[Cell]]) -> Int? { - let word = wordCells(fromRow: row, col: col, direction: direction, cells: cells) - return clueNumber(forWord: word, cells: cells) - } - private static func wordCells( forClueNumber number: Int, direction: Direction, - cells: [[Cell]] + cells: [[Cell]], + numbering: Numbering ) -> [(row: Int, col: Int)]? { - var counter = 1 - for r in cells.indices { - for c in cells[r].indices { - guard isOpen(cells, r, c) else { continue } - let startsAcross = !isOpen(cells, r, c - 1) && isOpen(cells, r, c + 1) - let startsDown = !isOpen(cells, r - 1, c) && isOpen(cells, r + 1, c) - guard startsAcross || startsDown else { continue } - - if counter == number { - switch direction { - case .across where startsAcross: - return wordCells(fromRow: r, col: c, direction: direction, cells: cells) - case .down where startsDown: - return wordCells(fromRow: r, col: c, direction: direction, cells: cells) - default: - return nil - } - } - counter += 1 - } + guard let start = numbering.start(forNumber: number) else { return nil } + switch direction { + case .across where start.startsAcross: + return wordCells(fromRow: start.row, col: start.col, direction: direction, cells: cells) + case .down where start.startsDown: + return wordCells(fromRow: start.row, col: start.col, direction: direction, cells: cells) + default: + return nil } - return nil } - private static func clueNumber(forWord word: [(row: Int, col: Int)], cells: [[Cell]]) -> Int? { + private static func clueNumber(forWord word: [(row: Int, col: Int)], numbering: Numbering) -> Int? { guard let first = word.first else { return nil } - return computedNumber(atRow: first.row, col: first.col, cells: cells) + return numbering.number(atRow: first.row, col: first.col) } private static func wordCells( @@ -985,20 +991,42 @@ struct XD: Sendable { return result } - private static func computedNumber(atRow row: Int, col: Int, cells: [[Cell]]) -> Int? { - var counter = 1 - for r in cells.indices { - for c in cells[r].indices { - guard isOpen(cells, r, c) else { continue } - let startsAcross = !isOpen(cells, r, c - 1) && isOpen(cells, r, c + 1) - let startsDown = !isOpen(cells, r - 1, c) && isOpen(cells, r + 1, c) - if startsAcross || startsDown { - if r == row, c == col { return counter } + /// Precomputed clue numbering for one grid. Built once per parse from the + /// block topology, then queried in O(1) by the answer-application passes — + /// replacing the former per-cell full-grid rescans that made those passes + /// O(cells²). The counter walk here mirrors the .xd numbering convention: + /// row-major, incrementing at every cell that starts an across or down word. + private struct Numbering { + /// Start cell → its clue number. + private let numberByPosition: [Position: Int] + /// Clue number → its start cell and which directions it opens. + private let startByNumber: [Int: (row: Int, col: Int, startsAcross: Bool, startsDown: Bool)] + + static func build(_ cells: [[Cell]]) -> Numbering { + var numberByPosition: [Position: Int] = [:] + var startByNumber: [Int: (row: Int, col: Int, startsAcross: Bool, startsDown: Bool)] = [:] + var counter = 1 + for r in cells.indices { + for c in cells[r].indices { + guard isOpen(cells, r, c) else { continue } + let startsAcross = !isOpen(cells, r, c - 1) && isOpen(cells, r, c + 1) + let startsDown = !isOpen(cells, r - 1, c) && isOpen(cells, r + 1, c) + guard startsAcross || startsDown else { continue } + numberByPosition[Position(row: r, col: c)] = counter + startByNumber[counter] = (r, c, startsAcross, startsDown) counter += 1 } } + return Numbering(numberByPosition: numberByPosition, startByNumber: startByNumber) + } + + func number(atRow row: Int, col: Int) -> Int? { + numberByPosition[Position(row: row, col: col)] + } + + func start(forNumber number: Int) -> (row: Int, col: Int, startsAcross: Bool, startsDown: Bool)? { + startByNumber[number] } - return nil } private static func isOpen(_ cells: [[Cell]], _ row: Int, _ col: Int) -> Bool { diff --git a/Crossmate/Views/Puzzle/CellPatterns.swift b/Crossmate/Views/Puzzle/CellPatterns.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// 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. +/// +/// Cell content is painted by `PuzzleCellsLayer` (a single `Canvas`); this +/// type and `CrossRefLines` are the drawing primitives it reuses. +enum CrossRefPattern: CaseIterable, Sendable { + case diagonalDown + case diagonalUp + case crosshatch + case horizontal + case vertical +} + +/// Parallel lines across a cell at one of four orientations. The line +/// family is anchored to a grid-wide lattice derived from the cell's +/// position rather than its own origin, so adjacent cells draw the same +/// family and the hatching meets exactly at every border. Runs are +/// generated past the edges and clipped by the caller. +struct CrossRefLines: Shape { + enum Slope { case down, up, horizontal, vertical } + var slope: Slope + /// This cell's grid position and the grid's inter-cell spacing. The + /// lattice phase is computed from these so every cell, at whatever + /// size the layout currently gives it, lands on the same global + /// family of lines. + var row: Int + var col: Int + var spacing: CGFloat + 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) + // Cells are square and uniformly pitched, so one pitch value + // covers both axes. Computed from the live rect, so it tracks + // any change in cell size automatically. + let pitch = side + spacing + let gx = CGFloat(col) * pitch + let gy = CGFloat(row) * pitch + + switch slope { + case .down: + // Lines of constant (globalX − globalY); snap the generator + // so that quantity is a multiple of `step` grid-wide. + let lower = rect.minX - rect.height + var x = firstAligned(at: lower, congruentTo: rect.minX - (gx - gy), step: step) + 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: + // Lines of constant (globalX + globalY). + let lower = rect.minX - rect.height + var x = firstAligned(at: lower, congruentTo: lower - (gx + gy), step: step) + 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 = firstAligned(at: rect.minY, congruentTo: rect.minY - gy, step: step) + 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 = firstAligned(at: rect.minX, congruentTo: rect.minX - gx, step: step) + 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 + } + + /// Smallest value ≥ `lower` that is congruent to `target` modulo + /// `step`. Starting each run here puts every cell's lines on one + /// shared global lattice, so they line up across cell borders. + private func firstAligned( + at lower: CGFloat, + congruentTo target: CGFloat, + step: CGFloat + ) -> CGFloat { + let delta = (target - lower).truncatingRemainder(dividingBy: step) + let offset = delta >= 0 ? delta : delta + step + return lower + offset + } +} diff --git a/Crossmate/Views/Puzzle/CellView.swift b/Crossmate/Views/Puzzle/CellView.swift @@ -1,278 +0,0 @@ -import SwiftUI - -struct CellView: View, Equatable { - let cell: Puzzle.Cell - let entry: String - let mark: CellMark - /// 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 - /// This cell's grid position and the grid's inter-cell spacing. Passed - /// through to the cross-reference texture so its line family is phased - /// to a grid-wide lattice and stays continuous across cell borders. - var gridRow: Int = 0 - var gridCol: Int = 0 - var gridSpacing: CGFloat = 1 - var authorTint: Color? = nil - - nonisolated static func == (lhs: CellView, rhs: CellView) -> Bool { - lhs.cell == rhs.cell - && lhs.entry == rhs.entry - && lhs.mark == rhs.mark - && lhs.crossRefPattern == rhs.crossRefPattern - && lhs.gridRow == rhs.gridRow - && lhs.gridCol == rhs.gridCol - && lhs.authorTint == rhs.authorTint - } - - var body: some View { - 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 letter and corner markers so it - // never fights legibility. - if let crossRefPattern { - CrossRefPatternView( - pattern: crossRefPattern, - row: gridRow, - col: gridCol, - spacing: gridSpacing - ) - } - if cell.special == .circled { - Circle() - .stroke(Color.black.opacity(0.55), lineWidth: 1) - .padding(1.5) - } - Text(entry) - .font(.system(size: 34, weight: .semibold, design: .rounded)) - .foregroundStyle(entryStyle) - .lineLimit(1) - .minimumScaleFactor(0.1) - .allowsTightening(true) - .padding(.horizontal, 2) - .frame(maxWidth: .infinity, maxHeight: .infinity) - if let triangleColor = cornerTriangleColor { - CornerTriangle() - .fill(triangleColor) - } - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - } - - /// Foreground style for the main entry letter. The cell fill is always - /// white regardless of system appearance, so the letter ink is always - /// black — it must not follow Dark Mode or it would vanish against the - /// white cell. Pencil entries render as a lighter black; everything else - /// — including revealed and checkedWrong cells — uses solid black. Reveal/ - /// wrong state is shown purely via the corner triangle, not by recolouring - /// the letter. - private var entryStyle: AnyShapeStyle { - switch mark { - case .pencil: - return AnyShapeStyle(Color.black.opacity(0.5)) - case .none, .pen, .revealed: - return AnyShapeStyle(Color.black) - } - } - - /// Fill colour for the top-right corner triangle: yellow for revealed - /// cells, red for checkedWrong, nothing otherwise. Revealed and - /// checkedWrong are mutually exclusive (a revealed cell can't be wrong), - /// so the order of the checks doesn't matter. - private var cornerTriangleColor: Color? { - if mark.isRevealed { return .yellow } - if mark.isCheckedWrong { return .red } - return nil - } - - // Drawn over a clear base: the white cell fill, the peer cursor tints, and - // the local cursor's selection/highlight are all rendered behind the grid - // (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`), so they show - // through wherever this cell draws nothing. Blocks stay fully clear, letting - // the grid's black show through. - @ViewBuilder - private var background: some View { - if !cell.isBlock { - ZStack { - if cell.special == .shaded { - Color.black.opacity(0.22) - } - // Faint background tint identifying who entered this letter in a - // shared game. The cursor fills it composites over are drawn - // behind the grid (see `LocalCursorTints`/`RemoteCursorTints`). - if let authorTint { - authorTint.opacity(PlayerColor.authorTintOpacity) - } - } - } - } -} - -/// 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 - /// Grid position and inter-cell spacing, forwarded to `CrossRefLines` - /// so the hatching is phased to a grid-wide lattice. - let row: Int - let col: Int - let spacing: CGFloat - - /// 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.20) - private let lineWidth: CGFloat = 1 - - private func lines(_ slope: CrossRefLines.Slope) -> CrossRefLines { - CrossRefLines(slope: slope, row: row, col: col, spacing: spacing) - } - - var body: some View { - Group { - switch pattern { - case .diagonalDown: - lines(.down).stroke(ink, lineWidth: lineWidth) - case .diagonalUp: - lines(.up).stroke(ink, lineWidth: lineWidth) - case .crosshatch: - ZStack { - lines(.down).stroke(ink, lineWidth: lineWidth) - lines(.up).stroke(ink, lineWidth: lineWidth) - } - case .horizontal: - lines(.horizontal).stroke(ink, lineWidth: lineWidth) - case .vertical: - lines(.vertical).stroke(ink, lineWidth: lineWidth) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .clipped() - .allowsHitTesting(false) - } -} - -/// Parallel lines across the cell at one of four orientations. The line -/// family is anchored to a grid-wide lattice derived from the cell's -/// position rather than its own origin, so adjacent cells draw the same -/// family and the hatching meets exactly at every border. Runs are -/// generated past the edges and clipped by the caller. -private struct CrossRefLines: Shape { - enum Slope { case down, up, horizontal, vertical } - var slope: Slope - /// This cell's grid position and the grid's inter-cell spacing. The - /// lattice phase is computed from these so every cell, at whatever - /// size the layout currently gives it, lands on the same global - /// family of lines. - var row: Int - var col: Int - var spacing: CGFloat - 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) - // Cells are square and uniformly pitched, so one pitch value - // covers both axes. Computed from the live rect, so it tracks - // any change in cell size automatically. - let pitch = side + spacing - let gx = CGFloat(col) * pitch - let gy = CGFloat(row) * pitch - - switch slope { - case .down: - // Lines of constant (globalX − globalY); snap the generator - // so that quantity is a multiple of `step` grid-wide. - let lower = rect.minX - rect.height - var x = firstAligned(at: lower, congruentTo: rect.minX - (gx - gy), step: step) - 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: - // Lines of constant (globalX + globalY). - let lower = rect.minX - rect.height - var x = firstAligned(at: lower, congruentTo: lower - (gx + gy), step: step) - 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 = firstAligned(at: rect.minY, congruentTo: rect.minY - gy, step: step) - 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 = firstAligned(at: rect.minX, congruentTo: rect.minX - gx, step: step) - 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 - } - - /// Smallest value ≥ `lower` that is congruent to `target` modulo - /// `step`. Starting each run here puts every cell's lines on one - /// shared global lattice, so they line up across cell borders. - private func firstAligned( - at lower: CGFloat, - congruentTo target: CGFloat, - step: CGFloat - ) -> CGFloat { - let delta = (target - lower).truncatingRemainder(dividingBy: step) - let offset = delta >= 0 ? delta : delta + step - return lower + offset - } -} - -/// Right-angled triangle pinned to the top-right corner of the cell, sized -/// as a fraction of the shorter cell dimension. Used as a small marker for -/// revealed and checkedWrong cells. -private struct CornerTriangle: Shape { - enum Corner { case topLeading, topTrailing } - var corner: Corner = .topTrailing - var fraction: CGFloat = 0.3 - - func path(in rect: CGRect) -> Path { - let side = min(rect.width, rect.height) * fraction - var path = Path() - switch corner { - case .topTrailing: - path.move(to: CGPoint(x: rect.maxX - side, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + side)) - case .topLeading: - path.move(to: CGPoint(x: rect.minX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.minX + side, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + side)) - } - path.closeSubpath() - return path - } -} diff --git a/Crossmate/Views/Puzzle/GridView.swift b/Crossmate/Views/Puzzle/GridView.swift @@ -18,6 +18,10 @@ struct GridView: View { private let spacing: CGFloat = 1 + /// The grid container's resolved size, captured so the single tap recogniser + /// can map a tap location back to a cell via `PuzzleGridGeometry`. + @State private var gridSize: CGSize = .zero + var body: some View { let width = session.puzzle.width let height = session.puzzle.height @@ -48,6 +52,45 @@ struct GridView: View { .map { $0.color.selectionFill } let patternPalette = CrossRefPattern.allCases let cellGroups = session.puzzle.cellGroups + // Flatten the grid to a plain draw model read here in `body` (so it + // tracks the observable game state), then hand it to a single `Canvas` + // that paints all cell content in one pass — replacing the former + // 441-view `ForEach`, which dominated first-render cost. Block cells + // carry no content; they're skipped at draw time but kept in the array + // so indices map directly to positions. + let cellModel: [CellDraw] = (0..<(width * height)).map { index in + let r = index / width + let c = index % width + let cell = session.puzzle.cells[r][c] + guard !cell.isBlock else { + return CellDraw(row: r, col: c, isBlock: true, special: nil, + crossRef: nil, entry: "", isPencil: false, + triangle: nil, authorTint: nil) + } + let pos = GridPosition(row: r, col: c) + let square = session.game.squares[r][c] + // During replay the cell's letter/mark/author come from the + // reconstructed history, not the live square. + let replayCell = replayCells?[pos] + let entry = isReplaying ? (replayCell?.letter ?? "") : square.entry + let displayEntry = canonicalRebusFill(for: cell, when: showsCanonicalRebus) ?? entry + let mark = isReplaying ? (replayCell?.mark ?? .none) : square.mark + let letterAuthorID = isReplaying ? replayCell?.cellAuthorID : square.letterAuthorID + let triangle: TriangleKind? = mark.isRevealed + ? .revealed + : (mark.isCheckedWrong ? .wrong : nil) + return CellDraw( + row: r, + col: c, + isBlock: false, + special: cell.special, + crossRef: cellGroups[pos].map { patternPalette[$0 % patternPalette.count] }, + entry: displayEntry, + isPencil: mark.isPencil, + triangle: triangle, + authorTint: entry.isEmpty ? nil : letterAuthorID.flatMap { authorTintByID[$0] } + ) + } // Layered back to front: black (shows through the inter-cell gaps and // behind blocks) -> white cell backdrop -> peer cursor tints -> local // cursor tints -> cells. Keep these as siblings in one layout so @@ -70,42 +113,28 @@ struct GridView: View { if !isReplaying { RecentChangeBorders(session: session, roster: roster, spacing: spacing) } - PuzzleGridLayout(columns: width, rows: height, spacing: spacing) { - ForEach(0..<(width * height), id: \.self) { index in - let r = index / width - let c = index % width - let pos = GridPosition(row: r, col: c) - let cell = session.puzzle.cells[r][c] - let square = session.game.squares[r][c] - // During replay the cell's letter/mark/author come from the - // reconstructed history, not the live square. - let replayCell = replayCells?[pos] - let entry = isReplaying ? (replayCell?.letter ?? "") : square.entry - let displayEntry = canonicalRebusFill(for: cell, when: showsCanonicalRebus) ?? entry - let mark = isReplaying ? (replayCell?.mark ?? .none) : square.mark - let letterAuthorID = isReplaying ? replayCell?.cellAuthorID : square.letterAuthorID - CellView( - cell: cell, - entry: displayEntry, - mark: mark, - crossRefPattern: cellGroups[pos].map { - patternPalette[$0 % patternPalette.count] - }, - gridRow: r, - gridCol: c, - gridSpacing: spacing, - authorTint: entry.isEmpty - ? nil - : letterAuthorID.flatMap { authorTintByID[$0] } - ) - .equatable() - .onTapGesture { - guard !isReplaying else { return } - session.select(row: r, col: c) - } - } + PuzzleCellsLayer( + cells: cellModel, + columns: width, + rows: height, + spacing: spacing + ) + } + // One tap recogniser for the whole grid instead of 441 per-cell ones: + // with uniform cells the tapped cell is pure arithmetic on the tap + // location (see `PuzzleGridGeometry.cell(at:)`). `select` already guards + // blocks and out-of-range, so any in-grid tap is safe to forward. + .contentShape(Rectangle()) + .onTapGesture(coordinateSpace: .local) { location in + guard !isReplaying, gridSize != .zero else { return } + let geometry = PuzzleGridGeometry( + size: gridSize, columns: width, rows: height, spacing: spacing + ) + if let (r, c) = geometry.cell(at: location) { + session.select(row: r, col: c) } } + .onGeometryChange(for: CGSize.self) { $0.size } action: { gridSize = $0 } } /// The canonical fill to display for a rebus square on a solved puzzle, or @@ -119,6 +148,170 @@ struct GridView: View { } +// MARK: - Cell content + +/// Which corner-triangle marker a cell carries, if any. Mirrors the former +/// `CellView.cornerTriangleColor`: revealed cells are yellow, checked-wrong red. +private enum TriangleKind { + case revealed + case wrong +} + +/// A flat, value-type snapshot of everything one cell draws. Built in +/// `GridView.body` (so it tracks observable state) and rendered by +/// `PuzzleCellsLayer`. Replaces the per-cell `CellView`. +private struct CellDraw { + let row: Int + let col: Int + let isBlock: Bool + let special: Puzzle.Special? + let crossRef: CrossRefPattern? + let entry: String + let isPencil: Bool + let triangle: TriangleKind? + /// The author's base tint, if this cell carries an entry in a shared game. + /// `PlayerColor.authorTintOpacity` is applied at draw time, matching the + /// former `CellView` background. + let authorTint: Color? +} + +/// Draws all cell content (author tints, shaded/circled specials, cross-ref +/// hatching, letters, corner triangles) in a single `Canvas`. This is the layer +/// that previously cost ~441 SwiftUI view subtrees on first render; as one +/// immediate-mode draw pass it builds in roughly constant time. Z-order within +/// each cell matches the former `CellView` exactly: shaded → author tint → +/// cross-ref → circle → letter → corner triangle. +private struct PuzzleCellsLayer: View { + let cells: [CellDraw] + let columns: Int + let rows: Int + let spacing: CGFloat + + /// Base size before the per-cell fit-scale; mirrors the former + /// `CellView` letter font (`.system(size: 34, weight: .semibold, + /// design: .rounded)`). + private let baseFontSize: CGFloat = 34 + + var body: some View { + Canvas { context, size in + let geometry = PuzzleGridGeometry( + size: size, columns: columns, rows: rows, spacing: spacing + ) + guard geometry.cellSize > 0 else { return } + + // Resolved letters are cached per (entry, pencil) for this draw: a + // full grid has ~26 distinct glyphs, so almost every cell is a hit + // and `resolve`/`measure` runs a couple of dozen times, not 441. + var glyphCache: [String: (text: GraphicsContext.ResolvedText, size: CGSize)] = [:] + + for cell in cells where !cell.isBlock { + let rect = geometry.cellRect(row: cell.row, col: cell.col) + + if cell.special == .shaded { + context.fill(Path(rect), with: .color(.black.opacity(0.22))) + } + if let tint = cell.authorTint { + context.fill( + Path(rect), + with: .color(tint.opacity(PlayerColor.authorTintOpacity)) + ) + } + if let crossRef = cell.crossRef { + drawCrossRef(crossRef, in: rect, row: cell.row, col: cell.col, context: context) + } + if cell.special == .circled { + context.stroke( + Path(ellipseIn: rect.insetBy(dx: 1.5, dy: 1.5)), + with: .color(.black.opacity(0.55)), + lineWidth: 1 + ) + } + if !cell.entry.isEmpty { + drawLetter(cell, in: rect, cache: &glyphCache, context: context) + } + if let triangle = cell.triangle { + let side = min(rect.width, rect.height) * 0.3 + var path = Path() + path.move(to: CGPoint(x: rect.maxX - side, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + side)) + path.closeSubpath() + context.fill(path, with: .color(triangle == .revealed ? .yellow : .red)) + } + } + } + } + + /// Draws one entry centred in its cell, scaled down to fit exactly as the + /// former `.minimumScaleFactor(0.1)` did: the largest size ≤ base that fits + /// the cell minus the 2pt horizontal padding. Single letters never scale (a + /// 4-char rebus does), and the per-cell scale is pure arithmetic on one + /// cached measurement. + private func drawLetter( + _ cell: CellDraw, + in rect: CGRect, + cache: inout [String: (text: GraphicsContext.ResolvedText, size: CGSize)], + context: GraphicsContext + ) { + let key = "\(cell.entry)|\(cell.isPencil)" + let resolved: GraphicsContext.ResolvedText + let natural: CGSize + if let hit = cache[key] { + resolved = hit.text + natural = hit.size + } else { + let color: Color = cell.isPencil ? .black.opacity(0.5) : .black + let text = context.resolve( + Text(cell.entry) + .font(.system(size: baseFontSize, weight: .semibold, design: .rounded)) + .foregroundStyle(color) + ) + let measured = text.measure(in: CGSize(width: CGFloat.infinity, height: CGFloat.infinity)) + cache[key] = (text, measured) + resolved = text + natural = measured + } + guard natural.width > 0, natural.height > 0 else { return } + let availableWidth = rect.width - 4 // matches .padding(.horizontal, 2) + let scale = min(1, availableWidth / natural.width, rect.height / natural.height) + context.drawLayer { layer in + layer.translateBy(x: rect.midX, y: rect.midY) + layer.scaleBy(x: scale, y: scale) + layer.draw(resolved, at: .zero, anchor: .center) + } + } + + /// Strokes the passive cross-reference hatching for one cell, reusing the + /// `CrossRefLines` lattice so lines stay continuous across cell borders. + /// The path is generated in cell-local space, then clipped to the cell and + /// translated into place. + private func drawCrossRef( + _ pattern: CrossRefPattern, + in rect: CGRect, + row: Int, + col: Int, + context: GraphicsContext + ) { + let ink = Color.black.opacity(0.20) + let localRect = CGRect(x: 0, y: 0, width: rect.width, height: rect.height) + func stroke(_ slope: CrossRefLines.Slope) { + let path = CrossRefLines(slope: slope, row: row, col: col, spacing: spacing) + .path(in: localRect) + var layer = context + layer.clip(to: Path(rect)) + layer.translateBy(x: rect.minX, y: rect.minY) + layer.stroke(path, with: .color(ink), lineWidth: 1) + } + switch pattern { + case .diagonalDown: stroke(.down) + case .diagonalUp: stroke(.up) + case .crosshatch: stroke(.down); stroke(.up) + case .horizontal: stroke(.horizontal) + case .vertical: stroke(.vertical) + } + } +} + // MARK: - Layout private enum PuzzleGridMetrics { @@ -158,60 +351,6 @@ private enum PuzzleGridMetrics { } } -/// Lays out puzzle cells in a `rows × columns` grid with a uniform square -/// cell size. The cell size is chosen to fit the proposed container, with -/// `spacing` points between every cell and around the outer edge (the black -/// grid border shows through the gaps). The laid-out grid reports its tight -/// size via `sizeThatFits`, so the parent view sizes us to exactly the grid -/// dimensions — no `.aspectRatio` modifier needed. -private struct PuzzleGridLayout: Layout { - let columns: Int - let rows: Int - let spacing: CGFloat - - func sizeThatFits( - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) -> CGSize { - let cellSize = PuzzleGridMetrics.cellSize( - for: proposal, - columns: columns, - rows: rows, - spacing: spacing - ) - let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) - let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) - return CGSize(width: width, height: height) - } - - func placeSubviews( - in bounds: CGRect, - proposal: ProposedViewSize, - subviews: Subviews, - cache: inout () - ) { - let geometry = PuzzleGridGeometry( - size: bounds.size, - columns: columns, - rows: rows, - spacing: spacing - ) - let cellProposal = ProposedViewSize( - width: geometry.cellSize, - height: geometry.cellSize - ) - for (index, subview) in subviews.enumerated() { - let rect = geometry.cellRect(row: index / columns, col: index % columns) - subview.place( - at: CGPoint(x: bounds.minX + rect.minX, y: bounds.minY + rect.minY), - anchor: .topLeading, - proposal: cellProposal - ) - } - } -} - /// Stacks the grid's backing layers and cell layout into one measured surface. /// Using `.background` for the Canvases can let SwiftUI hand those layers a /// different size from the custom cell layout on iPad, which makes their @@ -272,14 +411,16 @@ private struct PuzzleGridLayerLayout: Layout { /// Shared cell geometry for the puzzle grid: given a container `size`, computes /// the uniform cell size (via `PuzzleGridMetrics`) and the centred origin, then -/// hands back the frame of any `(row, col)`. `PuzzleGridLayout` and the backing +/// hands back the frame of any `(row, col)`. `PuzzleCellsLayer` and the backing /// `Canvas` layers (`GridBackdrop`, `RemoteCursorTints`, `LocalCursorTints`) all -/// derive their cell rects from this, so the layers stay pixel-aligned with the -/// cells above them. +/// derive their cell rects from this, so every layer stays pixel-aligned; it +/// also inverts the mapping (`cell(at:)`) for the grid's single tap recogniser. private struct PuzzleGridGeometry { let cellSize: CGFloat let spacing: CGFloat let gridSize: CGSize + private let columns: Int + private let rows: Int private let originX: CGFloat private let originY: CGFloat @@ -294,6 +435,8 @@ private struct PuzzleGridGeometry { let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) self.cellSize = cellSize self.spacing = spacing + self.columns = columns + self.rows = rows self.gridSize = CGSize(width: gridWidth, height: gridHeight) self.originX = (size.width - gridWidth) / 2 self.originY = (size.height - gridHeight) / 2 @@ -311,6 +454,21 @@ private struct PuzzleGridGeometry { height: cellSize ) } + + /// Inverse of `cellRect`: maps a tap point (in the grid's coordinate space) + /// to the cell it falls in. Returns `nil` for points outside the grid (the + /// centring margin / black border). The uniform lattice makes this pure + /// arithmetic — the reason a single grid-wide tap recogniser can replace a + /// per-cell one. Points landing in an inter-cell gap clamp to the nearest + /// cell so the whole grid surface is tappable. + func cell(at point: CGPoint) -> (row: Int, col: Int)? { + guard gridRect.contains(point) else { return nil } + let stride = cellSize + spacing + guard stride > 0 else { return nil } + let col = min(max(Int((point.x - originX - spacing) / stride), 0), columns - 1) + let row = min(max(Int((point.y - originY - spacing) / stride), 0), rows - 1) + return (row, col) + } } // MARK: - Backing layers