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:
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)