commit 6ebe518427f06133098657a95351652813eccd66
parent 64aa2ee31a1bc2f9e78095cf37433b5937a21b43
Author: Michael Camilleri <[email protected]>
Date: Mon, 22 Jun 2026 12:23:31 +0900
Use the system keyboard for rebus entry
The custom KeyboardView only offers A–Z, the digits, and a small set of
symbols, so a rebus fill that needs any character off that grid could
not be typed at all. This commit routes rebus entry through the system
on-screen keyboard instead, which reaches the wider character set the
hand-rolled keys cannot.
The centred RebusModal, previously a read-only display, is now an
editable TextField bound to rebusBuffer and focused while isRebusActive,
so entering rebus raises the keyboard and the card mirrors the buffer as
the player types. HardwareKeyboardInputView gains an isActive flag and
relinquishes first responder during rebus, letting the field own input —
including hardware keys — rather than the background capture view. Losing
focus or submitting commits the buffer, matching the existing scrim tap,
and the modal's swallow gesture is dropped so taps reach the field for
caret placement.
The field is pinned to .asciiCapable for now, keeping the enterable
letters reatricted while leaving room to widen the accepted characters
later.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat:
2 files changed, 52 insertions(+), 10 deletions(-)
diff --git a/Crossmate/Views/Puzzle/HardwareKeyboardInputView.swift b/Crossmate/Views/Puzzle/HardwareKeyboardInputView.swift
@@ -9,6 +9,9 @@ struct HardwareKeyboardEvent {
struct HardwareKeyboardInputView: UIViewRepresentable {
var onPress: (HardwareKeyboardEvent) -> Bool
+ /// When false, the capture view relinquishes first responder so another
+ /// view (the rebus text field) can own input and raise the system keyboard.
+ var isActive: Bool = true
func makeUIView(context: Context) -> KeyCaptureView {
let view = KeyCaptureView()
@@ -18,7 +21,11 @@ struct HardwareKeyboardInputView: UIViewRepresentable {
func updateUIView(_ uiView: KeyCaptureView, context: Context) {
uiView.onPress = onPress
- uiView.ensureFirstResponder()
+ if isActive {
+ uiView.ensureFirstResponder()
+ } else if uiView.isFirstResponder {
+ uiView.resignFirstResponder()
+ }
}
final class KeyCaptureView: UIView {
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -67,6 +67,10 @@ struct PuzzleView: View {
/// The shared open "arm" beat: flips a moment after open so the banner and
/// the "changed while you were away" borders reveal together.
@State private var isArmed = false
+ /// Drives the system keyboard for rebus entry. Bound to `isRebusActive`:
+ /// focusing the rebus field raises the keyboard, and losing focus (e.g. the
+ /// player swipes the keyboard away) commits the buffer.
+ @FocusState private var isRebusFieldFocused: Bool
@Environment(\.engagementStatus) private var engagementStatus
private enum PadLayout {
@@ -128,11 +132,26 @@ struct PuzzleView: View {
}
.background(Color(.systemBackground))
.background {
- HardwareKeyboardInputView(onPress: handleHardwareKeyboardEvent)
- .frame(width: 0, height: 0)
- .allowsHitTesting(false)
+ // Yields first responder during rebus so the focused rebus field
+ // owns input (including hardware keys) and the system keyboard rises.
+ HardwareKeyboardInputView(
+ onPress: handleHardwareKeyboardEvent,
+ isActive: !session.isRebusActive
+ )
+ .frame(width: 0, height: 0)
+ .allowsHitTesting(false)
}
.ignoresSafeArea(.keyboard)
+ .onChange(of: session.isRebusActive) { _, active in
+ isRebusFieldFocused = active
+ }
+ .onChange(of: isRebusFieldFocused) { _, focused in
+ // The player dismissed the keyboard (swipe-down / hardware Esc):
+ // treat it as a commit, matching the scrim tap.
+ if !focused, session.isRebusActive {
+ session.commitRebus()
+ }
+ }
.modifier(PuzzleToolbarModifier(
session: session,
roster: roster,
@@ -363,10 +382,14 @@ struct PuzzleView: View {
.onTapGesture {
session.commitRebus()
}
- RebusModal(text: session.rebusBuffer)
- .padding(.horizontal)
- .contentShape(Rectangle())
- .onTapGesture { /* swallow */ }
+ // No swallow gesture here: the card's opaque background already
+ // blocks taps from reaching the commit scrim beneath, and adding
+ // a tap gesture in front would steal the field's own taps,
+ // limiting the caret to the start/end of the buffer.
+ RebusModal(text: $session.rebusBuffer, isFocused: $isRebusFieldFocused) {
+ session.commitRebus()
+ }
+ .padding(.horizontal)
}
}
}
@@ -580,10 +603,22 @@ struct PuzzleView: View {
}
private struct RebusModal: View {
- let text: String
+ @Binding var text: String
+ var isFocused: FocusState<Bool>.Binding
+ let onCommit: () -> Void
var body: some View {
- Text(text.isEmpty ? " " : text)
+ // An editable field styled to read as the centred display card: the
+ // system keyboard drives entry (so symbols, accents, and emoji are all
+ // reachable) while the look and placement match the prior read-only modal.
+ TextField("", text: $text)
+ .focused(isFocused)
+ .keyboardType(.asciiCapable)
+ .textInputAutocapitalization(.characters)
+ .autocorrectionDisabled()
+ .submitLabel(.done)
+ .onSubmit(onCommit)
+ .multilineTextAlignment(.center)
.font(.system(size: 32, weight: .semibold, design: .rounded))
.foregroundStyle(.primary)
.frame(maxWidth: .infinity, minHeight: 56)