crossmate

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

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 }