crossmate

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

KeyboardView.swift (18683B)


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