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 }