KeyboardView.swift (12253B)
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 @State private var showingOverflow = false 10 11 private let topRow = Array("QWERTYUIOP").map(String.init) 12 private let middleRow = Array("ASDFGHJKL").map(String.init) 13 private let bottomLetters = Array("ZXCVBNM").map(String.init) 14 15 private let spacing: CGFloat = 6 16 private let keyHeight: CGFloat = 46 17 private let metaKeyWidthMultiplier: CGFloat = 1.5 18 19 static let standardHeight: CGFloat = 170 20 21 var body: some View { 22 VStack(spacing: spacing) { 23 KeyboardRow( 24 referenceColumns: showsNavigationKeys ? 12 : 10, 25 spacing: spacing, 26 keyHeight: keyHeight 27 ) { 28 if showsNavigationKeys { 29 actionKey(systemImage: "chevron.left", accessibilityLabel: "Previous Word") { 30 session.goToPreviousWord() 31 } 32 .fillsExtraKeyboardSpace() 33 } 34 ForEach(topRow, id: \.self) { letter in 35 letterKey(letter) 36 } 37 if showsNavigationKeys { 38 actionKey(systemImage: "chevron.right", accessibilityLabel: "Next Word") { 39 session.goToNextWord() 40 } 41 .fillsExtraKeyboardSpace() 42 } 43 } 44 KeyboardRow( 45 referenceColumns: showsNavigationKeys ? 12 : 10, 46 spacing: spacing, 47 keyHeight: keyHeight 48 ) { 49 if showsNavigationKeys { 50 actionKey(systemImage: "arrowtriangle.left.fill", accessibilityLabel: "Previous Letter") { 51 session.goToPreviousLetter() 52 } 53 .fillsExtraKeyboardSpace() 54 } 55 ForEach(middleRow, id: \.self) { letter in 56 letterKey(letter) 57 } 58 if showsNavigationKeys { 59 actionKey(systemImage: "arrowtriangle.right.fill", accessibilityLabel: "Next Letter") { 60 session.goToNextLetter() 61 } 62 .fillsExtraKeyboardSpace() 63 } 64 } 65 KeyboardRow( 66 referenceColumns: showsNavigationKeys ? 12 : 10, 67 spacing: spacing, 68 keyHeight: keyHeight 69 ) { 70 if showsNavigationKeys { 71 rebusKey 72 .disablesKeyboardMetaAnimations() 73 .fillsExtraKeyboardSpace() 74 75 actionKey( 76 systemImage: "pencil", 77 accessibilityLabel: session.isPencilMode ? "Turn Off Draft" : "Turn On Draft", 78 background: session.isPencilMode ? .blue : Color(.systemFill), 79 foreground: session.isPencilMode ? .white : .primary 80 ) { 81 session.togglePencil() 82 } 83 .disablesKeyboardMetaAnimations() 84 .fillsExtraKeyboardSpace() 85 } else { 86 if session.isRebusActive { 87 actionKey(text: "Done", background: .blue, foreground: .white) { 88 session.commitRebus() 89 } 90 .keyWidthMultiplier(metaKeyWidthMultiplier) 91 } else { 92 overflowKey 93 } 94 } 95 96 ForEach(bottomLetters, id: \.self) { letter in 97 letterKey(letter) 98 } 99 100 if showsNavigationKeys { 101 actionKey( 102 systemImage: "arrow.2.squarepath", 103 accessibilityLabel: "Switch Direction" 104 ) { 105 session.toggleDirection() 106 } 107 .disablesKeyboardMetaAnimations() 108 .fillsExtraKeyboardSpace() 109 } 110 111 actionKey(systemImage: "delete.left") { 112 if session.isRebusActive { 113 session.deleteRebusLetter() 114 } else { 115 session.deleteBackward() 116 } 117 } 118 .disablesKeyboardMetaAnimations() 119 .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier) 120 .fillsExtraKeyboardSpace(showsNavigationKeys) 121 } 122 } 123 .padding(.horizontal, 4) 124 .padding(.top, 12) 125 .padding(.bottom, 8) 126 } 127 128 private func letterKey(_ letter: String) -> some View { 129 Button { 130 if session.isRebusActive { 131 session.appendRebusLetter(letter) 132 } else { 133 session.enter(letter) 134 } 135 } label: { 136 Text(letter) 137 .font(.system(size: 22, weight: .medium, design: .rounded)) 138 .frame(maxWidth: .infinity, maxHeight: .infinity) 139 .background(Color(.tertiarySystemBackground)) 140 .foregroundStyle(.primary) 141 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) 142 } 143 .buttonStyle(.plain) 144 } 145 146 private var rebusKey: some View { 147 Group { 148 if session.isRebusActive { 149 actionKey(text: "Done", background: .blue, foreground: .white) { 150 session.commitRebus() 151 } 152 } else { 153 actionKey(text: "Rebus") { 154 session.startRebus() 155 } 156 } 157 } 158 } 159 160 private var overflowKey: some View { 161 actionKey(systemImage: "ellipsis") { 162 showingOverflow = true 163 } 164 .keyWidthMultiplier(metaKeyWidthMultiplier) 165 .popover(isPresented: $showingOverflow) { 166 VStack(alignment: .leading, spacing: 0) { 167 overflowItem("Undo Move", systemImage: "arrow.uturn.backward", isEnabled: session.canUndo) { 168 session.undo() 169 } 170 171 overflowItem("Redo Move", systemImage: "arrow.uturn.forward", isEnabled: session.canRedo) { 172 session.redo() 173 } 174 175 Divider() 176 177 overflowItem("Toggle Draft", systemImage: "pencil") { 178 session.togglePencil() 179 } 180 181 overflowItem("Enter Rebus", systemImage: "text.cursor") { 182 session.startRebus() 183 } 184 } 185 .frame(minWidth: 160) 186 .presentationCompactAdaptation(.popover) 187 .presentationBackground(.thinMaterial) 188 } 189 } 190 191 /// One row in the overflow popover. Dismisses the popover, then runs the 192 /// action — disabled rows (e.g. Undo with nothing to undo) are greyed out. 193 private func overflowItem( 194 _ title: String, 195 systemImage: String, 196 isEnabled: Bool = true, 197 action: @escaping () -> Void 198 ) -> some View { 199 Button { 200 showingOverflow = false 201 action() 202 } label: { 203 Label(title, systemImage: systemImage) 204 .frame(maxWidth: .infinity, alignment: .leading) 205 .padding(.horizontal, 16) 206 .padding(.vertical, 12) 207 } 208 .buttonStyle(.plain) 209 .disabled(!isEnabled) 210 } 211 212 private func actionKey( 213 systemImage: String, 214 accessibilityLabel: String? = nil, 215 background: Color = Color(.systemFill), 216 foreground: Color = .primary, 217 action: @escaping () -> Void 218 ) -> some View { 219 Button(action: action) { 220 Image(systemName: systemImage) 221 .font(.system(size: 18, weight: .medium)) 222 .frame(maxWidth: .infinity, maxHeight: .infinity) 223 .background(background) 224 .foregroundStyle(foreground) 225 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) 226 } 227 .buttonStyle(.plain) 228 .accessibilityLabel(accessibilityLabel ?? systemImage) 229 } 230 231 private func actionKey( 232 text: String, 233 background: Color = Color(.systemFill), 234 foreground: Color = .primary, 235 action: @escaping () -> Void 236 ) -> some View { 237 Button(action: action) { 238 Text(text) 239 .font(.system(size: 16, weight: .semibold, design: .rounded)) 240 .frame(maxWidth: .infinity, maxHeight: .infinity) 241 .background(background) 242 .foregroundStyle(foreground) 243 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) 244 } 245 .buttonStyle(.plain) 246 } 247 248 } 249 250 // MARK: - Layout 251 252 /// Lays out a row of keys at a fixed key height. Every key is sized as a 253 /// fraction of `referenceColumns`, so a row containing fewer keys ends up 254 /// narrower than the reference row and is centered. Action keys can opt into 255 /// a wider width via `keyWidthMultiplier`, and selected keys can split any 256 /// leftover row width via `fillsExtraKeyboardSpace`. 257 private struct KeyboardRow: Layout { 258 let referenceColumns: Int 259 let spacing: CGFloat 260 let keyHeight: CGFloat 261 262 func sizeThatFits( 263 proposal: ProposedViewSize, 264 subviews: Subviews, 265 cache: inout () 266 ) -> CGSize { 267 CGSize(width: proposal.width ?? 0, height: keyHeight) 268 } 269 270 func placeSubviews( 271 in bounds: CGRect, 272 proposal: ProposedViewSize, 273 subviews: Subviews, 274 cache: inout () 275 ) { 276 let containerWidth = bounds.width 277 let columns = CGFloat(referenceColumns) 278 let baseKeyWidth = (containerWidth - spacing * (columns - 1)) / columns 279 280 let widths = measuredWidths(for: subviews, baseKeyWidth: baseKeyWidth, containerWidth: containerWidth) 281 let totalWidth = widths.reduce(0, +) + spacing * CGFloat(max(0, subviews.count - 1)) 282 283 var x = bounds.minX + (containerWidth - totalWidth) / 2 284 for (index, subview) in subviews.enumerated() { 285 let width = widths[index] 286 subview.place( 287 at: CGPoint(x: x, y: bounds.minY), 288 anchor: .topLeading, 289 proposal: ProposedViewSize(width: width, height: keyHeight) 290 ) 291 x += width + spacing 292 } 293 } 294 295 private func measuredWidths( 296 for subviews: Subviews, 297 baseKeyWidth: CGFloat, 298 containerWidth: CGFloat 299 ) -> [CGFloat] { 300 var widths = subviews.map { baseKeyWidth * $0[KeyWidthMultiplier.self] } 301 let fixedSpacing = spacing * CGFloat(max(0, subviews.count - 1)) 302 let totalWidth = widths.reduce(0, +) + fixedSpacing 303 let flexibleIndexes = subviews.indices.filter { subviews[$0][FillsExtraKeyboardSpace.self] } 304 guard totalWidth < containerWidth, !flexibleIndexes.isEmpty else { 305 return widths 306 } 307 308 let extraWidth = (containerWidth - totalWidth) / CGFloat(flexibleIndexes.count) 309 for index in flexibleIndexes { 310 widths[index] += extraWidth 311 } 312 return widths 313 } 314 } 315 316 private struct KeyWidthMultiplier: LayoutValueKey { 317 static let defaultValue: CGFloat = 1.0 318 } 319 320 private struct FillsExtraKeyboardSpace: LayoutValueKey { 321 static let defaultValue = false 322 } 323 324 private extension View { 325 func keyWidthMultiplier(_ multiplier: CGFloat) -> some View { 326 layoutValue(key: KeyWidthMultiplier.self, value: multiplier) 327 } 328 329 func fillsExtraKeyboardSpace(_ fills: Bool = true) -> some View { 330 layoutValue(key: FillsExtraKeyboardSpace.self, value: fills) 331 } 332 333 func disablesKeyboardMetaAnimations() -> some View { 334 transaction { transaction in 335 transaction.animation = nil 336 transaction.disablesAnimations = true 337 } 338 } 339 }