crossmate

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

commit 12ced1516dcb353405aee0fbcffd030b233454ad
parent 5c3c08de2fc9f641e5842789b62fcab9756f1382
Author: Michael Camilleri <[email protected]>
Date:   Fri, 10 Apr 2026 06:06:55 +0900

Add support for specials

Crosswords sometimes include 'special' cells that are shaded or circle
as a way to express a theme and give a hint to the player. This adds
support for that to the .xd parser.

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

Diffstat:
MCrossmate/Models/Puzzle.swift | 16+++++++++++++---
MCrossmate/Models/XD.swift | 39+++++++++++++++++++++++++++------------
MCrossmate/Views/CellView.swift | 9+++++++++
MCrossmate/Views/GridView.swift | 3++-
4 files changed, 51 insertions(+), 16 deletions(-)

diff --git a/Crossmate/Models/Puzzle.swift b/Crossmate/Models/Puzzle.swift @@ -11,8 +11,16 @@ struct Puzzle: Sendable { var opposite: Direction { self == .across ? .down : .across } } + /// How "special" cells (lowercase letters in `.xd`) should be drawn. + /// A puzzle can use circles or shading, but not both at once. + enum Special: Sendable, Hashable { + case circled + case shaded + } + let title: String let author: String? + let specialKind: Special? let width: Int let height: Int let cells: [[Cell]] @@ -23,6 +31,7 @@ struct Puzzle: Sendable { let row: Int let col: Int let isBlock: Bool + let isSpecial: Bool let number: Int? let solution: String? } @@ -40,6 +49,7 @@ struct Puzzle: Sendable { init(xd: XD) { self.title = xd.title ?? "Untitled" self.author = xd.author + self.specialKind = xd.specialKind self.width = xd.width self.height = xd.height @@ -57,8 +67,8 @@ struct Puzzle: Sendable { for c in 0..<xd.width { switch xd.cells[r][c] { case .block: - rowCells.append(Cell(row: r, col: c, isBlock: true, number: nil, solution: nil)) - case .open(let solution): + rowCells.append(Cell(row: r, col: c, isBlock: true, isSpecial: false, number: nil, solution: nil)) + case .open(let solution, let isSpecial): let leftBlock = c == 0 || Self.isBlock(xd.cells, r, c - 1) let rightOpen = c + 1 < xd.width && !Self.isBlock(xd.cells, r, c + 1) let topBlock = r == 0 || Self.isBlock(xd.cells, r - 1, c) @@ -71,7 +81,7 @@ struct Puzzle: Sendable { } else { number = nil } - rowCells.append(Cell(row: r, col: c, isBlock: false, number: number, solution: solution)) + rowCells.append(Cell(row: r, col: c, isBlock: false, isSpecial: isSpecial, number: number, solution: solution)) } } cells.append(rowCells) diff --git a/Crossmate/Models/XD.swift b/Crossmate/Models/XD.swift @@ -7,6 +7,7 @@ struct XD: Sendable { let title: String? let author: String? let copyright: String? + let specialKind: Puzzle.Special? let width: Int let height: Int let cells: [[Cell]] @@ -15,10 +16,12 @@ struct XD: Sendable { /// A single grid cell as it appears in the .xd source. Open cells carry /// an optional solution string which may be 1+ characters long once any - /// `Rebus:` mapping has been applied. + /// `Rebus:` mapping has been applied, plus a flag indicating whether the + /// cell is "special" (circled or shaded — the kind is per-puzzle and + /// lives on `XD.specialKind`). enum Cell: Sendable, Equatable { case block - case open(solution: String?) + case open(solution: String?, isSpecial: Bool) } struct Clue: Sendable, Equatable { @@ -56,6 +59,7 @@ struct XD: Sendable { let metadata = parseMetadata(sections[0]) let rebus = parseRebusHeader(metadata["Rebus"]) + let specialKind = parseSpecialHeader(metadata["Special"]) let (cells, width, height) = try parseGrid(sections[1], rebus: rebus) let (across, down) = try parseClues(sections[2]) @@ -63,6 +67,7 @@ struct XD: Sendable { title: metadata["Title"], author: metadata["Author"], copyright: metadata["Copyright"], + specialKind: specialKind, width: width, height: height, cells: cells, @@ -156,6 +161,18 @@ struct XD: Sendable { return map } + /// Parses a `Special:` header value into a `Puzzle.Special` kind. The + /// .xd spec recognises `shaded` and `circle` as the two values; we accept + /// either case-insensitively and treat anything else as no special kind. + private static func parseSpecialHeader(_ value: String?) -> Puzzle.Special? { + guard let value else { return nil } + switch value.trimmingCharacters(in: .whitespaces).lowercased() { + case "shaded": return .shaded + case "circle", "circled": return .circled + default: return nil + } + } + // MARK: - Grid private static func parseGrid( @@ -196,20 +213,18 @@ struct XD: Sendable { } // '.' is an open cell with no known solution. if ch == "." { - return .open(solution: nil) + return .open(solution: nil, isSpecial: false) } - // Rebus placeholders are looked up in the Rebus header. Lowercase - // letters that appear in the Rebus header behave as combined - // rebus + special cells; we don't model "special" (circled/shaded) - // yet, so for now they collapse to their rebus expansion. + // Per the .xd spec, lowercase letters always indicate a special cell + // (circled or shaded — the kind is in the `Special:` header). They + // may *also* appear in the Rebus header, in which case the solution + // is the rebus expansion; otherwise it's the uppercased letter. + let isLowercaseLetter = ch.isLetter && ch.isLowercase if let expansion = rebus[ch] { - return .open(solution: expansion.uppercased()) + return .open(solution: expansion.uppercased(), isSpecial: isLowercaseLetter) } - // Lowercase letters not in the Rebus header are "special" cells - // (circled or shaded). The solution character is the uppercase form. - // Plain uppercase letters are normal solution cells. if ch.isLetter { - return .open(solution: String(ch).uppercased()) + return .open(solution: String(ch).uppercased(), isSpecial: isLowercaseLetter) } throw ParseError.unknownGridCharacter(ch) } diff --git a/Crossmate/Views/CellView.swift b/Crossmate/Views/CellView.swift @@ -6,6 +6,7 @@ struct CellView: View { let mark: CellMark let isSelected: Bool let isHighlighted: Bool + let specialKind: Puzzle.Special? @Environment(\.playerColor) private var playerColor @@ -13,6 +14,11 @@ struct CellView: View { ZStack(alignment: .topLeading) { background if !cell.isBlock { + if cell.isSpecial && specialKind == .circled { + Circle() + .stroke(Color.black.opacity(0.55), lineWidth: 1) + .padding(1.5) + } if let number = cell.number { Text("\(number)") .font(.system(size: 11, weight: .semibold)) @@ -71,6 +77,9 @@ struct CellView: View { } else { ZStack { Color.white + if cell.isSpecial && specialKind == .shaded { + Color.black.opacity(0.18) + } if isSelected { playerColor.tint.opacity(playerColor.selectedOpacity) } else if isHighlighted { diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -17,7 +17,8 @@ struct GridView: View { entry: session.game.entries[r][c], mark: session.game.marks[r][c], isSelected: session.selectedRow == r && session.selectedCol == c, - isHighlighted: session.isInCurrentWord(row: r, col: c) + isHighlighted: session.isInCurrentWord(row: r, col: c), + specialKind: session.puzzle.specialKind ) .onTapGesture { session.select(row: r, col: c)