PuzzleView.swift (30409B)
1 import SwiftUI 2 3 enum RevealScope { 4 case square 5 case word 6 case puzzle 7 8 var title: String { 9 switch self { 10 case .square: "Reveal Square?" 11 case .word: "Reveal Word?" 12 case .puzzle: "Reveal Puzzle?" 13 } 14 } 15 16 var message: String { 17 switch self { 18 case .square: "This will reveal the current square." 19 case .word: "This will reveal the current word." 20 case .puzzle: "This will reveal the entire puzzle and mark it complete." 21 } 22 } 23 } 24 25 struct PuzzleView: View { 26 @Bindable var session: PlayerSession 27 var shareController: ShareController? = nil 28 let roster: PlayerRoster 29 var onComplete: ((_ notifyPeers: Bool) -> Void)? = nil 30 var onResign: (() throws -> Void)? = nil 31 var onDelete: (() throws -> Void)? = nil 32 /// Sends a broadcast nudge to the other players. `nil` for solo/test 33 /// sessions, which hides the menu button. 34 var onNudge: (() async -> Void)? = nil 35 /// When the next nudge becomes allowed (the send cooldown's end), or `nil` 36 /// if one is allowed right now. A session without nudging wired up hides the 37 /// button regardless (see `onNudge`). 38 var nudgeReadyAt: () -> Date? = { nil } 39 /// Loads the finished game's merged journal for the finish-banner replay 40 /// scrubber. Defaults to `.unavailable` so previews/tests need not wire it. 41 var loadReplay: () async -> JournalReplayResult = { .unavailable } 42 /// Cells a peer filled or cleared since this player last viewed the puzzle, 43 /// mapped to the writing author. Read once on the open arm beat. Defaults to 44 /// empty so previews/tests need not wire it. 45 var loadRecentChanges: () -> [GridPosition: String] = { [:] } 46 /// Stamps this game's last-viewed timestamp (device-local). Called when the 47 /// away-change borders are acknowledged. Defaults to a no-op. 48 var markPuzzleViewed: () -> Void = {} 49 @Environment(InputMonitor.self) private var inputMonitor 50 @Environment(PlayerPreferences.self) private var preferences 51 @Environment(AnnouncementCenter.self) private var announcements 52 @Environment(\.dismiss) private var dismiss 53 @Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled 54 @State private var isRenaming = false 55 @State private var renameDraft = "" 56 @State private var showErrorsAlert = false 57 @State private var isConfirmingResign = false 58 @State private var isConfirmingDelete = false 59 @State private var isConfirmingLeave = false 60 @State private var revealConfirmation = RevealConfirmation() 61 @State private var isConfirmingClear = false 62 @State private var leaveError: String? 63 @State private var destructiveActionError: String? 64 @State private var isShowingShareSheet = false 65 @State private var hasSolved = false 66 @State private var replay = ReplayControls() 67 @State private var padLayout: PadLayout? 68 /// The most recent size reported while the app was frontmost. Snapshot 69 /// passes the system runs while backgrounded report other orientations; 70 /// those must not disturb the layout, so only active-time sizes are kept 71 /// and re-applied when the scene returns to the foreground. 72 @State private var lastActiveSize: CGSize = .zero 73 /// The shared open "arm" beat: flips a moment after open so the banner and 74 /// the "changed while you were away" borders reveal together. 75 @State private var isArmed = false 76 @State private var announcedIntroGameID: UUID? 77 /// Drives the system keyboard for rebus entry. Bound to `isRebusActive`: 78 /// focusing the rebus field raises the keyboard, and losing focus (e.g. the 79 /// player swipes the keyboard away) commits the buffer. 80 @FocusState private var isRebusFieldFocused: Bool 81 @Environment(\.engagementStatus) private var engagementStatus 82 @Environment(\.scenePhase) private var scenePhase 83 84 private enum PadLayout: Equatable { 85 case landscape 86 case portrait 87 } 88 89 private var effectivePadLayout: PadLayout? { 90 guard UIDevice.current.userInterfaceIdiom == .pad else { return nil } 91 return padLayout ?? .portrait 92 } 93 94 private func swatchImage(for color: PlayerColor) -> Image { 95 let tint = UIColor(color.tint) 96 let base = UIImage(systemName: "circle.fill") ?? UIImage() 97 return Image(uiImage: base.withTintColor(tint, renderingMode: .alwaysOriginal)) 98 } 99 100 private struct TitleParts { 101 let title: String 102 let subtitle: String? 103 } 104 105 private var titleParts: TitleParts { 106 let title = session.puzzle.title 107 let formattedDate = session.puzzle.date?.formatted(date: .long, time: .omitted) 108 let subtitle: String? 109 if let publisher = session.puzzle.publisher, let formattedDate { 110 subtitle = "\(publisher) · \(formattedDate)" 111 } else if let publisher = session.puzzle.publisher { 112 subtitle = publisher 113 } else { 114 subtitle = formattedDate 115 } 116 return TitleParts(title: title, subtitle: subtitle) 117 } 118 119 // Latched completion counts as solved for the read-only presentation 120 // (hides the keyboard, shows the finish panel, disables the controls) even 121 // when the locally merged grid drifted and no longer reads `.solved`. 122 private var isSolved: Bool { hasSolved || session.mutator.isCompleted } 123 124 /// Whether the mutator is read-only (access revoked, unsupported protocol, 125 /// completed) or a sticky, input-blocking announcement is showing. Greys 126 /// out the custom keyboard, drops the hardware keys that write to the grid 127 /// (navigation keys still move the cursor), and disables the toolbar's 128 /// editing menus. Reads the mutator's own predicate so the block holds 129 /// even if the matching banner was never posted. 130 private var isInputBlocked: Bool { 131 !session.mutator.isEditable 132 || announcements.isInputBlocked(forGame: session.mutator.gameID) 133 } 134 135 private var shouldAutoRevealScoreboard: Bool { 136 onNudge != nil && !isSolved && roster.entries.contains(where: { !$0.isLocal }) 137 } 138 139 var body: some View { 140 Group { 141 switch effectivePadLayout { 142 case .landscape: 143 landscapePadLayout 144 case .portrait: 145 portraitPadLayout 146 case .none: 147 phoneLayout 148 } 149 } 150 .background(Color(.systemBackground)) 151 .background { 152 // Yields first responder during rebus so the focused rebus field 153 // owns input (including hardware keys) and the system keyboard rises. 154 HardwareKeyboardInputView( 155 onPress: handleHardwareKeyboardEvent, 156 isActive: !session.isRebusActive 157 ) 158 .frame(width: 0, height: 0) 159 .allowsHitTesting(false) 160 } 161 .ignoresSafeArea(.keyboard) 162 .onChange(of: session.isRebusActive) { _, active in 163 isRebusFieldFocused = active 164 } 165 .onChange(of: isRebusFieldFocused) { _, focused in 166 // The player dismissed the keyboard (swipe-down / hardware Esc): 167 // treat it as a commit, matching the scrim tap. 168 if !focused, session.isRebusActive { 169 session.commitRebus() 170 } 171 } 172 .modifier(PuzzleToolbarModifier( 173 session: session, 174 roster: roster, 175 shareController: shareController, 176 isSolved: isSolved, 177 isEditingBlocked: isSolved || isInputBlocked, 178 canResign: onResign != nil, 179 canDelete: onDelete != nil, 180 onNudge: onNudge, 181 nudgeReadyAt: nudgeReadyAt, 182 isRenaming: $isRenaming, 183 renameDraft: $renameDraft, 184 isConfirmingResign: $isConfirmingResign, 185 isConfirmingDelete: $isConfirmingDelete, 186 isConfirmingLeave: $isConfirmingLeave, 187 revealConfirmation: revealConfirmation, 188 isConfirmingClear: $isConfirmingClear, 189 isShowingShareSheet: $isShowingShareSheet 190 )) 191 .modifier(PuzzleLifecycleModifier( 192 session: session, 193 roster: roster, 194 hasSolved: $hasSolved, 195 onCompletionEvent: handleCompletionEvent, 196 onSolvedOnAppear: { 197 onComplete?(false) 198 } 199 )) 200 .modifier(PuzzlePresentationModifier( 201 session: session, 202 shareController: shareController, 203 isRenaming: $isRenaming, 204 renameDraft: $renameDraft, 205 showErrorsAlert: $showErrorsAlert, 206 isConfirmingResign: $isConfirmingResign, 207 isConfirmingDelete: $isConfirmingDelete, 208 isConfirmingLeave: $isConfirmingLeave, 209 revealConfirmation: revealConfirmation, 210 isConfirmingClear: $isConfirmingClear, 211 leaveError: $leaveError, 212 destructiveActionError: $destructiveActionError, 213 isShowingShareSheet: $isShowingShareSheet, 214 performResign: performResign, 215 performDelete: performDelete, 216 leaveSharedGame: leaveSharedGame 217 )) 218 // Surfaces the puzzle's actions to the app-level menu so the hold-⌘ 219 // shortcut overlay lists them; reveal routes through the same 220 // confirmation alert the toolbar uses. 221 .focusedSceneValue(\.puzzleActions, PuzzleActionTarget( 222 session: session, 223 revealConfirmation: revealConfirmation, 224 isEnabled: !isSolved && !isInputBlocked 225 )) 226 .frame( 227 width: scenePhase == .active || lastActiveSize == .zero ? nil : lastActiveSize.width, 228 height: scenePhase == .active || lastActiveSize == .zero ? nil : lastActiveSize.height 229 ) 230 .onGeometryChange(for: CGSize.self) { proxy in 231 proxy.size 232 } action: { newSize in 233 if UIApplication.shared.applicationState == .active { 234 lastActiveSize = newSize 235 } 236 updateLayoutTrait(for: newSize) 237 } 238 .onChange(of: scenePhase) { _, newPhase in 239 // On return to the foreground, re-derive the layout from the last 240 // size seen while active — never from a backgrounded snapshot pass. 241 if newPhase == .active { 242 updateLayoutTrait(for: lastActiveSize, force: true) 243 } 244 } 245 .onAppear { 246 session.onRecentChangesAcknowledged = markPuzzleViewed 247 } 248 .task(id: session.mutator.gameID) { 249 announcePuzzleIntroIfNeeded() 250 // The shared open beat. A short hold lets the puzzle settle and the 251 // on-open sync land; then we arm the banner and capture — once — 252 // which cells a peer changed while we were away, so both reveal 253 // together. Moves that arrive after this are live activity (peer 254 // cursor tints), not part of the away-summary. 255 isArmed = false 256 try? await Task.sleep(for: .milliseconds(750)) 257 isArmed = true 258 if session.mutator.isShared { 259 session.recentChanges = loadRecentChanges() 260 } 261 } 262 } 263 264 private func announcePuzzleIntroIfNeeded() { 265 guard isVoiceOverEnabled, 266 announcedIntroGameID != session.mutator.gameID 267 else { return } 268 announcedIntroGameID = session.mutator.gameID 269 AccessibilityNotification.Announcement(puzzleIntroAnnouncement).post() 270 } 271 272 private var puzzleIntroAnnouncement: String { 273 var parts = [ 274 titleParts.title, 275 "\(session.puzzle.width) by \(session.puzzle.height) puzzle" 276 ] 277 if let shared = sharedPlayersAnnouncement { 278 parts.append(shared) 279 } 280 return parts.joined(separator: ". ") 281 } 282 283 private var sharedPlayersAnnouncement: String? { 284 let names = roster.entries 285 .filter { !$0.isLocal } 286 .map(\.name) 287 .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } 288 guard !names.isEmpty else { return nil } 289 return "Shared with \(Self.formattedList(names))" 290 } 291 292 private static func formattedList(_ items: [String]) -> String { 293 switch items.count { 294 case 0: 295 return "" 296 case 1: 297 return items[0] 298 case 2: 299 return "\(items[0]) and \(items[1])" 300 default: 301 let prefix = items.dropLast().joined(separator: ", ") 302 return "\(prefix), and \(items[items.count - 1])" 303 } 304 } 305 306 private var phoneLayout: some View { 307 puzzleArea() 308 // The custom keyboard must sit above the home-indicator area. A 309 // bottom safe-area inset both reserves its actual height from the 310 // grid and keeps every key reachable on iOS 27, whose bottom inset 311 // otherwise makes the fixed-height panel extend off-screen. 312 .safeAreaInset(edge: .bottom, spacing: 0) { 313 controlsArea(showClueBar: true) 314 } 315 } 316 317 private var landscapePadLayout: some View { 318 VStack(spacing: 0) { 319 HStack(spacing: 0) { 320 VStack(spacing: 0) { 321 if !isSolved { 322 PuzzleScoreboard( 323 session: session, 324 roster: roster, 325 onNudge: onNudge, 326 nudgeReadyAt: nudgeReadyAt 327 ) 328 329 Divider() 330 } 331 332 ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) 333 } 334 .frame(minWidth: 300, idealWidth: 360, maxWidth: 420) 335 .background(Color(.secondarySystemBackground)) 336 337 Divider() 338 .ignoresSafeArea(edges: .top) 339 340 puzzleArea(bottomInset: 12) 341 .frame(maxWidth: .infinity, maxHeight: .infinity) 342 } 343 .frame(maxWidth: .infinity, maxHeight: .infinity) 344 345 controlsArea(showClueBar: false) 346 } 347 } 348 349 private var portraitPadLayout: some View { 350 VStack(spacing: 0) { 351 WeightedVStack(weights: [3, 1]) { 352 puzzleArea(bottomInset: 12) 353 .frame(maxWidth: .infinity, maxHeight: .infinity) 354 355 VStack(spacing: 0) { 356 Divider() 357 358 HStack(alignment: .top, spacing: 0) { 359 if !isSolved { 360 PuzzleScoreboard( 361 session: session, 362 roster: roster, 363 onNudge: onNudge, 364 nudgeReadyAt: nudgeReadyAt 365 ) 366 .frame(minWidth: 240, idealWidth: 280, maxWidth: 320) 367 368 Divider() 369 } 370 371 ClueList(session: session, presentation: .sidebar, replayFrame: replay.frame) 372 .frame(maxWidth: .infinity, maxHeight: .infinity) 373 } 374 .background(Color(.secondarySystemBackground)) 375 } 376 } 377 .frame(maxWidth: .infinity, maxHeight: .infinity) 378 379 controlsArea(showClueBar: false) 380 } 381 } 382 383 private func updateLayoutTrait(for size: CGSize, force: Bool = false) { 384 // Ignore geometry reported while the app is backgrounded: the system 385 // lays the scene out at both orientations to render app-switcher 386 // snapshots, and reacting to those would flip `padLayout` and rebuild 387 // the puzzle subtree — losing the Clue List's scroll position. A forced 388 // re-apply on return to active handles a genuine rotation made while 389 // away. 390 guard force || UIApplication.shared.applicationState == .active else { 391 return 392 } 393 let newLayout: PadLayout? 394 if UIDevice.current.userInterfaceIdiom == .pad { 395 if Self.activeWindowSceneOrientation?.isLandscape == true { 396 newLayout = .landscape 397 } else { 398 newLayout = size.width > size.height ? .landscape : .portrait 399 } 400 } else { 401 newLayout = nil 402 } 403 // Dedupe so re-applying the same value on foreground doesn't needlessly 404 // invalidate the layout. 405 if newLayout != padLayout { 406 padLayout = newLayout 407 } 408 } 409 410 private static var activeWindowSceneOrientation: UIInterfaceOrientation? { 411 UIApplication.shared.connectedScenes 412 .compactMap { $0 as? UIWindowScene } 413 .first { $0.activationState == .foregroundActive }? 414 .interfaceOrientation 415 } 416 417 private func performResign() { 418 do { 419 try onResign?() 420 dismiss() 421 } catch { 422 destructiveActionError = String(describing: error) 423 } 424 } 425 426 private func performDelete() { 427 do { 428 try onDelete?() 429 dismiss() 430 } catch { 431 destructiveActionError = String(describing: error) 432 } 433 } 434 435 private func handleCompletionEvent(_ event: PlayerSession.CompletionEvent) { 436 switch (event.origin, event.state) { 437 case (_, .incomplete): 438 break 439 case (.observed, .filledWithErrors): 440 // A collaborator's wrong entry must not interrupt the local solver. 441 break 442 case (.local, .filledWithErrors): 443 showErrorsAlert = true 444 case (.local, .solved): 445 guard !hasSolved else { return } 446 hasSolved = true 447 if session.isPencilMode { 448 session.togglePencil() 449 } 450 Task { @MainActor in 451 onComplete?(true) 452 } 453 case (.observed, .solved): 454 guard !hasSolved else { return } 455 hasSolved = true 456 onComplete?(false) 457 } 458 } 459 460 private func puzzleArea(bottomInset: CGFloat = 0) -> some View { 461 ZStack { 462 VStack(spacing: 4) { 463 PuzzleHeader( 464 session: session, 465 roster: roster, 466 title: titleParts.title, 467 subtitle: titleParts.subtitle, 468 showsScoreboard: effectivePadLayout == nil, 469 shouldAutoRevealScoreboard: shouldAutoRevealScoreboard, 470 gameID: session.mutator.gameID, 471 isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true, 472 onNudge: onNudge, 473 nudgeReadyAt: nudgeReadyAt, 474 isArmed: isArmed 475 ) 476 GridView( 477 session: session, 478 roster: roster, 479 revealConfirmation: revealConfirmation, 480 showsSharedAnnotations: session.mutator.showsPlayerAttribution, 481 showsPeerCursors: !isSolved, 482 replayFrame: replay.frame 483 ) 484 } 485 .frame(maxWidth: .infinity, maxHeight: .infinity) 486 .padding(.top, -8) 487 // Keep the gap above the clue list inside the ZStack so the rebus 488 // scrim (a sibling below) covers it too, rather than leaving a strip 489 // of background showing between the grid and the clue list. 490 .padding(.bottom, bottomInset) 491 492 if session.isRebusActive { 493 Color.black.opacity(0.35) 494 // Ignore the bottom edge too: with a hardware keyboard there 495 // is no software keyboard covering the bottom safe area, so a 496 // top-only scrim leaves an un-dimmed strip at the screen edge. 497 .ignoresSafeArea(edges: [.top, .bottom]) 498 .contentShape(Rectangle()) 499 .onTapGesture { 500 session.commitRebus() 501 } 502 // No swallow gesture here: the card's opaque background already 503 // blocks taps from reaching the commit scrim beneath, and adding 504 // a tap gesture in front would steal the field's own taps, 505 // limiting the caret to the start/end of the buffer. 506 RebusModal(text: $session.rebusBuffer, isFocused: $isRebusFieldFocused) { 507 session.commitRebus() 508 } 509 .padding(.horizontal) 510 } 511 } 512 } 513 514 private func controlsArea(showClueBar: Bool) -> some View { 515 VStack(spacing: 0) { 516 if showClueBar { 517 ClueBarSlot(session: session, replayFrame: replay.frame) 518 } 519 controlsPanel 520 .frame(height: controlsPanelHeight) 521 } 522 } 523 524 private var controlsPanel: some View { 525 ZStack(alignment: .top) { 526 if isSolved { 527 ControlsView(height: controlsPanelHeight) { 528 SuccessPanel( 529 session: session, 530 roster: roster, 531 replay: replay, 532 loadReplay: loadReplay 533 ) 534 } 535 .transition(.move(edge: .bottom)) 536 } else if showsCustomKeyboard { 537 ControlsView(height: controlsPanelHeight) { 538 KeyboardView(session: session, showsNavigationKeys: effectivePadLayout != nil) 539 .opacity(isInputBlocked ? 0.4 : 1) 540 .allowsHitTesting(!isInputBlocked) 541 .animation(.easeInOut(duration: 0.3), value: isInputBlocked) 542 } 543 .transition(.move(edge: .bottom)) 544 } 545 } 546 .frame(height: controlsPanelHeight, alignment: .top) 547 .background { 548 Color(.systemGroupedBackground) 549 .ignoresSafeArea(edges: .bottom) 550 } 551 .overlay(alignment: .top) { 552 if controlsPanelHeight > 0 { 553 Rectangle() 554 .fill(Color(.opaqueSeparator)) 555 .frame(height: 0.5) 556 } 557 } 558 .animation(.easeOut(duration: 0.25), value: isSolved) 559 } 560 561 private var controlsPanelHeight: CGFloat { 562 isSolved || showsCustomKeyboard ? KeyboardView.standardHeight : 0 563 } 564 565 private var showsCustomKeyboard: Bool { 566 !inputMonitor.isConnected 567 } 568 569 private func handleHardwareKeyboardEvent(_ event: HardwareKeyboardEvent) -> Bool { 570 // Blocks the keys that write to the grid, not the ones that move the 571 // cursor: a finished (or read-only) puzzle still lets a player tap 572 // their way around the grid, so the hardware arrows, Tab and the 573 // direction toggles stay live to match. 574 let isEntryBlocked = isSolved || isInputBlocked 575 576 // Undo/redo (⌘Z, ⇧⌘Z) live in the app menu (see PuzzleCommands) so they 577 // appear in the hold-⌘ shortcut overlay. A ⌘-modified Z falls through 578 // the letter case below (which rejects modifiers) and bubbles up to that 579 // menu command. 580 switch event.keyCode { 581 case .keyboardA, .keyboardB, .keyboardC, .keyboardD, .keyboardE, 582 .keyboardF, .keyboardG, .keyboardH, .keyboardI, .keyboardJ, 583 .keyboardK, .keyboardL, .keyboardM, .keyboardN, .keyboardO, 584 .keyboardP, .keyboardQ, .keyboardR, .keyboardS, .keyboardT, 585 .keyboardU, .keyboardV, .keyboardW, .keyboardX, .keyboardY, 586 .keyboardZ, 587 .keyboard0, .keyboard1, .keyboard2, .keyboard3, .keyboard4, 588 .keyboard5, .keyboard6, .keyboard7, .keyboard8, .keyboard9: 589 guard !isEntryBlocked, 590 !event.modifierFlags.contains(.command), 591 !event.modifierFlags.contains(.control), 592 !event.modifierFlags.contains(.alternate), 593 let character = hardwareKeyboardCharacter(from: event) else { 594 return false 595 } 596 if session.isRebusActive { 597 session.appendRebusLetter(character) 598 } else { 599 session.enter(character) 600 } 601 return true 602 603 case .keyboardDeleteOrBackspace, .keyboardDeleteForward: 604 guard !isEntryBlocked else { return false } 605 if session.isRebusActive { 606 session.deleteRebusLetter() 607 } else { 608 session.deleteBackward() 609 } 610 return true 611 612 case .keyboardLeftArrow: 613 guard !session.isRebusActive else { return false } 614 if event.modifierFlags.contains(.command) { 615 session.goToPreviousWord() 616 return true 617 } 618 moveWithHardwareArrow(direction: .across) { 619 session.goToPreviousLetter() 620 } 621 return true 622 623 case .keyboardRightArrow: 624 guard !session.isRebusActive else { return false } 625 if event.modifierFlags.contains(.command) { 626 session.goToNextWord() 627 return true 628 } 629 moveWithHardwareArrow(direction: .across) { 630 session.goToNextLetter() 631 } 632 return true 633 634 case .keyboardUpArrow: 635 guard !session.isRebusActive else { return false } 636 moveWithHardwareArrow(direction: .down) { 637 session.goToPreviousLetter() 638 } 639 return true 640 641 case .keyboardDownArrow: 642 guard !session.isRebusActive else { return false } 643 moveWithHardwareArrow(direction: .down) { 644 session.goToNextLetter() 645 } 646 return true 647 648 case .keyboardTab: 649 guard !session.isRebusActive else { return false } 650 if event.modifierFlags.contains(.shift) { 651 session.goToPreviousClue() 652 } else { 653 session.goToNextClue() 654 } 655 return true 656 657 case .keyboardSpacebar: 658 guard !session.isRebusActive else { return false } 659 session.toggleDirection() 660 return true 661 662 case .keyboardReturnOrEnter: 663 if session.isRebusActive { 664 session.commitRebus() 665 } else { 666 session.toggleDirection() 667 } 668 return true 669 670 case .keyboardEscape: 671 if session.isRebusActive { 672 session.commitRebus() 673 return true 674 } 675 return false 676 677 default: 678 return false 679 } 680 } 681 682 private func hardwareKeyboardCharacter(from event: HardwareKeyboardEvent) -> String? { 683 let scalars = event.charactersIgnoringModifiers.unicodeScalars 684 guard scalars.count == 1, let scalar = scalars.first else { return nil } 685 686 switch scalar.value { 687 case 65...90, 97...122: // A–Z, a–z 688 return String(Character(scalar)).uppercased() 689 case 48...57: // 0–9 690 return String(Character(scalar)) 691 default: 692 return nil 693 } 694 } 695 696 private func moveWithHardwareArrow(direction: Puzzle.Direction, move: () -> Void) { 697 if session.direction != direction { 698 let previousDirection = session.direction 699 session.setDirection(direction) 700 if session.direction != previousDirection { 701 return 702 } 703 } 704 705 move() 706 } 707 708 private func leaveSharedGame() async { 709 guard let shareController else { return } 710 do { 711 try await shareController.leaveShare(gameID: session.mutator.gameID) 712 dismiss() 713 } catch { 714 leaveError = String(describing: error) 715 } 716 } 717 } 718 719 private struct RebusModal: View { 720 @Binding var text: String 721 var isFocused: FocusState<Bool>.Binding 722 let onCommit: () -> Void 723 724 var body: some View { 725 styledField 726 .accessibilityLabel("Rebus entry") 727 .accessibilityValue(accessibilityValue) 728 .accessibilityHint("Enter the full text for this square") 729 .accessibilityAction(.default, onCommit) 730 } 731 732 private var styledField: some View { 733 // An editable field styled to read as the centred display card: the 734 // system keyboard drives entry (so symbols, accents, and emoji are all 735 // reachable) while the look and placement match the prior read-only modal. 736 TextField("", text: $text) 737 .focused(isFocused) 738 .keyboardType(.asciiCapable) 739 .textInputAutocapitalization(.characters) 740 // .textInputAutocapitalization only sets the software keyboard's 741 // shift state; hardware-keyboard input arrives as typed. Uppercase 742 // the buffer directly so rebus fills are capitalised either way, 743 // matching the custom-keyboard path in appendRebusLetter. 744 .onChange(of: text) { _, newValue in 745 let upper = newValue.uppercased() 746 if upper != newValue { text = upper } 747 } 748 .autocorrectionDisabled() 749 .submitLabel(.done) 750 .onSubmit(onCommit) 751 .multilineTextAlignment(.center) 752 .font(.system(size: 32, weight: .semibold, design: .rounded)) 753 .foregroundStyle(.primary) 754 .frame(maxWidth: .infinity, minHeight: 56) 755 .padding(.horizontal, 16) 756 .background(Color(.systemBackground)) 757 .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) 758 .padding(20) 759 .frame(maxWidth: .infinity) 760 .background(Color(.secondarySystemBackground)) 761 .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) 762 } 763 764 private var accessibilityValue: String { 765 text.isEmpty ? "Empty" : text 766 } 767 } 768 769 private struct ControlsView<Content: View>: View { 770 let height: CGFloat 771 @ViewBuilder var content: () -> Content 772 773 var body: some View { 774 content() 775 .frame(height: height) 776 } 777 }