crossmate

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

KeyboardView.swift (19605B)


      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                         .keyWidthMultiplier(metaKeyWidthMultiplier)
    122                 }
    123 
    124                 ForEach(bottomLetters, id: \.self) { letter in
    125                     letterKey(letter)
    126                 }
    127 
    128                 if showsNavigationKeys {
    129                     actionKey(
    130                         systemImage: "arrow.2.squarepath",
    131                         accessibilityLabel: "Switch Direction"
    132                     ) {
    133                         session.toggleDirection()
    134                     }
    135                     .disablesKeyboardMetaAnimations()
    136                     .fillsExtraKeyboardSpace()
    137                 }
    138 
    139                 deleteKey
    140                     .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier)
    141                     .fillsExtraKeyboardSpace(showsNavigationKeys)
    142             }
    143         }
    144     }
    145 
    146     /// The secondary numbers/symbols layer. It mirrors the letters layer's
    147     /// scaffolding exactly — the `…` toggle, Delete, and (on iPad) the
    148     /// navigation arrows / pencil / switch-direction keys keep their position
    149     /// and width — and only swaps the *centre* keys: digits on the top row,
    150     /// symbols on the middle row, and the Undo / Rebus / Redo meta keys on the
    151     /// bottom row. The digit/symbol keys reuse `letterKey`, so they append to
    152     /// the rebus buffer during entry and enter directly otherwise. The three
    153     /// meta keys take the same column budget the seven letters they replace had,
    154     /// so `…` and Delete don't change size between layers.
    155     private var symbolRows: some View {
    156         VStack(spacing: spacing) {
    157             KeyboardRow(
    158                 referenceColumns: showsNavigationKeys ? 12 : 10,
    159                 spacing: spacing,
    160                 keyHeight: keyHeight
    161             ) {
    162                 if showsNavigationKeys {
    163                     actionKey(systemImage: "chevron.left", accessibilityLabel: "Previous Word") {
    164                         session.goToPreviousWord()
    165                     }
    166                     .fillsExtraKeyboardSpace()
    167                 }
    168                 ForEach(digitRow, id: \.self) { letterKey($0) }
    169                 if showsNavigationKeys {
    170                     actionKey(systemImage: "chevron.right", accessibilityLabel: "Next Word") {
    171                         session.goToNextWord()
    172                     }
    173                     .fillsExtraKeyboardSpace()
    174                 }
    175             }
    176             KeyboardRow(
    177                 referenceColumns: showsNavigationKeys ? 12 : 10,
    178                 spacing: spacing,
    179                 keyHeight: keyHeight
    180             ) {
    181                 if showsNavigationKeys {
    182                     actionKey(systemImage: "arrowtriangle.left.fill", accessibilityLabel: "Previous Letter") {
    183                         session.goToPreviousLetter()
    184                     }
    185                     .fillsExtraKeyboardSpace()
    186                 }
    187                 ForEach(symbolRow, id: \.self) { letterKey($0) }
    188                 if showsNavigationKeys {
    189                     actionKey(systemImage: "arrowtriangle.right.fill", accessibilityLabel: "Next Letter") {
    190                         session.goToNextLetter()
    191                     }
    192                     .fillsExtraKeyboardSpace()
    193                 }
    194             }
    195             KeyboardRow(
    196                 referenceColumns: showsNavigationKeys ? 12 : 10,
    197                 spacing: spacing,
    198                 keyHeight: keyHeight
    199             ) {
    200                 if showsNavigationKeys {
    201                     layerToggleKey
    202                         .disablesKeyboardMetaAnimations()
    203                         .fillsExtraKeyboardSpace()
    204 
    205                     actionKey(
    206                         systemImage: "pencil",
    207                         accessibilityLabel: session.isPencilMode ? "Turn Off Draft" : "Turn On Draft",
    208                         background: session.isPencilMode ? .blue : Color(.systemFill),
    209                         foreground: session.isPencilMode ? .white : .primary
    210                     ) {
    211                         session.togglePencil()
    212                     }
    213                     .disablesKeyboardMetaAnimations()
    214                     .fillsExtraKeyboardSpace()
    215                 } else {
    216                     layerToggleKey
    217                         .keyWidthMultiplier(metaKeyWidthMultiplier)
    218                 }
    219 
    220                 undoKey
    221                     .keyWidthMultiplier(metaKeyWidthMultiplier)
    222                 rebusKey
    223                     .disablesKeyboardMetaAnimations()
    224                     .keyWidthMultiplier(rebusKeyWidthMultiplier)
    225                     // The bottom row has four fewer inter-key gaps than the
    226                     // letters row (three meta keys replace seven letters). That
    227                     // shortfall otherwise pulls the flanking keys inward — `…`
    228                     // and Delete on compact, and the flex keys' shared width on
    229                     // iPad. Giving Rebus the missing gap width makes the row's
    230                     // total identical to the letters row, so those keys don't
    231                     // move between layers on either device.
    232                     .extraGapWidth(rebusExtraGapWidth)
    233                 redoKey
    234                     .keyWidthMultiplier(metaKeyWidthMultiplier)
    235 
    236                 if showsNavigationKeys {
    237                     actionKey(
    238                         systemImage: "arrow.2.squarepath",
    239                         accessibilityLabel: "Switch Direction"
    240                     ) {
    241                         session.toggleDirection()
    242                     }
    243                     .disablesKeyboardMetaAnimations()
    244                     .fillsExtraKeyboardSpace()
    245                 }
    246 
    247                 deleteKey
    248                     .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier)
    249                     .fillsExtraKeyboardSpace(showsNavigationKeys)
    250             }
    251         }
    252     }
    253 
    254     /// Width (in base key columns) for the Rebus key on the secondary layer's
    255     /// bottom row. Undo and Redo match the `…` and Delete meta-key width; Rebus
    256     /// absorbs the remainder of the column budget the seven `ZXCVBNM` keys
    257     /// occupy on the letters layer, so the surrounding `…` and Delete keys keep
    258     /// their letters-layer width.
    259     private var rebusKeyWidthMultiplier: CGFloat {
    260         CGFloat(bottomLetters.count) - 2 * metaKeyWidthMultiplier
    261     }
    262 
    263     /// Extra gap widths the Rebus key takes so the meta row totals the same
    264     /// width as the letters row — the difference in their inter-key gap counts.
    265     /// The two rows share scaffolding and differ only in the centre (three meta
    266     /// keys vs the seven `bottomLetters`), so the gap shortfall is the same on
    267     /// both layouts.
    268     private var rebusExtraGapWidth: CGFloat {
    269         CGFloat(bottomLetters.count - 3)
    270     }
    271 
    272     private func letterKey(_ letter: String) -> some View {
    273         Button {
    274             if session.isRebusActive {
    275                 session.appendRebusLetter(letter)
    276             } else {
    277                 session.enter(letter)
    278             }
    279         } label: {
    280             Text(letter)
    281                 .font(.system(size: 22, weight: .medium, design: .rounded))
    282                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    283                 .background(Color(.tertiarySystemBackground))
    284                 .foregroundStyle(.primary)
    285                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    286         }
    287         .buttonStyle(.plain)
    288     }
    289 
    290     private var rebusKey: some View {
    291         Group {
    292             if session.isRebusActive {
    293                 actionKey(text: "Done", background: .blue, foreground: .white) {
    294                     session.commitRebus()
    295                 }
    296             } else {
    297                 actionKey(text: "Rebus") {
    298                     session.startRebus()
    299                 }
    300             }
    301         }
    302     }
    303 
    304     /// The `…` key. It swaps between the letters and the numbers/symbols layer,
    305     /// keeping the same icon and position on both so it reads as one persistent
    306     /// control. While the symbols layer is showing the key inverts to a dark grey
    307     /// chip with a light glyph — the iOS Shift-key idiom — to signal it's the
    308     /// active mode. There's no room for an `ABC` label, and grey (rather than the
    309     /// pencil key's blue) keeps it from reading as a primary action. Available
    310     /// during rebus entry too, so mixed fills can be typed.
    311     private var layerToggleKey: some View {
    312         actionKey(
    313             systemImage: "ellipsis",
    314             accessibilityLabel: showsSymbols ? "Letters" : "Numbers and Symbols",
    315             background: showsSymbols ? Color(.secondaryLabel) : Color(.systemFill),
    316             foreground: showsSymbols ? Color(.systemBackground) : .primary
    317         ) {
    318             showsSymbols.toggle()
    319         }
    320     }
    321 
    322     private var undoKey: some View {
    323         actionKey(systemImage: "arrow.uturn.backward", accessibilityLabel: "Undo Move") {
    324             session.undo()
    325         }
    326         .disabled(!session.canUndo)
    327         .opacity(session.canUndo ? 1 : 0.4)
    328     }
    329 
    330     private var redoKey: some View {
    331         actionKey(systemImage: "arrow.uturn.forward", accessibilityLabel: "Redo Move") {
    332             session.redo()
    333         }
    334         .disabled(!session.canRedo)
    335         .opacity(session.canRedo ? 1 : 0.4)
    336     }
    337 
    338     private var deleteKey: some View {
    339         actionKey(systemImage: "delete.left") {
    340             if session.isRebusActive {
    341                 session.deleteRebusLetter()
    342             } else {
    343                 session.deleteBackward()
    344             }
    345         }
    346         .disablesKeyboardMetaAnimations()
    347     }
    348 
    349     private func actionKey(
    350         systemImage: String,
    351         accessibilityLabel: String? = nil,
    352         background: Color = Color(.systemFill),
    353         foreground: Color = .primary,
    354         action: @escaping () -> Void
    355     ) -> some View {
    356         Button(action: action) {
    357             Image(systemName: systemImage)
    358                 .font(.system(size: 18, weight: .medium))
    359                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    360                 .background(background)
    361                 .foregroundStyle(foreground)
    362                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    363         }
    364         .buttonStyle(.plain)
    365         .accessibilityLabel(accessibilityLabel ?? systemImage)
    366     }
    367 
    368     private func actionKey(
    369         text: String,
    370         background: Color = Color(.systemFill),
    371         foreground: Color = .primary,
    372         action: @escaping () -> Void
    373     ) -> some View {
    374         Button(action: action) {
    375             Text(text)
    376                 .font(.system(size: 16, weight: .semibold, design: .rounded))
    377                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    378                 .background(background)
    379                 .foregroundStyle(foreground)
    380                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    381         }
    382         .buttonStyle(.plain)
    383     }
    384 
    385 }
    386 
    387 // MARK: - Layout
    388 
    389 /// Lays out a row of keys at a fixed key height. Every key is sized as a
    390 /// fraction of `referenceColumns`, so a row containing fewer keys ends up
    391 /// narrower than the reference row and is centered. Action keys can opt into
    392 /// a wider width via `keyWidthMultiplier`, take a fixed number of extra
    393 /// inter-key gap widths via `extraGapWidth` (to compensate for a row having
    394 /// fewer gaps than another), and selected keys can split any leftover row
    395 /// width via `fillsExtraKeyboardSpace`.
    396 private struct KeyboardRow: Layout {
    397     let referenceColumns: Int
    398     let spacing: CGFloat
    399     let keyHeight: CGFloat
    400 
    401     func sizeThatFits(
    402         proposal: ProposedViewSize,
    403         subviews: Subviews,
    404         cache: inout ()
    405     ) -> CGSize {
    406         CGSize(width: proposal.width ?? 0, height: keyHeight)
    407     }
    408 
    409     func placeSubviews(
    410         in bounds: CGRect,
    411         proposal: ProposedViewSize,
    412         subviews: Subviews,
    413         cache: inout ()
    414     ) {
    415         let containerWidth = bounds.width
    416         let columns = CGFloat(referenceColumns)
    417         let baseKeyWidth = (containerWidth - spacing * (columns - 1)) / columns
    418 
    419         let widths = measuredWidths(for: subviews, baseKeyWidth: baseKeyWidth, containerWidth: containerWidth)
    420         let totalWidth = widths.reduce(0, +) + spacing * CGFloat(max(0, subviews.count - 1))
    421 
    422         var x = bounds.minX + (containerWidth - totalWidth) / 2
    423         for (index, subview) in subviews.enumerated() {
    424             let width = widths[index]
    425             subview.place(
    426                 at: CGPoint(x: x, y: bounds.minY),
    427                 anchor: .topLeading,
    428                 proposal: ProposedViewSize(width: width, height: keyHeight)
    429             )
    430             x += width + spacing
    431         }
    432     }
    433 
    434     private func measuredWidths(
    435         for subviews: Subviews,
    436         baseKeyWidth: CGFloat,
    437         containerWidth: CGFloat
    438     ) -> [CGFloat] {
    439         var widths = subviews.map {
    440             baseKeyWidth * $0[KeyWidthMultiplier.self] + spacing * $0[ExtraGapWidth.self]
    441         }
    442         let fixedSpacing = spacing * CGFloat(max(0, subviews.count - 1))
    443         let totalWidth = widths.reduce(0, +) + fixedSpacing
    444         let flexibleIndexes = subviews.indices.filter { subviews[$0][FillsExtraKeyboardSpace.self] }
    445         guard totalWidth < containerWidth, !flexibleIndexes.isEmpty else {
    446             return widths
    447         }
    448 
    449         let extraWidth = (containerWidth - totalWidth) / CGFloat(flexibleIndexes.count)
    450         for index in flexibleIndexes {
    451             widths[index] += extraWidth
    452         }
    453         return widths
    454     }
    455 }
    456 
    457 private struct KeyWidthMultiplier: LayoutValueKey {
    458     static let defaultValue: CGFloat = 1.0
    459 }
    460 
    461 private struct FillsExtraKeyboardSpace: LayoutValueKey {
    462     static let defaultValue = false
    463 }
    464 
    465 /// Extra width, measured in inter-key gap (`spacing`) multiples, added to a key
    466 /// on top of its column width. Used to make a sparse row total the same width as
    467 /// a denser one so their shared edge keys line up.
    468 private struct ExtraGapWidth: LayoutValueKey {
    469     static let defaultValue: CGFloat = 0
    470 }
    471 
    472 private extension View {
    473     func keyWidthMultiplier(_ multiplier: CGFloat) -> some View {
    474         layoutValue(key: KeyWidthMultiplier.self, value: multiplier)
    475     }
    476 
    477     func extraGapWidth(_ gaps: CGFloat) -> some View {
    478         layoutValue(key: ExtraGapWidth.self, value: gaps)
    479     }
    480 
    481     func fillsExtraKeyboardSpace(_ fills: Bool = true) -> some View {
    482         layoutValue(key: FillsExtraKeyboardSpace.self, value: fills)
    483     }
    484 
    485     func disablesKeyboardMetaAnimations() -> some View {
    486         transaction { transaction in
    487             transaction.animation = nil
    488             transaction.disablesAnimations = true
    489         }
    490     }
    491 }