PairSheet is a `Form` with two `TextField`s. On tvOS the focus engine drives those natively, but on iOS/macOS a controller cannot reach a text field, type into it, or press the button underneath — so for anyone in the console UI, pairing (the ONE thing between a fresh install and a first stream) ended at "now touch the screen". GamepadPairView is the same ceremony in the gamepad UI's own vocabulary: the vertical focus list the settings and add-host screens use, A on a field to open GamepadKeyboard in a bottom tray, B to peel one layer. It mirrors GamepadAddHostView field for field, because it is the same interaction and someone who has added a host should recognise it immediately. The ceremony itself moved to a shared `PairCeremony` used by both presentations, so they can never disagree about what a wrong PIN means, what a host rejection says, or when a late result must be discarded. On iOS it is a shell layer like settings and add-host, and it LEADS the shell's screen order: it blocks a connect the user already asked for and can be raised from on top of the library (launching a title on an unpaired host), so it has to win; backing out reveals whatever it interrupted. macOS has no shell, so its sheet switches content by mode instead. tvOS is untouched. macOS + tvOS typecheck; console UI verified opening Settings in the iPad simulator with the pair screen wired into the shell.
98 lines
4.8 KiB
Swift
98 lines
4.8 KiB
Swift
// The gamepad UI's screen-shell vocabulary (iOS): which screen sits over the launcher, and the
|
||
// console push/pop choreography that presents it. On iOS the launcher's sub-screens (settings,
|
||
// add-host, library) are NOT system covers — they are transparent layers composited in
|
||
// GamepadHomeView's ZStack over ONE persistent living backdrop, exactly the model
|
||
// `pf-console-ui`'s shell renders on the desktop clients: a push slides the incoming screen up
|
||
// out of a fade while the outgoing one recedes; a pop mirrors it; the field underneath never
|
||
// moves and never leaves. A system `fullScreenCover` — an opaque sheet sliding up from the
|
||
// bottom edge, mounting its own backdrop — was exactly the wrong grammar for a console.
|
||
// (macOS keeps its windowed sheets and tvOS its focus-engine covers; this file's motion
|
||
// constants are iOS-only in practice, but compile everywhere for the shared call sites.)
|
||
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
#if os(iOS) || os(macOS) || os(tvOS)
|
||
|
||
/// The screen the shell currently shows over the launcher. Derived, not stored: the presentation
|
||
/// triggers (`showSettings`, `showAddHost`, `libraryTarget`) stay authoritative on every
|
||
/// platform — this enum is just their iOS rendering. Depth is ≤ 1 by construction (the settings
|
||
/// pin picker is an in-screen layer, and every trigger is only reachable from the launcher), so
|
||
/// there is no stack to model.
|
||
enum GamepadScreen: Identifiable {
|
||
case settings
|
||
case addHost
|
||
case pair(StoredHost)
|
||
case library(StoredHost)
|
||
|
||
var id: String {
|
||
switch self {
|
||
case .settings: return "settings"
|
||
case .addHost: return "addHost"
|
||
case .pair(let host): return "pair-\(host.id.uuidString)"
|
||
case .library(let host): return "library-\(host.id.uuidString)"
|
||
}
|
||
}
|
||
|
||
/// The backdrop's calm target while this screen is up: the form screens quiet the field
|
||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||
var isForm: Bool {
|
||
switch self {
|
||
case .settings, .addHost, .pair: return true
|
||
case .library: return false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The console shell's motion constants, mapped to SwiftUI. Source of truth:
|
||
/// `crates/pf-console-ui/src/shell/render.rs` (push/pop) and `shell.rs` (`TRANSITION_S`).
|
||
enum GamepadShellMotion {
|
||
/// One transition, both layers — the console's `TRANSITION_S`.
|
||
static let duration: TimeInterval = 0.26
|
||
/// `1-(1-t)³` as a bezier: the standard ease-out-cubic control points.
|
||
static let screen = Animation.timingCurve(0.33, 1, 0.68, 1, duration: duration)
|
||
/// The backdrop's calm chase. The console runs an exponential approach (τ 0.12 s); the same
|
||
/// ease-out at 0.30 s lands within a few percent of it and settles together with the screen.
|
||
static let calm = Animation.timingCurve(0.33, 1, 0.68, 1, duration: 0.30)
|
||
/// The push/pop travel — the console's `36 * k`, k-floored for a landscape phone.
|
||
static func slide(compact: Bool) -> CGFloat { compact ? 27 : 36 }
|
||
/// The incoming screen grows from this; the revealed launcher grows back from `underScale`.
|
||
static let inScale: CGFloat = 0.985
|
||
static let underScale: CGFloat = 0.96
|
||
}
|
||
|
||
extension AnyTransition {
|
||
/// The console push/pop for the top layer. Insertion: up out of a fade, growing from 0.985.
|
||
/// Removal: down into a fade at full size (the console's pop leaves scale alone). The
|
||
/// launcher's recede underneath is NOT a transition — it never unmounts — it is the
|
||
/// `covered` opacity/scale in GamepadHomeView, animated in the same transaction.
|
||
///
|
||
/// Known deviation from the console: a pop there re-reveals the launcher from α 0.4; a
|
||
/// SwiftUI opacity animates from 0. Same duration, same landing — the revealed screen just
|
||
/// reads a beat later in the fade, not worth an explicitly-driven progress machine.
|
||
static func gamepadScreen(slide: CGFloat) -> AnyTransition {
|
||
.asymmetric(
|
||
insertion: .opacity
|
||
.combined(with: .offset(y: slide))
|
||
.combined(with: .scale(scale: GamepadShellMotion.inScale)),
|
||
removal: .opacity.combined(with: .offset(y: slide)))
|
||
}
|
||
}
|
||
|
||
private struct GamepadHostedInShellKey: EnvironmentKey {
|
||
static let defaultValue = false
|
||
}
|
||
|
||
extension EnvironmentValues {
|
||
/// True for a screen mounted as one of the shell's layers: it must NOT mount its own
|
||
/// backdrop (the shell's single persistent field is behind everything already — a second
|
||
/// one would double the mesh cost and break the "field never moves" illusion). The same
|
||
/// screens presented as macOS sheets / tvOS covers read the default `false` and keep
|
||
/// mounting their own, exactly as before.
|
||
var gamepadHostedInShell: Bool {
|
||
get { self[GamepadHostedInShellKey.self] }
|
||
set { self[GamepadHostedInShellKey.self] = newValue }
|
||
}
|
||
}
|
||
|
||
#endif
|