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 }