crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 27a4f6d9cafd0152653d0b06314c39105cacc29f
parent 856ee7589cd0136c65ed0d18731be519550ce25c
Author: Michael Camilleri <[email protected]>
Date:   Mon, 17 Aug 2026 13:52:42 +0900

Yield puzzle keyboard capture while sharing diagnostics

The Save to Files filename field could not be edited by a user using a
hardware keyboard while a completed puzzle remained open underneath it.
The puzzle's hidden key capture view retained first responder and
consumed the navigation keys that the previous change deliberately kept
active after completion.

This commit suspends puzzle keyboard capture for the lifetime of the
diagnostics activity and restores it when sharing ends. The capture view
tracks suspension across SwiftUI updates and rechecks queued responder
requests, preventing it from reclaiming focus while the system filename
field is active.

Co-Authored-By: Codex GPT 5.6 Sol <[email protected]>

Diffstat:
MCrossmate/Views/Puzzle/HardwareKeyboardInputView.swift | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
MCrossmate/Views/Settings/DiagnosticsView.swift | 16+++++++++++++++-
2 files changed, 94 insertions(+), 7 deletions(-)

diff --git a/Crossmate/Views/Puzzle/HardwareKeyboardInputView.swift b/Crossmate/Views/Puzzle/HardwareKeyboardInputView.swift @@ -8,6 +8,14 @@ struct HardwareKeyboardEvent { } struct HardwareKeyboardInputView: UIViewRepresentable { + private static let suspendCaptureNotification = Notification.Name( + "HardwareKeyboardInputView.suspendCapture" + ) + private static let resumeCaptureNotification = Notification.Name( + "HardwareKeyboardInputView.resumeCapture" + ) + @MainActor private static var isCaptureSuspended = false + 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. @@ -16,20 +24,45 @@ struct HardwareKeyboardInputView: UIViewRepresentable { func makeUIView(context: Context) -> KeyCaptureView { let view = KeyCaptureView() view.onPress = onPress + view.setActive(isActive) return view } func updateUIView(_ uiView: KeyCaptureView, context: Context) { uiView.onPress = onPress - if isActive { - uiView.ensureFirstResponder() - } else if uiView.isFirstResponder { - uiView.resignFirstResponder() - } + uiView.setActive(isActive) + } + + /// Temporarily yields hardware-keyboard ownership to UIKit content + /// presented above the puzzle, such as the Save to Files filename field. + @MainActor static func setCaptureSuspended(_ suspended: Bool) { + isCaptureSuspended = suspended + NotificationCenter.default.post( + name: suspended ? suspendCaptureNotification : resumeCaptureNotification, + object: nil + ) } final class KeyCaptureView: UIView { var onPress: ((HardwareKeyboardEvent) -> Bool)? + private var isActive = true + private var isSuspended = false + + override init(frame: CGRect) { + super.init(frame: frame) + isSuspended = HardwareKeyboardInputView.isCaptureSuspended + observeCaptureNotifications() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + isSuspended = HardwareKeyboardInputView.isCaptureSuspended + observeCaptureNotifications() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } override var canBecomeFirstResponder: Bool { true } @@ -75,13 +108,53 @@ struct HardwareKeyboardInputView: UIViewRepresentable { ensureFirstResponder() } + func setActive(_ active: Bool) { + isActive = active + if active { + ensureFirstResponder() + } else if isFirstResponder { + resignFirstResponder() + } + } + func ensureFirstResponder() { - guard window != nil, !isFirstResponder else { return } + guard isActive, !isSuspended, window != nil, !isFirstResponder else { return } Task { @MainActor in + guard self.isActive, + !self.isSuspended, + self.window != nil, + !self.isFirstResponder else { return } self.becomeFirstResponder() } } + private func observeCaptureNotifications() { + NotificationCenter.default.addObserver( + self, + selector: #selector(suspendCapture), + name: HardwareKeyboardInputView.suspendCaptureNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(resumeCapture), + name: HardwareKeyboardInputView.resumeCaptureNotification, + object: nil + ) + } + + @objc private func suspendCapture() { + isSuspended = true + if isFirstResponder { + resignFirstResponder() + } + } + + @objc private func resumeCapture() { + isSuspended = false + ensureFirstResponder() + } + override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) { var unhandled: [UIPress] = [] diff --git a/Crossmate/Views/Settings/DiagnosticsView.swift b/Crossmate/Views/Settings/DiagnosticsView.swift @@ -269,6 +269,16 @@ private struct DiagnosticsShareButton: UIViewRepresentable { guard let button else { return } let configuration = DiagnosticsShareItem.configuration(for: snapshot()) let controller = UIActivityViewController(activityItemsConfiguration: configuration) + // The open puzzle normally owns first responder so it can + // capture hardware navigation keys. Yield that ownership + // while the activity and its Save to Files filename field + // are presented, then restore it when the activity ends. + HardwareKeyboardInputView.setCaptureSuspended(true) + controller.completionWithItemsHandler = { _, _, _, _ in + Task { @MainActor in + HardwareKeyboardInputView.setCaptureSuspended(false) + } + } // iPad presents the share sheet as a popover and traps // without an anchor. if let popover = controller.popoverPresentationController { @@ -279,7 +289,11 @@ private struct DiagnosticsShareButton: UIViewRepresentable { while let presented = presenter?.presentedViewController { presenter = presented } - presenter?.present(controller, animated: true) + guard let presenter else { + HardwareKeyboardInputView.setCaptureSuspended(false) + return + } + presenter.present(controller, animated: true) } }, for: .touchUpInside