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 }