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 }