Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/GamepadPrompt.swift
T
enricobuehler 522ac7bd49 feat(apple): the connect path's modals are pad-navigable
`.alert` and `.confirmationDialog` are UIKit/AppKit surfaces: a game controller
cannot move through their buttons or press one. On iOS/macOS that made every
prompt in the connect path a dead end for a pad-only user, and they are not
incidental prompts — "Pairing required" is the FIRST thing an unpaired host
shows (so pairing was unreachable before it even got to a PIN), "Connection
failed" strands the console UI behind a modal only a finger can dismiss, and
"Waiting for approval" owns the only Cancel for a connect that may never
complete. GamepadPromptView renders those states as a console card with a
focus list of actions; the system alerts stand down while it is up. tvOS keeps
them — the focus engine drives them natively there, which is exactly why this
gap was invisible from that platform.

Two things are deliberate rather than incidental:

The gate is "not STREAMING", not `model.connection == nil`. A connection
object exists well before a stream does — through the pair-required and
approval handshakes, precisely when these fire — so gating on the connection
would hand those cases back to the system dialog. Streaming is the one state
that must keep the alert: there the pad belongs to GamepadCapture.

And the overlay hangs off `driven`, not `home`, for the same reason: `home`
renders only while the connection is nil, so a prompt mounted there would be
skipped in the very case it was written for.

The launcher stands down from the controller poll while a prompt is up
(`promptActive`) — without it the host carousel keeps scrolling underneath the
modal and one A press reaches both.

macOS + tvOS typecheck; console UI verified opening Settings in the iPad
simulator with the prompts wired in.
2026-08-10 09:56:25 +02:00

220 lines
8.7 KiB
Swift

// The gamepad UI's answer to a system alert / confirmation dialog (iOS/iPadOS/macOS).
//
// `.alert` and `.confirmationDialog` are UIKit/AppKit surfaces. A game controller cannot move
// through their buttons or press one — so on iOS/macOS every prompt in the connect path was a dead
// end for a pad-only user, and they are not incidental prompts:
//
// - "Pairing required" (Request Access / Pair with PIN…) is the FIRST thing an unpaired host
// shows. Pairing was unreachable before it even got to the PIN.
// - "Connection failed" strands the console UI behind a modal only a finger can dismiss.
// - "Waiting for approval" owns the only Cancel for a connect that may never complete.
//
// tvOS keeps the system alerts: the focus engine drives them natively there, which is the whole
// reason this gap was tvOS-invisible.
//
// Deliberately NOT built on GamepadMenuList: that is a ScrollView (right for a settings screen of
// unknown length, wrong for two buttons in a card, where it would need an invented height and
// could clip). A prompt has two or three actions, so it owns a plain VStack and a cursor.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS)
/// One choice in a console prompt.
struct GamepadPromptAction: Identifiable {
let id: String
let title: String
/// This is the action B (and Esc) performs, and the one the cursor opens on. Exactly one
/// action should carry it — `GamepadPrompt` falls back to the LAST action when none does,
/// which matches how a system alert treats its cancel role.
var isCancel = false
/// Drawn as the primary, accent-tinted row. At most one.
var isPrimary = false
let run: () -> Void
}
/// A prompt to show over the console UI: what happened, and what can be done about it.
struct GamepadPrompt: Identifiable {
let id: String
let title: String
let message: String
let actions: [GamepadPromptAction]
/// A wait with no outcome yet (the delegated-approval hold) shows a spinner beside the title —
/// the prompt is the UI for something still in flight, not a report that it finished.
var busy = false
}
/// The prompt, worn as the console's own modal: a dimmed field, a glass card, a focus list of
/// actions, and the same legend every other gamepad screen carries.
struct GamepadPromptView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.gamepadMetrics) private var metrics
let prompt: GamepadPrompt
@State private var cursor = 0
@State private var input = GamepadMenuInput(manager: .shared)
@State private var haptics = MenuHaptics(manager: .shared)
/// `.sensoryFeedback` counters — device ticks for confirm and for a refused move at an end.
@State private var activateTick = 0
@State private var boundaryTick = 0
#if os(iOS)
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false
#endif
var body: some View {
ZStack {
// Swallows touch to the launcher behind it, which is also gated out of the controller
// poll for as long as this is up (ContentView's `promptActive`).
Rectangle()
.fill(.black.opacity(0.55))
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {}
card
}
.sensoryFeedback(.selection, trigger: cursor)
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
.onAppear {
cursor = prompt.actions.firstIndex(where: \.isCancel) ?? max(prompt.actions.count - 1, 0)
wire()
input.start()
}
// The prompt's identity is stable across a message change (same `id`), so re-wire rather
// than rely on a remount: the stored closures captured the OLD actions array.
.onChange(of: prompt.actions.map(\.id)) { _, _ in
cursor = min(cursor, max(prompt.actions.count - 1, 0))
wire()
}
.onDisappear {
input.stop()
haptics.stop()
}
}
private var card: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 10) {
if prompt.busy {
ProgressView().controlSize(.small).tint(ink.fg(0.8))
}
Text(prompt.title)
.font(.geist(compact ? 19 : 22, .bold, relativeTo: .title3))
.foregroundStyle(ink.fg)
}
Text(prompt.message)
.font(.geist(metrics.detailFont, relativeTo: .callout))
.foregroundStyle(ink.fg(0.62))
.fixedSize(horizontal: false, vertical: true)
VStack(spacing: 6) {
ForEach(Array(prompt.actions.enumerated()), id: \.element.id) { idx, action in
actionRow(action, focused: idx == cursor)
.contentShape(Rectangle())
.onTapGesture { tap(idx) }
}
}
.padding(.top, 2)
GamepadHintBar(hints: hints)
}
.padding(compact ? 20 : 26)
.frame(maxWidth: 460)
.consoleGlass(RoundedRectangle(cornerRadius: 24, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.padding(24)
}
private func actionRow(_ action: GamepadPromptAction, focused: Bool) -> some View {
let m = metrics
return Text(action.title)
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
.foregroundStyle(action.isPrimary ? ink.accent : ink.fg)
.frame(maxWidth: .infinity)
.padding(.horizontal, m.rowHPad)
.padding(.vertical, m.rowVPad)
.consoleGlass(
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
tint: focused ? ink.accent(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
}
private var hints: [GamepadHint] {
var hints: [GamepadHint] = [.init(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
action: { activate() })]
// Only where B has somewhere to go: a one-action prompt ("OK") is dismissed by that
// action, and B does it too — naming it twice would just be noise.
if prompt.actions.count > 1, let cancel = prompt.actions.first(where: \.isCancel) {
hints.append(.init(
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: cancel.title,
action: { back() }))
}
return hints
}
// MARK: - Input
private func wire() {
input.onMove = { direction in
switch direction {
case .up: step(by: -1)
case .down: step(by: 1)
// A prompt's actions are a vertical list; left/right have nothing to mean here, and
// silently treating them as up/down would make a nudged stick pick a different button.
case .left, .right: break
}
}
input.onConfirm = { activate() }
input.onBack = { back() }
}
private func step(by delta: Int) {
let target = cursor + delta
guard target >= 0, target < prompt.actions.count else {
boundaryTick &+= 1
haptics.boundary()
return
}
cursor = target
haptics.move()
}
private func activate() {
guard cursor >= 0, cursor < prompt.actions.count else { return }
activateTick &+= 1
haptics.confirm()
prompt.actions[cursor].run()
}
/// B: the cancel action, else the last one — the same fallback a system alert applies when
/// nothing carries the cancel role, so B always has a way out rather than doing nothing.
private func back() {
guard let action = prompt.actions.first(where: \.isCancel) ?? prompt.actions.last
else { return }
activateTick &+= 1
haptics.confirm()
action.run()
}
/// Touch fallback matching the rest of the gamepad UI: a tap focuses AND activates.
private func tap(_ idx: Int) {
guard idx >= 0, idx < prompt.actions.count else { return }
cursor = idx
activate()
}
}
#endif