crossmate

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

KeyboardView.swift (19721B)


      1 import SwiftUI
      2 
      3 /// Custom on-screen keyboard. We use a hand-rolled keyboard rather than the
      4 /// system keyboard because crossword input is single-character and needs to
      5 /// stay glued to the bottom of the screen alongside the grid.
      6 struct KeyboardView: View {
      7     @Bindable var session: PlayerSession
      8     var showsNavigationKeys = false
      9 
     10     /// Whether the secondary numbers/symbols layer is showing in place of the
     11     /// letters. Reached via the `…` key and dismissed via `ABC`; sticky across
     12     /// keystrokes (crossword input isn't predominantly numeric, so we don't
     13     /// auto-revert). Stays available during rebus entry so mixed fills like
     14     /// `H2O` can be typed.
     15     #if DEBUG
     16     @State private var showsSymbols = ProcessInfo.processInfo.arguments.contains(
     17         "--crossmate-marketing-symbol-keyboard"
     18     )
     19     #else
     20     @State private var showsSymbols = false
     21     #endif
     22 
     23     private let topRow = Array("QWERTYUIOP").map(String.init)
     24     private let middleRow = Array("ASDFGHJKL").map(String.init)
     25     private let bottomLetters = Array("ZXCVBNM").map(String.init)
     26     private let digitRow = Array("1234567890").map(String.init)
     27     // The nine symbols on the NYT Games keyboard's middle row. NYT shows a
     28     // further row (' ` , . : /) we omit: a grid fill that isn't a letter is
     29     // almost always a digit, with `&` the only symbol seen in practice, so the
     30     // omitted ones are not expected to appear in any puzzle.
     31     private let symbolRow = ["@", "#", "$", "%", "&", "*", "-", "!", "+"]
     32 
     33     private let spacing: CGFloat = 6
     34     private let keyHeight: CGFloat = 46
     35     private let metaKeyWidthMultiplier: CGFloat = 1.5
     36 
     37     static let standardHeight: CGFloat = 170
     38 
     39     var body: some View {
     40         Group {
     41             if showsSymbols {
     42                 symbolRows
     43             } else {
     44                 letterRows
     45             }
     46         }
     47         .padding(.horizontal, 4)
     48         .padding(.top, 12)
     49         .padding(.bottom, 8)
     50         .accessibilityLabel("Crossword Keyboard")
     51         .accessibilityHint("Double-tap to type by touching keys directly. Language switching and dictation controls are not shown.")
     52         .accessibilityDirectTouch(options: .requiresActivation)
     53     }
     54 
     55     private var letterRows: some View {
     56         VStack(spacing: spacing) {
     57             KeyboardRow(
     58                 referenceColumns: showsNavigationKeys ? 12 : 10,
     59                 spacing: spacing,
     60                 keyHeight: keyHeight
     61             ) {
     62                 if showsNavigationKeys {
     63                     actionKey(systemImage: "chevron.left", accessibilityLabel: "Previous Word") {
     64                         session.goToPreviousWord()
     65                     }
     66                     .fillsExtraKeyboardSpace()
     67                 }
     68                 ForEach(topRow, id: \.self) { letter in
     69                     letterKey(letter)
     70                 }
     71                 if showsNavigationKeys {
     72                     actionKey(systemImage: "chevron.right", accessibilityLabel: "Next Word") {
     73                         session.goToNextWord()
     74                     }
     75                     .fillsExtraKeyboardSpace()
     76                 }
     77             }
     78             KeyboardRow(
     79                 referenceColumns: showsNavigationKeys ? 12 : 10,
     80                 spacing: spacing,
     81                 keyHeight: keyHeight
     82             ) {
     83                 if showsNavigationKeys {
     84                     actionKey(systemImage: "arrowtriangle.left.fill", accessibilityLabel: "Previous Letter") {
     85                         session.goToPreviousLetter()
     86                     }
     87                     .fillsExtraKeyboardSpace()
     88                 }
     89                 ForEach(middleRow, id: \.self) { letter in
     90                     letterKey(letter)
     91                 }
     92                 if showsNavigationKeys {
     93                     actionKey(systemImage: "arrowtriangle.right.fill", accessibilityLabel: "Next Letter") {
     94                         session.goToNextLetter()
     95                     }
     96                     .fillsExtraKeyboardSpace()
     97                 }
     98             }
     99             KeyboardRow(
    100                 referenceColumns: showsNavigationKeys ? 12 : 10,
    101                 spacing: spacing,
    102                 keyHeight: keyHeight
    103             ) {
    104                 if showsNavigationKeys {
    105                     layerToggleKey
    106                         .disablesKeyboardMetaAnimations()
    107                         .fillsExtraKeyboardSpace()
    108 
    109                     actionKey(
    110                         systemImage: "pencil",
    111                         accessibilityLabel: session.isPencilMode ? "Turn Off Draft" : "Turn On Draft",
    112                         background: session.isPencilMode ? .blue : Color(.systemFill),
    113                         foreground: session.isPencilMode ? .white : .primary
    114                     ) {
    115                         session.togglePencil()
    116                     }
    117                     .disablesKeyboardMetaAnimations()
    118                     .fillsExtraKeyboardSpace()
    119                 } else {
    120                     layerToggleKey
    121                         .disablesKeyboardMetaAnimations()
    122                         .keyWidthMultiplier(metaKeyWidthMultiplier)
    123                 }
    124 
    125                 ForEach(bottomLetters, id: \.self) { letter in
    126                     letterKey(letter)
    127                 }
    128 
    129                 if showsNavigationKeys {
    130                     actionKey(
    131                         systemImage: "arrow.2.squarepath",
    132                         accessibilityLabel: "Switch Direction"
    133                     ) {
    134                         session.toggleDirection()
    135                     }
    136                     .disablesKeyboardMetaAnimations()
    137                     .fillsExtraKeyboardSpace()
    138                 }
    139 
    140                 deleteKey
    141                     .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier)
    142                     .fillsExtraKeyboardSpace(showsNavigationKeys)
    143             }
    144         }
    145     }
    146 
    147     /// The secondary numbers/symbols layer. It mirrors the letters layer's
    148     /// scaffolding exactly — the `…` toggle, Delete, and (on iPad) the
    149     /// navigation arrows / pencil / switch-direction keys keep their position
    150     /// and width — and only swaps the *centre* keys: digits on the top row,
    151     /// symbols on the middle row, and the Undo / Rebus / Redo meta keys on the
    152     /// bottom row. The digit/symbol keys reuse `letterKey`, so they append to
    153     /// the rebus buffer during entry and enter directly otherwise. The three
    154     /// meta keys take the same column budget the seven letters they replace had,
    155     /// so `…` and Delete don't change size between layers.
    156     private var symbolRows: some View {
    157         VStack(spacing: spacing) {
    158             KeyboardRow(
    159                 referenceColumns: showsNavigationKeys ? 12 : 10,
    160                 spacing: spacing,
    161                 keyHeight: keyHeight
    162             ) {
    163                 if showsNavigationKeys {
    164                     actionKey(systemImage: "chevron.left", accessibilityLabel: "Previous Word") {
    165                         session.goToPreviousWord()
    166                     }
    167                     .fillsExtraKeyboardSpace()
    168                 }
    169                 ForEach(digitRow, id: \.self) { letterKey($0) }
    170                 if showsNavigationKeys {
    171                     actionKey(systemImage: "chevron.right", accessibilityLabel: "Next Word") {
    172                         session.goToNextWord()
    173                     }
    174                     .fillsExtraKeyboardSpace()
    175                 }
    176             }
    177             KeyboardRow(
    178                 referenceColumns: showsNavigationKeys ? 12 : 10,
    179                 spacing: spacing,
    180                 keyHeight: keyHeight
    181             ) {
    182                 if showsNavigationKeys {
    183                     actionKey(systemImage: "arrowtriangle.left.fill", accessibilityLabel: "Previous Letter") {
    184                         session.goToPreviousLetter()
    185                     }
    186                     .fillsExtraKeyboardSpace()
    187                 }
    188                 ForEach(symbolRow, id: \.self) { letterKey($0) }
    189                 if showsNavigationKeys {
    190                     actionKey(systemImage: "arrowtriangle.right.fill", accessibilityLabel: "Next Letter") {
    191                         session.goToNextLetter()
    192                     }
    193                     .fillsExtraKeyboardSpace()
    194                 }
    195             }
    196             KeyboardRow(
    197                 referenceColumns: showsNavigationKeys ? 12 : 10,
    198                 spacing: spacing,
    199                 keyHeight: keyHeight
    200             ) {
    201                 if showsNavigationKeys {
    202                     layerToggleKey
    203                         .disablesKeyboardMetaAnimations()
    204                         .fillsExtraKeyboardSpace()
    205 
    206                     actionKey(
    207                         systemImage: "pencil",
    208                         accessibilityLabel: session.isPencilMode ? "Turn Off Draft" : "Turn On Draft",
    209                         background: session.isPencilMode ? .blue : Color(.systemFill),
    210                         foreground: session.isPencilMode ? .white : .primary
    211                     ) {
    212                         session.togglePencil()
    213                     }
    214                     .disablesKeyboardMetaAnimations()
    215                     .fillsExtraKeyboardSpace()
    216                 } else {
    217                     layerToggleKey
    218                         .disablesKeyboardMetaAnimations()
    219                         .keyWidthMultiplier(metaKeyWidthMultiplier)
    220                 }
    221 
    222                 undoKey
    223                     .keyWidthMultiplier(metaKeyWidthMultiplier)
    224                 rebusKey
    225                     .disablesKeyboardMetaAnimations()
    226                     .keyWidthMultiplier(rebusKeyWidthMultiplier)
    227                     // The bottom row has four fewer inter-key gaps than the
    228                     // letters row (three meta keys replace seven letters). That
    229                     // shortfall otherwise pulls the flanking keys inward — `…`
    230                     // and Delete on compact, and the flex keys' shared width on
    231                     // iPad. Giving Rebus the missing gap width makes the row's
    232                     // total identical to the letters row, so those keys don't
    233                     // move between layers on either device.
    234                     .extraGapWidth(rebusExtraGapWidth)
    235                 redoKey
    236                     .keyWidthMultiplier(metaKeyWidthMultiplier)
    237 
    238                 if showsNavigationKeys {
    239                     actionKey(
    240                         systemImage: "arrow.2.squarepath",
    241                         accessibilityLabel: "Switch Direction"
    242                     ) {
    243                         session.toggleDirection()
    244                     }
    245                     .disablesKeyboardMetaAnimations()
    246                     .fillsExtraKeyboardSpace()
    247                 }
    248 
    249                 deleteKey
    250                     .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier)
    251                     .fillsExtraKeyboardSpace(showsNavigationKeys)
    252             }
    253         }
    254     }
    255 
    256     /// Width (in base key columns) for the Rebus key on the secondary layer's
    257     /// bottom row. Undo and Redo match the `…` and Delete meta-key width; Rebus
    258     /// absorbs the remainder of the column budget the seven `ZXCVBNM` keys
    259     /// occupy on the letters layer, so the surrounding `…` and Delete keys keep
    260     /// their letters-layer width.
    261     private var rebusKeyWidthMultiplier: CGFloat {
    262         CGFloat(bottomLetters.count) - 2 * metaKeyWidthMultiplier
    263     }
    264 
    265     /// Extra gap widths the Rebus key takes so the meta row totals the same
    266     /// width as the letters row — the difference in their inter-key gap counts.
    267     /// The two rows share scaffolding and differ only in the centre (three meta
    268     /// keys vs the seven `bottomLetters`), so the gap shortfall is the same on
    269     /// both layouts.
    270     private var rebusExtraGapWidth: CGFloat {
    271         CGFloat(bottomLetters.count - 3)
    272     }
    273 
    274     private func letterKey(_ letter: String) -> some View {
    275         Button {
    276             if session.isRebusActive {
    277                 session.appendRebusLetter(letter)
    278             } else {
    279                 session.enter(letter)
    280             }
    281         } label: {
    282             Text(letter)
    283                 .font(.system(size: 22, weight: .medium, design: .rounded))
    284                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    285                 .background(Color(.tertiarySystemBackground))
    286                 .foregroundStyle(.primary)
    287                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    288         }
    289         .buttonStyle(.plain)
    290     }
    291 
    292     private var rebusKey: some View {
    293         Group {
    294             if session.isRebusActive {
    295                 actionKey(text: "Done", background: .blue, foreground: .white) {
    296                     session.commitRebus()
    297                 }
    298             } else {
    299                 actionKey(text: "Rebus") {
    300                     session.startRebus()
    301                 }
    302             }
    303         }
    304     }
    305 
    306     /// The `…` key. It swaps between the letters and the numbers/symbols layer,
    307     /// keeping the same icon and position on both so it reads as one persistent
    308     /// control. While the symbols layer is showing the key inverts to a dark grey
    309     /// chip with a light glyph — the iOS Shift-key idiom — to signal it's the
    310     /// active mode. There's no room for an `ABC` label, and grey (rather than the
    311     /// pencil key's blue) keeps it from reading as a primary action. Available
    312     /// during rebus entry too, so mixed fills can be typed.
    313     private var layerToggleKey: some View {
    314         actionKey(
    315             systemImage: "ellipsis",
    316             accessibilityLabel: showsSymbols ? "Letters" : "Numbers and Symbols",
    317             background: showsSymbols ? Color(.secondaryLabel) : Color(.systemFill),
    318             foreground: showsSymbols ? Color(.systemBackground) : .primary
    319         ) {
    320             showsSymbols.toggle()
    321         }
    322     }
    323 
    324     private var undoKey: some View {
    325         actionKey(systemImage: "arrow.uturn.backward", accessibilityLabel: "Undo Move") {
    326             session.undo()
    327         }
    328         .disabled(!session.canUndo)
    329         .opacity(session.canUndo ? 1 : 0.4)
    330     }
    331 
    332     private var redoKey: some View {
    333         actionKey(systemImage: "arrow.uturn.forward", accessibilityLabel: "Redo Move") {
    334             session.redo()
    335         }
    336         .disabled(!session.canRedo)
    337         .opacity(session.canRedo ? 1 : 0.4)
    338     }
    339 
    340     private var deleteKey: some View {
    341         actionKey(systemImage: "delete.left") {
    342             if session.isRebusActive {
    343                 session.deleteRebusLetter()
    344             } else {
    345                 session.deleteBackward()
    346             }
    347         }
    348         .disablesKeyboardMetaAnimations()
    349     }
    350 
    351     private func actionKey(
    352         systemImage: String,
    353         accessibilityLabel: String? = nil,
    354         background: Color = Color(.systemFill),
    355         foreground: Color = .primary,
    356         action: @escaping () -> Void
    357     ) -> some View {
    358         Button(action: action) {
    359             Image(systemName: systemImage)
    360                 .font(.system(size: 18, weight: .medium))
    361                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    362                 .background(background)
    363                 .foregroundStyle(foreground)
    364                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    365         }
    366         .buttonStyle(.plain)
    367         .accessibilityLabel(accessibilityLabel ?? systemImage)
    368     }
    369 
    370     private func actionKey(
    371         text: String,
    372         background: Color = Color(.systemFill),
    373         foreground: Color = .primary,
    374         action: @escaping () -> Void
    375     ) -> some View {
    376         Button(action: action) {
    377             Text(text)
    378                 .font(.system(size: 16, weight: .semibold, design: .rounded))
    379                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    380                 .background(background)
    381                 .foregroundStyle(foreground)
    382                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    383         }
    384         .buttonStyle(.plain)
    385     }
    386 
    387 }
    388 
    389 // MARK: - Layout
    390 
    391 /// Lays out a row of keys at a fixed key height. Every key is sized as a
    392 /// fraction of `referenceColumns`, so a row containing fewer keys ends up
    393 /// narrower than the reference row and is centered. Action keys can opt into
    394 /// a wider width via `keyWidthMultiplier`, take a fixed number of extra
    395 /// inter-key gap widths via `extraGapWidth` (to compensate for a row having
    396 /// fewer gaps than another), and selected keys can split any leftover row
    397 /// width via `fillsExtraKeyboardSpace`.
    398 private struct KeyboardRow: Layout {
    399     let referenceColumns: Int
    400     let spacing: CGFloat
    401     let keyHeight: CGFloat
    402 
    403     func sizeThatFits(
    404         proposal: ProposedViewSize,
    405         subviews: Subviews,
    406         cache: inout ()
    407     ) -> CGSize {
    408         CGSize(width: proposal.width ?? 0, height: keyHeight)
    409     }
    410 
    411     func placeSubviews(
    412         in bounds: CGRect,
    413         proposal: ProposedViewSize,
    414         subviews: Subviews,
    415         cache: inout ()
    416     ) {
    417         let containerWidth = bounds.width
    418         let columns = CGFloat(referenceColumns)
    419         let baseKeyWidth = (containerWidth - spacing * (columns - 1)) / columns
    420 
    421         let widths = measuredWidths(for: subviews, baseKeyWidth: baseKeyWidth, containerWidth: containerWidth)
    422         let totalWidth = widths.reduce(0, +) + spacing * CGFloat(max(0, subviews.count - 1))
    423 
    424         var x = bounds.minX + (containerWidth - totalWidth) / 2
    425         for (index, subview) in subviews.enumerated() {
    426             let width = widths[index]
    427             subview.place(
    428                 at: CGPoint(x: x, y: bounds.minY),
    429                 anchor: .topLeading,
    430                 proposal: ProposedViewSize(width: width, height: keyHeight)
    431             )
    432             x += width + spacing
    433         }
    434     }
    435 
    436     private func measuredWidths(
    437         for subviews: Subviews,
    438         baseKeyWidth: CGFloat,
    439         containerWidth: CGFloat
    440     ) -> [CGFloat] {
    441         var widths = subviews.map {
    442             baseKeyWidth * $0[KeyWidthMultiplier.self] + spacing * $0[ExtraGapWidth.self]
    443         }
    444         let fixedSpacing = spacing * CGFloat(max(0, subviews.count - 1))
    445         let totalWidth = widths.reduce(0, +) + fixedSpacing
    446         let flexibleIndexes = subviews.indices.filter { subviews[$0][FillsExtraKeyboardSpace.self] }
    447         guard totalWidth < containerWidth, !flexibleIndexes.isEmpty else {
    448             return widths
    449         }
    450 
    451         let extraWidth = (containerWidth - totalWidth) / CGFloat(flexibleIndexes.count)
    452         for index in flexibleIndexes {
    453             widths[index] += extraWidth
    454         }
    455         return widths
    456     }
    457 }
    458 
    459 private struct KeyWidthMultiplier: LayoutValueKey {
    460     static let defaultValue: CGFloat = 1.0
    461 }
    462 
    463 private struct FillsExtraKeyboardSpace: LayoutValueKey {
    464     static let defaultValue = false
    465 }
    466 
    467 /// Extra width, measured in inter-key gap (`spacing`) multiples, added to a key
    468 /// on top of its column width. Used to make a sparse row total the same width as
    469 /// a denser one so their shared edge keys line up.
    470 private struct ExtraGapWidth: LayoutValueKey {
    471     static let defaultValue: CGFloat = 0
    472 }
    473 
    474 private extension View {
    475     func keyWidthMultiplier(_ multiplier: CGFloat) -> some View {
    476         layoutValue(key: KeyWidthMultiplier.self, value: multiplier)
    477     }
    478 
    479     func extraGapWidth(_ gaps: CGFloat) -> some View {
    480         layoutValue(key: ExtraGapWidth.self, value: gaps)
    481     }
    482 
    483     func fillsExtraKeyboardSpace(_ fills: Bool = true) -> some View {
    484         layoutValue(key: FillsExtraKeyboardSpace.self, value: fills)
    485     }
    486 
    487     func disablesKeyboardMetaAnimations() -> some View {
    488         transaction { transaction in
    489             transaction.animation = nil
    490             transaction.disablesAnimations = true
    491         }
    492     }
    493 }