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.
This commit is contained in:
@@ -200,6 +200,22 @@ struct ContentView: View {
|
||||
private var driven: some View {
|
||||
drivenBase
|
||||
.environment(\.gamepadMetrics, gamepadMetrics)
|
||||
#if os(iOS) || os(macOS)
|
||||
// The console's own modal, over WHICHEVER screen is up. Not attached to `home`, which
|
||||
// renders only while `model.connection == nil`: a connection exists through the
|
||||
// pair-required and approval handshakes, which is precisely when these prompts fire.
|
||||
// It sits above the connect takeover too — the delegated-approval wait is raised
|
||||
// DURING a dial and owns the only Cancel for it. (The takeover draws nothing in that
|
||||
// state: `connectingOverlayName` is nil while `awaitingApproval` is set, so the two
|
||||
// never poll the pad at once.)
|
||||
.overlay {
|
||||
if let prompt = consolePrompt {
|
||||
GamepadPromptView(prompt: prompt)
|
||||
.gamepadPaletteInk()
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var drivenBase: some View {
|
||||
@@ -444,9 +460,102 @@ struct ContentView: View {
|
||||
// budget (inline, they tip SwiftUI's per-expression limit — see the split sections idiom).
|
||||
|
||||
private var deepLinkNoticePresented: Binding<Bool> {
|
||||
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
|
||||
Binding(
|
||||
get: { deepLinkNotice != nil && !consolePromptShowing },
|
||||
set: { if !$0 { deepLinkNotice = nil } })
|
||||
}
|
||||
|
||||
/// True while the console prompt owns the modal state (see `consolePrompt`). Always false on
|
||||
/// tvOS, whose alerts the focus engine drives natively.
|
||||
private var consolePromptShowing: Bool {
|
||||
#if os(iOS) || os(macOS)
|
||||
consolePrompt != nil
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The modal state the console UI should present ITSELF, as a pad-navigable prompt, instead of
|
||||
/// letting a system alert take it. `.alert`/`.confirmationDialog` are UIKit/AppKit surfaces a
|
||||
/// controller cannot navigate, and these are not incidental prompts: "Pairing required" is the
|
||||
/// FIRST thing an unpaired host shows, "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. One at a time, most-urgent first — a system alert stack
|
||||
/// would layer these, but a console shows one screen.
|
||||
///
|
||||
/// Gated on not STREAMING, not on `model.connection == nil`: a connection object exists well
|
||||
/// before a stream does, through exactly the handshakes these prompts belong to. Streaming is
|
||||
/// the one case that must stay with the system alert — there the pad belongs to
|
||||
/// `GamepadCapture` and is being forwarded to the host.
|
||||
private var consolePrompt: GamepadPrompt? {
|
||||
guard gamepadUIActive, model.phase != .streaming else { return nil }
|
||||
if let req = approvalChoice {
|
||||
return GamepadPrompt(
|
||||
id: "pairing-required",
|
||||
title: "Pairing required",
|
||||
message: "\(req.host.displayName) requires pairing. Request access and approve "
|
||||
+ "this device in the host's web console (port 47992 → Pairing) — no PIN "
|
||||
+ "needed. Or pair with the 4-digit PIN it can display.",
|
||||
actions: [
|
||||
// The follow-on presentation is deferred a tick exactly as the system dialog
|
||||
// does it, so this prompt is fully torn down before the next screen mounts —
|
||||
// two controller pollers overlapping for a frame is how one A press reaches
|
||||
// both.
|
||||
GamepadPromptAction(id: "request", title: "Request Access", isPrimary: true) {
|
||||
approvalChoice = nil
|
||||
DispatchQueue.main.async { requestAccess(req) }
|
||||
},
|
||||
GamepadPromptAction(id: "pin", title: "Pair with PIN…") {
|
||||
approvalChoice = nil
|
||||
DispatchQueue.main.async { pairingTarget = req.host }
|
||||
},
|
||||
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
|
||||
approvalChoice = nil
|
||||
},
|
||||
])
|
||||
}
|
||||
if let req = awaitingApproval {
|
||||
return GamepadPrompt(
|
||||
id: "awaiting-approval",
|
||||
title: "Waiting for approval",
|
||||
message: "Approve \u{201C}\(localDeviceName)\u{201D} in \(req.host.displayName)'s "
|
||||
+ "web console (port 47992 → Pairing). This device connects automatically "
|
||||
+ "once you approve it — no need to reconnect.",
|
||||
actions: [
|
||||
GamepadPromptAction(id: "cancel", title: "Cancel", isCancel: true) {
|
||||
awaitingApproval = nil
|
||||
model.disconnect()
|
||||
},
|
||||
],
|
||||
busy: true)
|
||||
}
|
||||
if connectionErrorReady {
|
||||
return GamepadPrompt(
|
||||
id: "connection-failed",
|
||||
title: "Connection failed",
|
||||
message: model.errorMessage ?? "",
|
||||
actions: [
|
||||
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
|
||||
model.errorMessage = nil
|
||||
},
|
||||
])
|
||||
}
|
||||
if let notice = deepLinkNotice {
|
||||
return GamepadPrompt(
|
||||
id: "cant-open",
|
||||
title: "Can't open",
|
||||
message: notice,
|
||||
actions: [
|
||||
GamepadPromptAction(id: "ok", title: "OK", isCancel: true) {
|
||||
deepLinkNotice = nil
|
||||
},
|
||||
])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
|
||||
/// the library in place (see the cover's comment).
|
||||
private var touchLibraryTarget: Binding<StoredHost?> {
|
||||
@@ -470,18 +579,22 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var approvalChoicePresented: Binding<Bool> {
|
||||
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
|
||||
Binding(
|
||||
get: { approvalChoice != nil && !consolePromptShowing },
|
||||
set: { if !$0 { approvalChoice = nil } })
|
||||
}
|
||||
|
||||
private var awaitingApprovalPresented: Binding<Bool> {
|
||||
Binding(get: { awaitingApproval != nil }, set: { if !$0 { awaitingApproval = nil } })
|
||||
Binding(
|
||||
get: { awaitingApproval != nil && !consolePromptShowing },
|
||||
set: { if !$0 { awaitingApproval = nil } })
|
||||
}
|
||||
|
||||
private var connectionErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
guard model.errorMessage != nil else { return false }
|
||||
#if os(macOS)
|
||||
/// Whether the "Connection failed" state is ready to be shown at all — shared by the system
|
||||
/// alert and the console prompt so the two can never disagree about the macOS deferral below.
|
||||
private var connectionErrorReady: Bool {
|
||||
guard model.errorMessage != nil else { return false }
|
||||
#if os(macOS)
|
||||
// Defer the alert while a forced-fullscreen exit is still pending: a sheet
|
||||
// attached to a fullscreen window makes AppKit drop `-toggleFullScreen:`, so
|
||||
// presenting it now strands the window fullscreen on the home screen after a
|
||||
@@ -490,10 +603,14 @@ struct ContentView: View {
|
||||
// once the window leaves fullscreen and `isFullscreen` flips, the alert shows
|
||||
// over the windowed home UI. Not gated when fullscreen is the user's own manual
|
||||
// choice (opt-out setting) — nothing is auto-exiting there to conflict with.
|
||||
if fullscreenForSession && isFullscreen { return false }
|
||||
#endif
|
||||
return true
|
||||
},
|
||||
if fullscreenForSession && isFullscreen { return false }
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
private var connectionErrorPresented: Binding<Bool> {
|
||||
Binding(
|
||||
get: { connectionErrorReady && !consolePromptShowing },
|
||||
set: { if !$0 { model.errorMessage = nil } })
|
||||
}
|
||||
|
||||
@@ -628,7 +745,8 @@ struct ContentView: View {
|
||||
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
launchTitle: launchTitle,
|
||||
promptActive: consolePromptShowing)
|
||||
} else {
|
||||
HomeView(
|
||||
store: store, model: model, discovery: discovery,
|
||||
@@ -646,7 +764,8 @@ struct ContentView: View {
|
||||
libraryTarget: $libraryTarget, pairingTarget: $pairingTarget,
|
||||
onPaired: handlePaired, waker: waker,
|
||||
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
|
||||
launchTitle: launchTitle)
|
||||
launchTitle: launchTitle,
|
||||
promptActive: consolePromptShowing)
|
||||
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||||
|
||||
@@ -84,6 +84,11 @@ struct GamepadHomeView: View {
|
||||
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
||||
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
|
||||
let launchTitle: (StoredHost, String) -> Void
|
||||
/// A console prompt (GamepadPromptView) is up over the home — it polls the same controller, so
|
||||
/// this screen must stand down for as long as it is. Same handoff contract as the connect
|
||||
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
|
||||
/// modal and a single A press reaches both.
|
||||
var promptActive = false
|
||||
|
||||
/// The profile catalog — pinned host+profile combos render as their own tiles here, which is
|
||||
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
|
||||
@@ -311,14 +316,14 @@ struct GamepadHomeView: View {
|
||||
/// transition's input drop, during which NOBODY polls.
|
||||
private var homeOwnsController: Bool {
|
||||
#if os(iOS)
|
||||
topScreen == nil && !transitioning
|
||||
topScreen == nil && !transitioning && !promptActive
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
#else
|
||||
// `pairingTarget` too: macOS presents the pair screen as a sheet and tvOS as a cover, and
|
||||
// either way the launcher underneath must stop consuming the pad — the pair screen's own
|
||||
// list is polling the same controller.
|
||||
libraryTarget == nil && pairingTarget == nil && !showSettings && !showAddHost
|
||||
&& waker.waking == nil && model.phase != .connecting
|
||||
&& !promptActive && waker.waking == nil && model.phase != .connecting
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// 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
|
||||
Reference in New Issue
Block a user