ci / bun-nix (pull_request) Successful in 49s
ci / web (pull_request) Successful in 1m3s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m38s
ci / rust (pull_request) Successful in 16m9s
Four reworks from the first palette-era on-glass review, all iOS-facing: - Surfaces carry the palette now, not just the text on them: ConsoleGlass washes every tier (Liquid Glass tint, pre-26 material, tvOS material) with ink.glass — the same colour the desktop console fills its panels with — and the close buttons move to an ink-aware consoleGlassBackground. The pre-26 branch also gains the focus tint it had silently dropped. Stray literals follow: ConnectOverlay text rides ink in the console takeover, card shadows soften on pale fields, the focused keycap reads onAccent. The online pip stays status-green on purpose. - The header breathes: title top padding 4/10 -> 10/18 plus shared header-spacing and title-bottom helpers mapped from the console shell's rhythm, applied to the launcher, settings and add-host alike, with the add-host close X re-anchored to the title row. - Settings, Add Host and the Library present IN PLACE on iOS: one persistent aurora whose calm is chased (the console's bg_mix), screens as transparent layers with the console's 0.26 s ease-out-cubic push/pop, an input drop for the transition, and the controller handed off through isActive — no more opaque bottom-up covers, no backdrop teardown. macOS keeps its sheets, tvOS its focus-engine covers. - The settings select is a real band: choice rows mount GamepadOptionBand, a spring-driven drum (Animatable body, ring-distance wrap, neighbours gated by focus and flight) whose retargeting spring accumulates rapid steps into one continuous spin. Reduce Motion falls back to a plain crossfade; toggles keep the quiet 14 pt slip. Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0, swift test 208 passed / 0 failed. On-glass QA still owed: palette sweep on a pale palette, transition compositing over materials, drum feel on device.
96 lines
4.7 KiB
Swift
96 lines
4.7 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 library(StoredHost)
|
||
|
||
var id: String {
|
||
switch self {
|
||
case .settings: return "settings"
|
||
case .addHost: return "addHost"
|
||
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: 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
|