Asked for by a field user: "make the iPadOS client compatible with keyboard to select games with keyboard arrows, enter to launch". An iPad on a Magic Keyboard and a couch Mac are the same situation the console layout was built for — a screen driven from a distance with a fixed set of directional inputs — and the cursor/confirm/back model already exists here for the pad. A keyboard is a third input onto it, not a new navigation scheme: arrows move, Return and Space activate, Esc backs out, everywhere the controller already worked (carousel, menu lists, prompts) plus the plain poster grid. `active` mirrors each caller's existing controller gate rather than being a second, parallel notion of "who has input". Without that, a launcher sitting under an open screen would keep eating key presses and navigate behind it — the same defect the pad gate exists to prevent. Esc returns `.ignored` when a screen has no back action, so it still reaches the `.cancelAction` shortcut that closes a macOS sheet. The plain grid needed real arithmetic rather than a flat index. It renders up to TWO `LazyVGrid` sections (launchers above titles), so a flat index steps by the wrong amount at the boundary whenever the first section's last row is partial — up from the titles' first row lands mid-launcher-row instead of above. `LibraryGridNav` moves within a section and hands off at its edges preserving the column, clamping into partial rows. It lives in PunktfunkKit because it is edge-case arithmetic and that is the target tests can reach; 12 cases cover the partial row, the hand-off, a stale cursor, an empty grid and a zero column count. The column count comes from the grid's MEASURED width run through `.adaptive`'s own fitting rule, so up/down move exactly one visual row instead of a guess that drifts with window size. Measured via a background GeometryReader — a sibling inside a ScrollView would claim the whole viewport. The grid cursor starts nil and only appears on the first arrow press, so a touch user is never shown a selection they didn't ask for. tvOS is excluded throughout: its focus engine already routes hardware arrows, and these screens hand it navigation authority deliberately. 17 PunktfunkKit tests pass; macOS + tvOS typecheck; launcher and settings verified rendering and navigating in the iPad Pro 13" simulator.
231 lines
9.1 KiB
Swift
231 lines
9.1 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)
|
|
// A prompt is exactly where a keyboard user gets stuck, so it takes arrows/Return/Esc too.
|
|
.gamepadKeyNavigation(
|
|
onMove: { direction in
|
|
switch direction {
|
|
case .up: step(by: -1)
|
|
case .down: step(by: 1)
|
|
case .left, .right: break
|
|
}
|
|
},
|
|
onConfirm: { activate() },
|
|
onBack: { back() })
|
|
.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
|