crossmate

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

PuzzleView.swift (29878B)


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