commit 58154ad2ca30e3739418d1325c3ec1eaa1ef36b7
parent 1376ed86a7e44d41247e3ad6913b51c3e04be22f
Author: Michael Camilleri <[email protected]>
Date: Wed, 17 Jun 2026 13:04:45 +0900
Support puzzles with number and symbol squares
Some puzzles place a single non-letter character in a grid square and if
that occurred, Crossmate would simply fail to load these puzzles. The
converter wrote the bare character straight into the .xd grid, but the
grid parser only accepts letters there (alongside block and special
markers and declared placeholders) and so rejected it.
This commit routes every non-letter fill, whether multi-letter or a
single digit or symbol, through a 'Rebus:' placeholder, so a digit never
appears literally in the grid — each grid digit is unambiguously a
placeholder reference, and a digit fill can no longer collide with a
digit placeholder. After parsing, such a square is an ordinary
single-character cell whose solution is the digit itself.
The commit also moves to a 2-layer keyboard. The first layer is the
alphabetical keys while the second layer is numbers, symbols and some
meta keys.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
5 files changed, 268 insertions(+), 75 deletions(-)
diff --git a/Crossmate/Services/NYTToXDConverter.swift b/Crossmate/Services/NYTToXDConverter.swift
@@ -29,6 +29,20 @@ enum NYTToXDConverter {
return Array("123456789") + symbols
}()
+ /// Whether a cell fill must ride into the grid via a `Rebus:` placeholder
+ /// rather than appear literally. The `.xd` grid is one character per cell and
+ /// the grid parser only takes letters (plus block/special markers and
+ /// declared placeholders) as direct fills, so anything longer than one
+ /// character — or a single non-letter character such as the "2"/"3" cells in
+ /// an "R2D2"/"C3PO" themer — has to be encoded. Routing every non-letter fill
+ /// through a placeholder means a digit never appears literally in the grid:
+ /// each grid digit is unambiguously a placeholder reference, so digit fills
+ /// and digit placeholders can't collide.
+ private static func needsRebusEncoding(_ answer: String) -> Bool {
+ if answer.count != 1 { return true }
+ return !(answer.first?.isLetter ?? false)
+ }
+
/// Converts raw JSON data from the NYT puzzle endpoint to an `.xd` source string.
static func convert(jsonData: Data) throws -> String {
guard let root = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else {
@@ -122,7 +136,7 @@ enum NYTToXDConverter {
var rebusLookup: [String: Character] = [:]
for answer in answers {
- guard let answer, answer.count > 1 else { continue }
+ guard let answer, needsRebusEncoding(answer) else { continue }
if rebusLookup[answer] == nil {
guard rebusLookup.count < rebusPlaceholders.count else {
throw ConversionError(
@@ -158,7 +172,7 @@ enum NYTToXDConverter {
line += "*"
continue
}
- if answer.count > 1 {
+ if needsRebusEncoding(answer) {
line += String(rebusLookup[answer]!)
} else {
line += answer.uppercased()
diff --git a/Crossmate/Views/Puzzle/HardwareKeyboardInputView.swift b/Crossmate/Views/Puzzle/HardwareKeyboardInputView.swift
@@ -35,12 +35,20 @@ struct HardwareKeyboardInputView: UIViewRepresentable {
)
}
+ let digits = "0123456789".map {
+ UIKeyCommand(
+ input: String($0),
+ modifierFlags: [],
+ action: #selector(handleKeyCommand(_:))
+ )
+ }
+
let undo = UIKeyCommand(input: "z", modifierFlags: .command, action: #selector(handleKeyCommand(_:)))
undo.discoverabilityTitle = "Undo Move"
let redo = UIKeyCommand(input: "z", modifierFlags: [.command, .shift], action: #selector(handleKeyCommand(_:)))
redo.discoverabilityTitle = "Redo Move"
- return letters + [
+ return letters + digits + [
undo,
redo,
UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleKeyCommand(_:))),
@@ -137,6 +145,16 @@ private extension HardwareKeyboardEvent {
return nil
}
keyCode = code
+ case "1"..."9":
+ // HID usages run keyboard1…keyboard9 contiguously (keyboard0 sits
+ // after, handled separately below).
+ guard let digit = characters.first?.wholeNumberValue,
+ let code = UIKeyboardHIDUsage(rawValue: UIKeyboardHIDUsage.keyboard1.rawValue + (digit - 1)) else {
+ return nil
+ }
+ keyCode = code
+ case "0":
+ keyCode = .keyboard0
default:
return nil
}
diff --git a/Crossmate/Views/Puzzle/KeyboardView.swift b/Crossmate/Views/Puzzle/KeyboardView.swift
@@ -6,11 +6,23 @@ import SwiftUI
struct KeyboardView: View {
@Bindable var session: PlayerSession
var showsNavigationKeys = false
- @State private var showingOverflow = false
+
+ /// Whether the secondary numbers/symbols layer is showing in place of the
+ /// letters. Reached via the `…` key and dismissed via `ABC`; sticky across
+ /// keystrokes (crossword input isn't predominantly numeric, so we don't
+ /// auto-revert). Stays available during rebus entry so mixed fills like
+ /// `H2O` can be typed.
+ @State private var showsSymbols = false
private let topRow = Array("QWERTYUIOP").map(String.init)
private let middleRow = Array("ASDFGHJKL").map(String.init)
private let bottomLetters = Array("ZXCVBNM").map(String.init)
+ private let digitRow = Array("1234567890").map(String.init)
+ // The nine symbols on the NYT Games keyboard's middle row. NYT shows a
+ // further row (' ` , . : /) we omit: a grid fill that isn't a letter is
+ // almost always a digit, with `&` the only symbol seen in practice, so the
+ // omitted ones are not expected to appear in any puzzle.
+ private let symbolRow = ["@", "#", "$", "%", "&", "*", "-", "!", "+"]
private let spacing: CGFloat = 6
private let keyHeight: CGFloat = 46
@@ -19,6 +31,19 @@ struct KeyboardView: View {
static let standardHeight: CGFloat = 170
var body: some View {
+ Group {
+ if showsSymbols {
+ symbolRows
+ } else {
+ letterRows
+ }
+ }
+ .padding(.horizontal, 4)
+ .padding(.top, 12)
+ .padding(.bottom, 8)
+ }
+
+ private var letterRows: some View {
VStack(spacing: spacing) {
KeyboardRow(
referenceColumns: showsNavigationKeys ? 12 : 10,
@@ -68,7 +93,7 @@ struct KeyboardView: View {
keyHeight: keyHeight
) {
if showsNavigationKeys {
- rebusKey
+ layerToggleKey
.disablesKeyboardMetaAnimations()
.fillsExtraKeyboardSpace()
@@ -83,14 +108,8 @@ struct KeyboardView: View {
.disablesKeyboardMetaAnimations()
.fillsExtraKeyboardSpace()
} else {
- if session.isRebusActive {
- actionKey(text: "Done", background: .blue, foreground: .white) {
- session.commitRebus()
- }
+ layerToggleKey
.keyWidthMultiplier(metaKeyWidthMultiplier)
- } else {
- overflowKey
- }
}
ForEach(bottomLetters, id: \.self) { letter in
@@ -108,21 +127,137 @@ struct KeyboardView: View {
.fillsExtraKeyboardSpace()
}
- actionKey(systemImage: "delete.left") {
- if session.isRebusActive {
- session.deleteRebusLetter()
- } else {
- session.deleteBackward()
+ deleteKey
+ .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier)
+ .fillsExtraKeyboardSpace(showsNavigationKeys)
+ }
+ }
+ }
+
+ /// The secondary numbers/symbols layer. It mirrors the letters layer's
+ /// scaffolding exactly — the `…` toggle, Delete, and (on iPad) the
+ /// navigation arrows / pencil / switch-direction keys keep their position
+ /// and width — and only swaps the *centre* keys: digits on the top row,
+ /// symbols on the middle row, and the Undo / Rebus / Redo meta keys on the
+ /// bottom row. The digit/symbol keys reuse `letterKey`, so they append to
+ /// the rebus buffer during entry and enter directly otherwise. The three
+ /// meta keys take the same column budget the seven letters they replace had,
+ /// so `…` and Delete don't change size between layers.
+ private var symbolRows: some View {
+ VStack(spacing: spacing) {
+ KeyboardRow(
+ referenceColumns: showsNavigationKeys ? 12 : 10,
+ spacing: spacing,
+ keyHeight: keyHeight
+ ) {
+ if showsNavigationKeys {
+ actionKey(systemImage: "chevron.left", accessibilityLabel: "Previous Word") {
+ session.goToPreviousWord()
+ }
+ .fillsExtraKeyboardSpace()
+ }
+ ForEach(digitRow, id: \.self) { letterKey($0) }
+ if showsNavigationKeys {
+ actionKey(systemImage: "chevron.right", accessibilityLabel: "Next Word") {
+ session.goToNextWord()
+ }
+ .fillsExtraKeyboardSpace()
+ }
+ }
+ KeyboardRow(
+ referenceColumns: showsNavigationKeys ? 12 : 10,
+ spacing: spacing,
+ keyHeight: keyHeight
+ ) {
+ if showsNavigationKeys {
+ actionKey(systemImage: "arrowtriangle.left.fill", accessibilityLabel: "Previous Letter") {
+ session.goToPreviousLetter()
+ }
+ .fillsExtraKeyboardSpace()
+ }
+ ForEach(symbolRow, id: \.self) { letterKey($0) }
+ if showsNavigationKeys {
+ actionKey(systemImage: "arrowtriangle.right.fill", accessibilityLabel: "Next Letter") {
+ session.goToNextLetter()
+ }
+ .fillsExtraKeyboardSpace()
+ }
+ }
+ KeyboardRow(
+ referenceColumns: showsNavigationKeys ? 12 : 10,
+ spacing: spacing,
+ keyHeight: keyHeight
+ ) {
+ if showsNavigationKeys {
+ layerToggleKey
+ .disablesKeyboardMetaAnimations()
+ .fillsExtraKeyboardSpace()
+
+ actionKey(
+ systemImage: "pencil",
+ accessibilityLabel: session.isPencilMode ? "Turn Off Draft" : "Turn On Draft",
+ background: session.isPencilMode ? .blue : Color(.systemFill),
+ foreground: session.isPencilMode ? .white : .primary
+ ) {
+ session.togglePencil()
+ }
+ .disablesKeyboardMetaAnimations()
+ .fillsExtraKeyboardSpace()
+ } else {
+ layerToggleKey
+ .keyWidthMultiplier(metaKeyWidthMultiplier)
+ }
+
+ undoKey
+ .keyWidthMultiplier(metaKeyWidthMultiplier)
+ rebusKey
+ .disablesKeyboardMetaAnimations()
+ .keyWidthMultiplier(rebusKeyWidthMultiplier)
+ // The bottom row has four fewer inter-key gaps than the
+ // letters row (three meta keys replace seven letters). That
+ // shortfall otherwise pulls the flanking keys inward — `…`
+ // and Delete on compact, and the flex keys' shared width on
+ // iPad. Giving Rebus the missing gap width makes the row's
+ // total identical to the letters row, so those keys don't
+ // move between layers on either device.
+ .extraGapWidth(rebusExtraGapWidth)
+ redoKey
+ .keyWidthMultiplier(metaKeyWidthMultiplier)
+
+ if showsNavigationKeys {
+ actionKey(
+ systemImage: "arrow.2.squarepath",
+ accessibilityLabel: "Switch Direction"
+ ) {
+ session.toggleDirection()
}
+ .disablesKeyboardMetaAnimations()
+ .fillsExtraKeyboardSpace()
}
- .disablesKeyboardMetaAnimations()
- .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier)
- .fillsExtraKeyboardSpace(showsNavigationKeys)
+
+ deleteKey
+ .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier)
+ .fillsExtraKeyboardSpace(showsNavigationKeys)
}
}
- .padding(.horizontal, 4)
- .padding(.top, 12)
- .padding(.bottom, 8)
+ }
+
+ /// Width (in base key columns) for the Rebus key on the secondary layer's
+ /// bottom row. Undo and Redo match the `…` and Delete meta-key width; Rebus
+ /// absorbs the remainder of the column budget the seven `ZXCVBNM` keys
+ /// occupy on the letters layer, so the surrounding `…` and Delete keys keep
+ /// their letters-layer width.
+ private var rebusKeyWidthMultiplier: CGFloat {
+ CGFloat(bottomLetters.count) - 2 * metaKeyWidthMultiplier
+ }
+
+ /// Extra gap widths the Rebus key takes so the meta row totals the same
+ /// width as the letters row — the difference in their inter-key gap counts.
+ /// The two rows share scaffolding and differ only in the centre (three meta
+ /// keys vs the seven `bottomLetters`), so the gap shortfall is the same on
+ /// both layouts.
+ private var rebusExtraGapWidth: CGFloat {
+ CGFloat(bottomLetters.count - 3)
}
private func letterKey(_ letter: String) -> some View {
@@ -157,56 +292,40 @@ struct KeyboardView: View {
}
}
- private var overflowKey: some View {
- actionKey(systemImage: "ellipsis") {
- showingOverflow = true
+ /// The `…` key. It swaps between the letters and the numbers/symbols layer,
+ /// keeping the same icon and position on both so it reads as one persistent
+ /// control. Available during rebus entry too, so mixed fills can be typed.
+ private var layerToggleKey: some View {
+ actionKey(systemImage: "ellipsis", accessibilityLabel: showsSymbols ? "Letters" : "Numbers and Symbols") {
+ showsSymbols.toggle()
}
- .keyWidthMultiplier(metaKeyWidthMultiplier)
- .popover(isPresented: $showingOverflow) {
- VStack(alignment: .leading, spacing: 0) {
- overflowItem("Undo Move", systemImage: "arrow.uturn.backward", isEnabled: session.canUndo) {
- session.undo()
- }
-
- overflowItem("Redo Move", systemImage: "arrow.uturn.forward", isEnabled: session.canRedo) {
- session.redo()
- }
-
- Divider()
+ }
- overflowItem("Toggle Draft", systemImage: "pencil") {
- session.togglePencil()
- }
+ private var undoKey: some View {
+ actionKey(systemImage: "arrow.uturn.backward", accessibilityLabel: "Undo Move") {
+ session.undo()
+ }
+ .disabled(!session.canUndo)
+ .opacity(session.canUndo ? 1 : 0.4)
+ }
- overflowItem("Enter Rebus", systemImage: "text.cursor") {
- session.startRebus()
- }
- }
- .frame(minWidth: 160)
- .presentationCompactAdaptation(.popover)
- .presentationBackground(.thinMaterial)
+ private var redoKey: some View {
+ actionKey(systemImage: "arrow.uturn.forward", accessibilityLabel: "Redo Move") {
+ session.redo()
}
+ .disabled(!session.canRedo)
+ .opacity(session.canRedo ? 1 : 0.4)
}
- /// One row in the overflow popover. Dismisses the popover, then runs the
- /// action — disabled rows (e.g. Undo with nothing to undo) are greyed out.
- private func overflowItem(
- _ title: String,
- systemImage: String,
- isEnabled: Bool = true,
- action: @escaping () -> Void
- ) -> some View {
- Button {
- showingOverflow = false
- action()
- } label: {
- Label(title, systemImage: systemImage)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 16)
- .padding(.vertical, 12)
+ private var deleteKey: some View {
+ actionKey(systemImage: "delete.left") {
+ if session.isRebusActive {
+ session.deleteRebusLetter()
+ } else {
+ session.deleteBackward()
+ }
}
- .buttonStyle(.plain)
- .disabled(!isEnabled)
+ .disablesKeyboardMetaAnimations()
}
private func actionKey(
@@ -252,8 +371,10 @@ struct KeyboardView: View {
/// Lays out a row of keys at a fixed key height. Every key is sized as a
/// fraction of `referenceColumns`, so a row containing fewer keys ends up
/// narrower than the reference row and is centered. Action keys can opt into
-/// a wider width via `keyWidthMultiplier`, and selected keys can split any
-/// leftover row width via `fillsExtraKeyboardSpace`.
+/// a wider width via `keyWidthMultiplier`, take a fixed number of extra
+/// inter-key gap widths via `extraGapWidth` (to compensate for a row having
+/// fewer gaps than another), and selected keys can split any leftover row
+/// width via `fillsExtraKeyboardSpace`.
private struct KeyboardRow: Layout {
let referenceColumns: Int
let spacing: CGFloat
@@ -297,7 +418,9 @@ private struct KeyboardRow: Layout {
baseKeyWidth: CGFloat,
containerWidth: CGFloat
) -> [CGFloat] {
- var widths = subviews.map { baseKeyWidth * $0[KeyWidthMultiplier.self] }
+ var widths = subviews.map {
+ baseKeyWidth * $0[KeyWidthMultiplier.self] + spacing * $0[ExtraGapWidth.self]
+ }
let fixedSpacing = spacing * CGFloat(max(0, subviews.count - 1))
let totalWidth = widths.reduce(0, +) + fixedSpacing
let flexibleIndexes = subviews.indices.filter { subviews[$0][FillsExtraKeyboardSpace.self] }
@@ -321,11 +444,22 @@ private struct FillsExtraKeyboardSpace: LayoutValueKey {
static let defaultValue = false
}
+/// Extra width, measured in inter-key gap (`spacing`) multiples, added to a key
+/// on top of its column width. Used to make a sparse row total the same width as
+/// a denser one so their shared edge keys line up.
+private struct ExtraGapWidth: LayoutValueKey {
+ static let defaultValue: CGFloat = 0
+}
+
private extension View {
func keyWidthMultiplier(_ multiplier: CGFloat) -> some View {
layoutValue(key: KeyWidthMultiplier.self, value: multiplier)
}
+ func extraGapWidth(_ gaps: CGFloat) -> some View {
+ layoutValue(key: ExtraGapWidth.self, value: gaps)
+ }
+
func fillsExtraKeyboardSpace(_ fills: Bool = true) -> some View {
layoutValue(key: FillsExtraKeyboardSpace.self, value: fills)
}
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -420,17 +420,19 @@ struct PuzzleView: View {
.keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO,
.keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT,
.keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY,
- .keyboardZ:
+ .keyboardZ,
+ .keyboard0, .keyboard1, .keyboard2, .keyboard3, .keyboard4,
+ .keyboard5, .keyboard6, .keyboard7, .keyboard8, .keyboard9:
guard !event.modifierFlags.contains(.command),
!event.modifierFlags.contains(.control),
!event.modifierFlags.contains(.alternate),
- let letter = hardwareKeyboardLetter(from: event) else {
+ let character = hardwareKeyboardCharacter(from: event) else {
return false
}
if session.isRebusActive {
- session.appendRebusLetter(letter)
+ session.appendRebusLetter(character)
} else {
- session.enter(letter)
+ session.enter(character)
}
return true
@@ -512,13 +514,15 @@ struct PuzzleView: View {
}
}
- private func hardwareKeyboardLetter(from event: HardwareKeyboardEvent) -> String? {
+ private func hardwareKeyboardCharacter(from event: HardwareKeyboardEvent) -> String? {
let scalars = event.charactersIgnoringModifiers.unicodeScalars
guard scalars.count == 1, let scalar = scalars.first else { return nil }
switch scalar.value {
- case 65...90, 97...122:
+ case 65...90, 97...122: // A–Z, a–z
return String(Character(scalar)).uppercased()
+ case 48...57: // 0–9
+ return String(Character(scalar))
default:
return nil
}
diff --git a/Tests/Unit/NYTToXDConverterTests.swift b/Tests/Unit/NYTToXDConverterTests.swift
@@ -200,6 +200,29 @@ struct NYTToXDConverterTests {
#expect(puzzle.cells[0][12].solution == "HE")
}
+ @Test("Single-character digit fills are encoded as rebus placeholders and round-trip")
+ func digitCellsBecomeRebusPlaceholders() throws {
+ // Mirrors the 2021-04-21 "R2D2" themer: the "2" cells are single-
+ // character digit fills. A digit can't appear literally in the .xd grid
+ // (the parser only accepts letters there), so each rides in via a Rebus
+ // placeholder — the digit never lands in the grid. After parsing the
+ // cell is an ordinary single-character cell whose solution is "2", so a
+ // solver typing "2" directly (not through the rebus interface) is
+ // accepted.
+ let data = try singleRowPuzzleJSON(answers: ["R", "2", "D", "2"])
+ let xd = try NYTToXDConverter.convert(jsonData: data)
+
+ #expect(header("Rebus", in: xd) == "1=2")
+ #expect(xd.contains("R1D1")) // grid uses the placeholder, not a literal 2
+ #expect(xd.contains("~ R2D2")) // the clue answer still carries the real fill
+
+ let puzzle = Puzzle(xd: try XD.parse(xd))
+ let cell = puzzle.cells[0][1]
+ #expect(cell.solution == "2")
+ #expect(cell.accepts("2")) // a direct "2" keystroke counts as correct
+ #expect(!cell.accepts("R"))
+ }
+
@Test("NYT type 2 cells emit circled specials")
func typeTwoCellsEmitCircledSpecials() throws {
let data = try puzzleJSON(