crossmate

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

GridAccessibility.swift (24637B)


      1 import SwiftUI
      2 
      3 /// Builds the VoiceOver label/value strings for one grid cell. Pure and
      4 /// UI-free so the wording is unit-testable without any view scaffolding.
      5 struct CellAccessibilityDescriber {
      6     let puzzle: Puzzle
      7     /// Display names keyed by authorID, spoken as "filled by <name>" on a
      8     /// filled square ("you" for the local player). Empty when author
      9     /// attribution should not be spoken (solo games, hidden annotations).
     10     let authorNames: [String: String]
     11 
     12     /// The cell's position within its word(s): "5 Across, letter 2 of 7;
     13     /// 3 Down, letter 4 of 5", dropping either half when the cell isn't part
     14     /// of a crossing word. A cell in no word at all (a length-1 run) falls
     15     /// back to its coordinates so it still reads as something.
     16     func label(atRow row: Int, atCol col: Int) -> String {
     17         var parts: [String] = []
     18         for direction in [Puzzle.Direction.across, .down] {
     19             let cells = puzzle.wordCells(atRow: row, col: col, direction: direction)
     20             guard let number = cells.first?.number,
     21                   let index = cells.firstIndex(where: { $0.row == row && $0.col == col })
     22             else { continue }
     23             let name = direction == .across ? "Across" : "Down"
     24             parts.append("\(number) \(name), letter \(index + 1) of \(cells.count)")
     25         }
     26         if parts.isEmpty {
     27             parts.append("Row \(row + 1), Column \(col + 1)")
     28         }
     29         let special = puzzle.cells[row][col].special
     30         if special == .circled { parts.append("circled") }
     31         if special == .shaded { parts.append("shaded") }
     32         return parts.joined(separator: "; ")
     33     }
     34 
     35     /// The spoken heading for a clue: "5 Across: Big cheese in Holland".
     36     /// Used both as the rotor entry label and as the announcement prefix.
     37     /// Nil when no clue carries that number in that direction.
     38     func clueLabel(number: Int, direction: Puzzle.Direction) -> String? {
     39         let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
     40         guard let clue = clues.first(where: { $0.number == number }) else { return nil }
     41         let name = direction == .across ? "Across" : "Down"
     42         return "\(number) \(name): \(clue.text)"
     43     }
     44 
     45     /// The announcement posted when the cursor enters a new word or the
     46     /// direction toggles: the clue heading plus progress — "5 Across: Big
     47     /// cheese in Holland. 7 letters, 3 filled." A zero filled-count is
     48     /// omitted; a complete word is spoken as "all filled". Nil when the cell
     49     /// has no word in `direction`.
     50     func clueAnnouncement(
     51         atRow row: Int,
     52         atCol col: Int,
     53         direction: Puzzle.Direction,
     54         squares: [[Square]]
     55     ) -> String? {
     56         let cells = puzzle.wordCells(atRow: row, col: col, direction: direction)
     57         guard let number = cells.first?.number,
     58               let heading = clueLabel(number: number, direction: direction)
     59         else { return nil }
     60         let filled = cells.count { !squares[$0.row][$0.col].entry.isEmpty }
     61         var progress = "\(cells.count) letters"
     62         if filled == cells.count {
     63             progress += ", all filled"
     64         } else if filled > 0 {
     65             progress += ", \(filled) filled"
     66         }
     67         return "\(heading). \(progress)"
     68     }
     69 
     70     /// The coalesced announcement for a burst of peer fills: "Alice filled
     71     /// 5 Across" when the burst completes a single word, "Alice filled
     72     /// 3 squares in 5 Across" while it's still open, and a plain square
     73     /// count when the burst spans words. Gap cells count as filled for the
     74     /// completeness read, matching the completion logic.
     75     func peerFillAnnouncement(
     76         playerName: String,
     77         positions: Set<GridPosition>,
     78         squares: [[Square]]
     79     ) -> String? {
     80         guard let first = positions.first else { return nil }
     81         for direction in [Puzzle.Direction.across, .down] {
     82             let cells = puzzle.wordCells(atRow: first.row, col: first.col, direction: direction)
     83             guard let number = cells.first?.number else { continue }
     84             let wordPositions = Set(cells.map { GridPosition(row: $0.row, col: $0.col) })
     85             guard positions.isSubset(of: wordPositions) else { continue }
     86             let word = "\(number) \(direction == .across ? "Across" : "Down")"
     87             let complete = cells.allSatisfy {
     88                 !squares[$0.row][$0.col].entry.isEmpty || $0.expectsBlank
     89             }
     90             if complete { return "\(playerName) filled \(word)" }
     91             if positions.count == 1 { return "\(playerName) filled a square in \(word)" }
     92             return "\(playerName) filled \(positions.count) squares in \(word)"
     93         }
     94         return positions.count == 1
     95             ? "\(playerName) filled a square"
     96             : "\(playerName) filled \(positions.count) squares"
     97     }
     98 
     99     /// The outcome of a check gesture over `cells`, read against the marks
    100     /// the check just applied. Single-cell checks read as a bare verdict;
    101     /// word checks count mistakes and unfilled squares.
    102     func checkAnnouncement(for cells: [Puzzle.Cell], squares: [[Square]]) -> String {
    103         let open = cells.filter { !$0.isBlock }
    104         let filled = open.filter { !squares[$0.row][$0.col].entry.isEmpty }
    105         guard !filled.isEmpty else { return "Nothing to check" }
    106         let wrong = filled.count { squares[$0.row][$0.col].mark.isCheckedWrong }
    107         if open.count == 1 {
    108             return wrong > 0 ? "Incorrect" : "Correct"
    109         }
    110         if wrong > 0 {
    111             return wrong == 1 ? "1 square incorrect" : "\(wrong) squares incorrect"
    112         }
    113         let empty = open.count - filled.count
    114         if empty == 0 { return "All correct" }
    115         return empty == 1
    116             ? "No mistakes, 1 square empty"
    117             : "No mistakes, \(empty) squares empty"
    118     }
    119 
    120     /// Speaks the letter a reveal just wrote: "Revealed R". Nil when the
    121     /// reveal was a no-op (e.g. a gap cell, whose correct state is empty).
    122     func revealAnnouncement(atRow row: Int, atCol col: Int, squares: [[Square]]) -> String? {
    123         let entry = squares[row][col].entry
    124         guard !entry.isEmpty else { return nil }
    125         return "Revealed \(entry)"
    126     }
    127 
    128     /// The grid-completion announcement. `.incomplete` is a return to normal
    129     /// play and stays silent. `.filledWithErrors` is the state a sighted
    130     /// player notices by the absence of the success panel — for a blind
    131     /// player it must be said out loud.
    132     func completionAnnouncement(for state: Game.CompletionState) -> String? {
    133         switch state {
    134         case .incomplete: nil
    135         case .filledWithErrors: "The grid is full, but something isn't right"
    136         case .solved: "Puzzle solved. Congratulations!"
    137         }
    138     }
    139 
    140     /// The cell's fill and its provenance: "Empty", "R", "R, draft",
    141     /// "R, revealed", "R, incorrect", plus "filled by Alice" when author
    142     /// names are being spoken. "Draft" matches the keyboard's user-facing
    143     /// term for pencil mode.
    144     func value(for square: Square) -> String {
    145         guard !square.entry.isEmpty else { return "Empty" }
    146         var parts = [square.entry]
    147         if square.mark.isPencil { parts.append("draft") }
    148         if square.mark.isRevealed { parts.append("revealed") }
    149         if square.mark.isCheckedWrong { parts.append("incorrect") }
    150         if let authorID = square.letterAuthorID, let name = authorNames[authorID] {
    151             parts.append("filled by \(name)")
    152         }
    153         return parts.joined(separator: ", ")
    154     }
    155 }
    156 
    157 extension View {
    158     /// Synthesises a VoiceOver representation for the Canvas-drawn puzzle
    159     /// grid: one accessibility element per open cell, framed to match the
    160     /// drawn cell exactly (so touch exploration works and blocks read as
    161     /// gaps), plus two-way sync between VoiceOver focus and the session
    162     /// cursor. Apply to the grid container alongside the tap recogniser.
    163     func puzzleGridAccessibility(
    164         session: PlayerSession,
    165         roster: PlayerRoster,
    166         revealConfirmation: RevealConfirmation,
    167         showsSharedAnnotations: Bool,
    168         isReplaying: Bool,
    169         gridSize: CGSize,
    170         spacing: CGFloat
    171     ) -> some View {
    172         modifier(PuzzleGridAccessibility(
    173             session: session,
    174             roster: roster,
    175             revealConfirmation: revealConfirmation,
    176             showsSharedAnnotations: showsSharedAnnotations,
    177             isReplaying: isReplaying,
    178             gridSize: gridSize,
    179             spacing: spacing
    180         ))
    181     }
    182 }
    183 
    184 private struct PuzzleGridAccessibility: ViewModifier {
    185     let session: PlayerSession
    186     let roster: PlayerRoster
    187     let revealConfirmation: RevealConfirmation
    188     let showsSharedAnnotations: Bool
    189     let isReplaying: Bool
    190     let gridSize: CGSize
    191     let spacing: CGFloat
    192 
    193     @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled
    194     @AccessibilityFocusState private var focusedCell: GridPosition?
    195     /// Pairs the rotor entries with the cell elements they jump to.
    196     @Namespace private var rotorNamespace
    197     /// Peer fills buffered per author while a burst is in flight, flushed as
    198     /// one coalesced announcement after a quiet interval — a peer typing a
    199     /// word arrives as one edit per letter and must not announce per letter.
    200     @State private var pendingPeerFills: [String: Set<GridPosition>] = [:]
    201     @State private var peerFillFlushTask: Task<Void, Never>?
    202 
    203     /// Whether to build the synthetic hierarchy. Normally only while
    204     /// VoiceOver is running, but the Simulator can't run VoiceOver at all —
    205     /// Accessibility Inspector reads the tree without setting the flag — so
    206     /// debug builds can force it on with the `-ForceGridAccessibility`
    207     /// launch argument.
    208     private var isActive: Bool {
    209         if voiceOverEnabled { return true }
    210         #if DEBUG
    211         return ProcessInfo.processInfo.arguments.contains("-ForceGridAccessibility")
    212         #else
    213         return false
    214         #endif
    215     }
    216 
    217     func body(content: Content) -> some View {
    218         // The synthetic hierarchy is ~441 accessibility-only nodes, so it is
    219         // built only while VoiceOver is running — sighted users keep the
    220         // single-Canvas render path untouched. Replay is display-only: the
    221         // cells are suppressed and the scrubber narrates the playhead.
    222         if isActive, !isReplaying, gridSize != .zero {
    223             content
    224                 .accessibilityChildren { cellElements }
    225                 // Selection → focus: `enter(_:)` auto-advances the cursor, so
    226                 // following it makes VoiceOver read the newly focused cell —
    227                 // that is the typing echo; no explicit announcement needed.
    228                 .onChange(of: selection) { _, newValue in
    229                     focusedCell = newValue
    230                 }
    231                 // Focus → selection: a flick onto a cell is navigation, so the
    232                 // session cursor (and the clue bar, and peers' view of our
    233                 // cursor) follows. Guarded on inequality because `select` on
    234                 // the already-selected cell toggles direction — that toggle is
    235                 // reserved for an explicit double-tap (the activate action),
    236                 // matching what a sighted tap does.
    237                 .onChange(of: focusedCell) { _, newValue in
    238                     guard let newValue, newValue != selection else { return }
    239                     session.select(row: newValue.row, col: newValue.col)
    240                 }
    241                 // Word-change context: moving within a word stays quiet (the
    242                 // focused cell's own label carries the position); entering a
    243                 // new word or toggling direction announces the clue and its
    244                 // progress once.
    245                 .onChange(of: currentClueRef) { _, newValue in
    246                     guard newValue != nil else { return }
    247                     announceCurrentClue()
    248                 }
    249                 .accessibilityRotor("Across clues") { clueRotorEntries(for: .across) }
    250                 .accessibilityRotor("Down clues") { clueRotorEntries(for: .down) }
    251                 .accessibilityRotor("Incomplete clues") { incompleteClueRotorEntries }
    252                 // Peer fills: both the realtime overlay and CloudKit merges
    253                 // land on the open game through `GameStore.refreshCurrentGame`,
    254                 // so diffing the squares here catches every remote path with
    255                 // no sync plumbing. Local edits are filtered out by authorID.
    256                 .onChange(of: session.game.squares) { oldValue, newValue in
    257                     collectPeerFills(from: oldValue, to: newValue)
    258                 }
    259                 .onChange(of: session.completionEvent) { _, event in
    260                     guard let event else { return }
    261                     announce(describer.completionAnnouncement(for: event.state))
    262                 }
    263                 .onAppear { focusedCell = selection }
    264                 .onDisappear { peerFillFlushTask?.cancel() }
    265         } else {
    266             content
    267         }
    268     }
    269 
    270     private var selection: GridPosition {
    271         GridPosition(row: session.selectedRow, col: session.selectedCol)
    272     }
    273 
    274     /// Identity of the word the cursor is in: changes when the cursor crosses
    275     /// into a different word or the direction toggles, and drives the clue
    276     /// announcement. Nil when the focused cell has no word in the current
    277     /// direction.
    278     private var currentClueRef: Puzzle.ClueRef? {
    279         session.puzzle.clue(
    280             atRow: session.selectedRow,
    281             col: session.selectedCol,
    282             direction: session.direction
    283         ).map { Puzzle.ClueRef(number: $0.number, direction: session.direction) }
    284     }
    285 
    286     private var describer: CellAccessibilityDescriber {
    287         CellAccessibilityDescriber(puzzle: session.puzzle, authorNames: [:])
    288     }
    289 
    290     private func announceCurrentClue() {
    291         announce(describer.clueAnnouncement(
    292             atRow: session.selectedRow,
    293             atCol: session.selectedCol,
    294             direction: session.direction,
    295             squares: session.game.squares
    296         ))
    297     }
    298 
    299     /// Posts a spoken announcement. Low priority queues behind whatever
    300     /// VoiceOver is already saying (and may be dropped if the user keeps
    301     /// talking over it) — right for ambient peer activity, wrong for direct
    302     /// responses to the user's own action.
    303     private func announce(_ text: String?, lowPriority: Bool = false) {
    304         guard let text else { return }
    305         if lowPriority {
    306             var attributed = AttributedString(text)
    307             attributed.accessibilitySpeechAnnouncementPriority = .low
    308             AccessibilityNotification.Announcement(attributed).post()
    309         } else {
    310             AccessibilityNotification.Announcement(text).post()
    311         }
    312     }
    313 
    314     // MARK: - Check actions
    315 
    316     private func checkAndAnnounce(wholeWord: Bool) {
    317         let cells: [Puzzle.Cell]
    318         if wholeWord {
    319             session.checkCurrentWord()
    320             cells = session.puzzle.wordCells(
    321                 atRow: session.selectedRow,
    322                 col: session.selectedCol,
    323                 direction: session.direction
    324             )
    325         } else {
    326             session.checkSquare()
    327             cells = [session.puzzle.cells[session.selectedRow][session.selectedCol]]
    328         }
    329         announce(describer.checkAnnouncement(for: cells, squares: session.game.squares))
    330     }
    331 
    332     // MARK: - Peer fill announcements
    333 
    334     /// Collects squares whose entry a remote author just changed. Fills only:
    335     /// a cleared square carries no author, so a peer's clear is
    336     /// indistinguishable here from the local player's own delete and must
    337     /// stay silent rather than misfire on every local backspace.
    338     private func collectPeerFills(from old: [[Square]], to new: [[Square]]) {
    339         guard old.count == new.count else { return }
    340         let localAuthorID = roster.entries.first(where: { $0.isLocal })?.authorID
    341         var found = false
    342         for row in 0..<new.count {
    343             guard old[row].count == new[row].count else { continue }
    344             for col in 0..<new[row].count {
    345                 let after = new[row][col]
    346                 guard after.entry != old[row][col].entry,
    347                       !after.entry.isEmpty,
    348                       let author = after.letterAuthorID,
    349                       author != localAuthorID
    350                 else { continue }
    351                 pendingPeerFills[author, default: []].insert(GridPosition(row: row, col: col))
    352                 found = true
    353             }
    354         }
    355         guard found else { return }
    356         peerFillFlushTask?.cancel()
    357         peerFillFlushTask = Task {
    358             try? await Task.sleep(for: .seconds(2))
    359             guard !Task.isCancelled else { return }
    360             flushPeerFills()
    361         }
    362     }
    363 
    364     private func flushPeerFills() {
    365         let fills = pendingPeerFills
    366         pendingPeerFills = [:]
    367         guard !fills.isEmpty else { return }
    368         let names = Dictionary(
    369             roster.entries.map { ($0.authorID, $0.name) },
    370             uniquingKeysWith: { first, _ in first }
    371         )
    372         let squares = session.game.squares
    373         let texts = fills.keys.sorted().compactMap { author in
    374             describer.peerFillAnnouncement(
    375                 playerName: names[author] ?? "A collaborator",
    376                 positions: fills[author] ?? [],
    377                 squares: squares
    378             )
    379         }
    380         guard !texts.isEmpty else { return }
    381         announce(texts.joined(separator: ". "), lowPriority: true)
    382     }
    383 
    384     // MARK: - Rotors
    385 
    386     /// Rotor entries for one direction's clue list, each jumping VoiceOver to
    387     /// the word's first cell. The prepare closure aligns the session cursor
    388     /// first so the jump lands with direction and selection already correct
    389     /// (and the clue announcement fires from the resulting word change).
    390     @AccessibilityRotorContentBuilder
    391     private func clueRotorEntries(
    392         for direction: Puzzle.Direction
    393     ) -> some AccessibilityRotorContent {
    394         let puzzle = session.puzzle
    395         let describer = CellAccessibilityDescriber(puzzle: puzzle, authorNames: [:])
    396         let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
    397         ForEach(clues) { clue in
    398             if let cell = puzzle.cell(numbered: clue.number),
    399                let label = describer.clueLabel(number: clue.number, direction: direction) {
    400                 AccessibilityRotorEntry(
    401                     Text(label),
    402                     id: GridPosition(row: cell.row, col: cell.col),
    403                     in: rotorNamespace
    404                 ) {
    405                     session.selectClue(direction: direction, number: clue.number)
    406                 }
    407             }
    408         }
    409     }
    410 
    411     /// "What's left": every clue whose word still has an unfilled square,
    412     /// acrosses first then downs. Gap cells (`expectsBlank`) count as filled —
    413     /// their correct state is empty, so they must not pin a word in this
    414     /// rotor forever.
    415     @AccessibilityRotorContentBuilder
    416     private var incompleteClueRotorEntries: some AccessibilityRotorContent {
    417         let puzzle = session.puzzle
    418         let describer = CellAccessibilityDescriber(puzzle: puzzle, authorNames: [:])
    419         ForEach(incompleteClueRefs, id: \.self) { ref in
    420             if let cell = puzzle.cell(numbered: ref.number),
    421                let label = describer.clueLabel(number: ref.number, direction: ref.direction) {
    422                 AccessibilityRotorEntry(
    423                     Text(label),
    424                     id: GridPosition(row: cell.row, col: cell.col),
    425                     in: rotorNamespace
    426                 ) {
    427                     session.selectClue(direction: ref.direction, number: ref.number)
    428                 }
    429             }
    430         }
    431     }
    432 
    433     private var incompleteClueRefs: [Puzzle.ClueRef] {
    434         let puzzle = session.puzzle
    435         let squares = session.game.squares
    436         var refs: [Puzzle.ClueRef] = []
    437         for direction in [Puzzle.Direction.across, .down] {
    438             let clues = direction == .across ? puzzle.acrossClues : puzzle.downClues
    439             for clue in clues {
    440                 guard let start = puzzle.cell(numbered: clue.number) else { continue }
    441                 let cells = puzzle.wordCells(
    442                     atRow: start.row, col: start.col, direction: direction
    443                 )
    444                 let isIncomplete = cells.contains {
    445                     squares[$0.row][$0.col].entry.isEmpty && !$0.expectsBlank
    446                 }
    447                 if isIncomplete {
    448                     refs.append(Puzzle.ClueRef(number: clue.number, direction: direction))
    449                 }
    450             }
    451         }
    452         return refs
    453     }
    454 
    455     /// The hidden per-cell hierarchy handed to `accessibilityChildren`. Each
    456     /// open cell becomes a clear rect at the exact `PuzzleGridGeometry` frame
    457     /// of the drawn cell; blocks get no element and read as gaps. Emitted in
    458     /// row-major order, which is also VoiceOver's flick order.
    459     private var cellElements: some View {
    460         let puzzle = session.puzzle
    461         let geometry = PuzzleGridGeometry(
    462             size: gridSize,
    463             columns: puzzle.width,
    464             rows: puzzle.height,
    465             spacing: spacing
    466         )
    467         let describer = CellAccessibilityDescriber(
    468             puzzle: puzzle,
    469             authorNames: authorNames
    470         )
    471         let selection = self.selection
    472         return ZStack(alignment: .topLeading) {
    473             ForEach(openPositions, id: \.self) { pos in
    474                 let rect = geometry.cellRect(row: pos.row, col: pos.col)
    475                 Color.clear
    476                     .accessibilityElement()
    477                     .accessibilityLabel(describer.label(atRow: pos.row, atCol: pos.col))
    478                     .accessibilityValue(describer.value(for: session.game.squares[pos.row][pos.col]))
    479                     .accessibilityAddTraits(pos == selection ? .isSelected : [])
    480                     // Double-tap: same entry point as a sighted tap, including
    481                     // select's toggle-direction behaviour on the focused cell.
    482                     .accessibilityAction {
    483                         session.select(row: pos.row, col: pos.col)
    484                     }
    485                     .accessibilityFocused($focusedCell, equals: pos)
    486                     .accessibilityRotorEntry(id: pos, in: rotorNamespace)
    487                     // These act on the session's *selected* cell, which the
    488                     // focus sync keeps equal to the focused element. Check and
    489                     // reveal disappear on a solved grid or a read-only mutator
    490                     // — revoked access or an unsupported protocol (which would
    491                     // reject them anyway); Switch Direction stays for
    492                     // navigation. Reveal is non-undoable, so it routes through
    493                     // the same confirmation alert as the Hints menu and ⌘R
    494                     // instead of revealing immediately.
    495                     .accessibilityActions {
    496                         Button("Switch Direction") { session.toggleDirection() }
    497                         if session.mutator.isEditable, session.game.completionState != .solved {
    498                             Button("Check Square") { checkAndAnnounce(wholeWord: false) }
    499                             Button("Check Word") { checkAndAnnounce(wholeWord: true) }
    500                             Button("Reveal Square") { revealConfirmation.request(.square) }
    501                         }
    502                     }
    503                     .frame(width: rect.width, height: rect.height)
    504                     .position(x: rect.midX, y: rect.midY)
    505             }
    506         }
    507         .frame(width: gridSize.width, height: gridSize.height, alignment: .topLeading)
    508     }
    509 
    510     private var openPositions: [GridPosition] {
    511         let puzzle = session.puzzle
    512         var positions: [GridPosition] = []
    513         for row in 0..<puzzle.height {
    514             for col in 0..<puzzle.width where !puzzle.cells[row][col].isBlock {
    515                 positions.append(GridPosition(row: row, col: col))
    516             }
    517         }
    518         return positions
    519     }
    520 
    521     /// Spoken author attribution, mirroring the visual author tints: only in
    522     /// a shared game, with the local player spoken as "you".
    523     private var authorNames: [String: String] {
    524         guard showsSharedAnnotations else { return [:] }
    525         return Dictionary(
    526             roster.entries.map { ($0.authorID, $0.isLocal ? "you" : $0.name) },
    527             uniquingKeysWith: { first, _ in first }
    528         )
    529     }
    530 }