feat(client/apple): the gamepad UI moves and colours like the console it mirrors
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.
This commit is contained in:
2026-08-07 13:59:40 +02:00
parent f7ef41b45b
commit c010139e6e
15 changed files with 812 additions and 102 deletions
@@ -384,7 +384,13 @@ struct ContentView: View {
.frame(minWidth: 940, minHeight: 620)
}
#else
.fullScreenCover(item: $libraryTarget) { host in
// iOS: the cover is the TOUCH UI's presentation only. In gamepad mode the library is one
// of GamepadHomeView's in-place layers (the console shell no bottom-up cover), so the
// proxy hides the target from the cover while that mode owns it; every writer (Y on a
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
// controller arriving or leaving mid-browse hands the open library to whichever
// presentation the new mode owns.
.fullScreenCover(item: touchLibraryTarget) { host in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
}
@@ -401,6 +407,14 @@ struct ContentView: View {
Binding(get: { deepLinkNotice != nil }, set: { if !$0 { deepLinkNotice = nil } })
}
/// 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?> {
Binding(
get: { gamepadUIActive ? nil : libraryTarget },
set: { libraryTarget = $0 })
}
private var approvalChoicePresented: Binding<Bool> {
Binding(get: { approvalChoice != nil }, set: { if !$0 { approvalChoice = nil } })
}
@@ -558,7 +572,8 @@ struct ContentView: View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
} else {
HomeView(
store: store, model: model, discovery: discovery,
@@ -574,7 +589,8 @@ struct ContentView: View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered)
connect: { connect($0, profile: $1) }, connectDiscovered: connectDiscovered,
launchTitle: launchTitle)
// 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
@@ -83,6 +83,11 @@ struct ConnectOverlay: View {
}
}
/// The overlay's text/glyph colour: the palette's ink in the console takeover over a pale
/// aurora, literal white was the one console surface that stayed white-on-white and white
/// in the touch modal, whose branch is deliberately forced dark over a black scrim.
private var overlayFG: Color { gamepadUI ? ink.fg : .white }
@ViewBuilder private func content(_ phase: Phase) -> some View {
// The takeover carries larger type than the compact modal.
let titleSize: CGFloat = gamepadUI ? 24 : 19
@@ -90,21 +95,24 @@ struct ConnectOverlay: View {
VStack(spacing: gamepadUI ? 16 : 14) {
switch phase {
case .connecting(let name):
ProgressView().controlSize(.large).tint(.white)
ProgressView().controlSize(.large).tint(overlayFG)
Text("Connecting to \(name)")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
.multilineTextAlignment(.center)
Text("Establishing a secure connection…")
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
.font(.geist(bodySize, relativeTo: .caption))
.foregroundStyle(overlayFG.opacity(0.6))
Button("Cancel") { onCancelConnect() }.buttonStyle(.bordered).padding(.top, 6)
case .waking(let w) where w.timedOut:
Image(systemName: "moon.zzz.fill")
.font(.system(size: gamepadUI ? 40 : 34)).foregroundStyle(.white.opacity(0.9))
.font(.system(size: gamepadUI ? 40 : 34))
.foregroundStyle(overlayFG.opacity(0.9))
Text("\(w.hostName) didn't wake")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
.multilineTextAlignment(.center)
Text("It may still be booting, or it's powered off / off this network.")
.font(.geist(bodySize, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
.font(.geist(bodySize, relativeTo: .caption))
.foregroundStyle(overlayFG.opacity(0.6))
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
@@ -112,12 +120,13 @@ struct ConnectOverlay: View {
}
.padding(.top, 6)
case .waking(let w):
ProgressView().controlSize(.large).tint(.white)
ProgressView().controlSize(.large).tint(overlayFG)
Text("Waking \(w.hostName)")
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(.white)
.font(.geist(titleSize, .bold, relativeTo: .title3)).foregroundStyle(overlayFG)
.multilineTextAlignment(.center)
Text("Waiting for it to come online · \(w.seconds)s")
.font(.geistFixed(bodySize)).foregroundStyle(.white.opacity(0.6)).monospacedDigit()
.font(.geistFixed(bodySize)).foregroundStyle(overlayFG.opacity(0.6))
.monospacedDigit()
// A wake-only wait (no dial after) offers "Stop Waiting"; a wake-&-connect is "Cancel".
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
.buttonStyle(.bordered).padding(.top, 6)
@@ -14,7 +14,15 @@ import SwiftUI
struct GamepadAddHostView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
let onAdd: (StoredHost) -> Void
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet, the tvOS cover)
/// falls back to the environment dismiss. Declared AFTER `onAdd` so the existing trailing-
/// closure call sites keep binding to it, not to this.
var close: (() -> Void)?
/// Whether this screen owns the controller false while the shell is mid-transition or the
/// connect takeover is up (see GamepadSettingsView's twin).
var controllerActive = true
#if os(iOS)
/// `.compact` in a landscape phone window tighter chrome so the keyboard tray still fits.
@@ -36,8 +44,8 @@ struct GamepadAddHostView: View {
items: rows,
focusID: $focusID,
onActivate: { activate(id: $0.id) },
onBack: { dismiss() },
isActive: editing == nil
onBack: { performClose() },
isActive: controllerActive && editing == nil
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -45,10 +53,14 @@ struct GamepadAddHostView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 4) {
VStack(spacing: gamepadHeaderSpacing(compact: compact)) {
Text("Add Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.frame(maxWidth: .infinity)
// On the title row itself (not the header block) so it rides the title's own
// top padding the same anchoring the settings screen's close button uses.
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
if !compact {
Text("Hosts on this network appear automatically — add one by address "
+ "for everything else.")
@@ -59,9 +71,8 @@ struct GamepadAddHostView: View {
}
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity)
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
@@ -73,7 +84,10 @@ struct GamepadAddHostView: View {
.background { GamepadTrayScrim(edge: .bottom) }
}
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
.background { GamepadFormBackground() }
// Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
.background {
if !hostedInShell { GamepadFormBackground() }
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
@@ -141,15 +155,21 @@ struct GamepadAddHostView: View {
#endif
}
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
/// the environment dismiss under a macOS sheet / tvOS cover.
private func performClose() {
if let close { close() } else { dismiss() }
}
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc
/// rides the cancel action.
private var closeButton: some View {
Button { dismiss() } label: {
Button { performClose() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.consoleGlassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
@@ -237,7 +257,7 @@ struct GamepadAddHostView: View {
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(port) ?? 9777))
dismiss()
performClose()
default:
openKeyboard(id)
}
@@ -23,12 +23,36 @@ func buttonGlyph(
/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance the launcher
/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar
/// at all, so the iOS value hugs the top edge there.
/// at all. The other values follow the console shell's rhythm (title top = 18 design units,
/// k-floored to 10 for a landscape phone): the title needs air to the screen edge or the whole
/// header reads pressed against the bezel, which the tab strip's extra band made obvious.
func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
#if os(macOS)
26
#elseif os(tvOS)
24
#else
compact ? 4 : 10
compact ? 10 : 18
#endif
}
/// Padding under a gamepad screen's pinned header block (title, and the tab strip where there is
/// one) before the content: the console leaves ~14 units of air under its tab pills, and without
/// it the first row sits shoulder-to-shoulder with the header.
func gamepadTitleBottomPadding(compact: Bool) -> CGFloat {
#if os(tvOS)
16
#else
compact ? 8 : 12
#endif
}
/// Spacing between a header's stacked elements (title over tab strip / subtitle).
func gamepadHeaderSpacing(compact: Bool) -> CGFloat {
#if os(tvOS)
13
#else
compact ? 6 : 10
#endif
}
@@ -60,6 +84,7 @@ enum GamepadFormMetrics {
static let detailFont: CGFloat = 19
static let closeFont: CGFloat = 20
static let closeSide: CGFloat = 48
static let bandWidth: CGFloat = 380
#else
static let headerFont: CGFloat = 12
static let labelFont: CGFloat = 16
@@ -74,6 +99,8 @@ enum GamepadFormMetrics {
static let detailFont: CGFloat = 13
static let closeFont: CGFloat = 14
static let closeSide: CGFloat = 34
/// The option band's (GamepadOptionBand) fixed stage inside a choice row.
static let bandWidth: CGFloat = 240
#endif
}
@@ -147,8 +174,21 @@ struct GamepadHintBar: View {
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.gamepadInk) private var ink
/// Quiet the field for a form screen (see the type comment).
var calm = false
/// How far toward the form screens' quiet the field sits: 0 = the launcher's full aurora,
/// 1 = calm, fractional mid-chase. Continuous (not a Bool) so the in-place shell can CHASE
/// it during a push/pop the console does the same with its `bg_mix` and every
/// calm-dependent factor below rides an `.opacity` modifier, which animates reliably where
/// re-built gradient stops do not.
var calmMix: Double
/// The Bool spelling every non-shell call site uses (see the type comment for `calm`).
init(calm: Bool = false) {
calmMix = calm ? 1 : 0
}
init(calmMix: Double) {
self.calmMix = calmMix
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
@@ -184,21 +224,22 @@ struct GamepadScreenBackground: View {
// ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8))
// Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply
.opacity(calm ? 0.6 : 1)
if calm {
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the
// ground lands exactly where it was and the bright pools come down to meet it.
Self.color(palette.ground)
.opacity(0.4)
.blendMode(.plusLighter)
}
.opacity(1 - 0.4 * calmMix)
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the
// ground lands exactly where it was and the bright pools come down to meet it.
// Mounted unconditionally at opacity 0 a plusLighter layer contributes nothing,
// and an always-present layer is what lets the mix animate instead of popping.
Self.color(palette.ground)
.opacity(0.4 * calmMix)
.blendMode(.plusLighter)
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the
// pooled light. Soft (extends past the frame) so the corners deepen rather than
// crush. Halved under calm: a launcher's cards sit in the pooled centre, but a form
// screen's rows run out toward the edges, where crushing them just eats the list.
EllipticalGradient(
colors: [.clear, scrim.opacity((calm ? 0.21 : 0.42) * strength)],
colors: [.clear, scrim.opacity(0.42 * strength)],
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
.opacity(1 - 0.5 * calmMix)
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
// works on the field itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one.
@@ -74,6 +74,9 @@ struct GamepadHomeView: View {
@ObservedObject var waker: HostWaker
let connect: (StoredHost, ProfileSelection) -> Void
let connectDiscovered: (DiscoveredHost) -> Void
/// 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
/// 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).
@@ -93,29 +96,51 @@ struct GamepadHomeView: View {
private let compact = false // no size classes on macOS; the window minimum keeps room
#endif
@ObservedObject private var gamepads = GamepadManager.shared
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var selection: GamepadHomeTarget?
@State private var showSettings = false
@State private var showAddHost = false
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
/// the controller a double-tapped A can't push two screens, and the held button that
/// caused the change is long released before the next poller starts (whose own
/// `needsSnapshot` seed swallows it if not).
@State private var transitioning = false
/// Guards the gate's release against an interrupted transition: only the newest hold clears.
@State private var transitionEpoch = 0
var body: some View {
GeometryReader { geo in
hero(for: geo.size)
// The in-place shell (see GamepadShell.swift): the launcher is the base layer, the
// current sub-screen a transparent layer over it, both over ONE persistent backdrop
// that never unmounts a push slides the screen up out of a fade while the launcher
// recedes underneath, the console's own choreography. On macOS/tvOS `topScreen` is
// constantly nil and this ZStack degenerates to the plain launcher, presented over by
// the sheets/covers below exactly as before.
ZStack {
homeLayer
.opacity(covered ? 0 : 1)
.scaleEffect(covered ? GamepadShellMotion.underScale : 1)
// The covers used to swallow touch; the recessed layer must too.
.allowsHitTesting(!covered)
#if os(iOS)
if let screen = topScreen {
screenLayer(screen)
.zIndex(1)
.id(screen.id)
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
}
#endif
}
// Pinned inside the safe area, out of the carousel's vertical budget never clipped.
.safeAreaInset(edge: .top, spacing: 0) {
titleBar
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
// Value-keyed rather than `withAnimation` at the triggers: pushes originate outside
// this view too (`model.returnToLibrary` writes `libraryTarget`), and keying on the
// derived id catches every writer. Reduce Motion snaps.
.animation(reduceMotion ? nil : GamepadShellMotion.screen, value: topScreenID)
// ONE living field for every layer, still a `.background` (the layout rule in this
// file's header). Its calm is CHASED between the launcher's aurora and the form
// screens' quiet, never crossfaded per screen the console's `bg_mix`.
.background {
GamepadScreenBackground(calmMix: calmTarget)
.animation(reduceMotion ? nil : GamepadShellMotion.calm, value: calmTarget)
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints)
// Equal distance from the left and bottom edges the pill's corner inset was the
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
.padding(.leading, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 4 : 8)
}
.background { GamepadScreenBackground() }
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
@@ -129,6 +154,17 @@ struct GamepadHomeView: View {
try? await Task.sleep(for: .seconds(10))
}
}
#if os(iOS)
.onChange(of: topScreenID) { _, _ in
transitionEpoch += 1
let epoch = transitionEpoch
transitioning = true
let hold = reduceMotion ? 0.05 : GamepadShellMotion.duration + 0.02
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
if epoch == transitionEpoch { transitioning = false }
}
}
#endif
// The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces
// X, and historically tvOS maps a pad's X to this same press the poll and this command
// double-firing just sets the same Bool twice.
@@ -136,8 +172,9 @@ struct GamepadHomeView: View {
.onPlayPauseCommand { showSettings = true }
#endif
// The settings / add-host screens take over the controller (the carousel's `isActive`
// gate above). iOS presents them full screen the immersive console feel; macOS has no
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
// gate above). macOS has no fullScreenCover they are generously sized sheets over the
// dimmed launcher; tvOS keeps its focus-engine covers. iOS needs nothing here: the
// shell's layers above ARE the presentation.
#if os(macOS)
.sheet(isPresented: $showSettings) {
GamepadSettingsView(store: store)
@@ -148,7 +185,7 @@ struct GamepadHomeView: View {
.frame(width: 660, height: 620)
}
.frame(minWidth: 640, minHeight: 420)
#else
#elseif os(tvOS)
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
.fullScreenCover(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
@@ -156,6 +193,110 @@ struct GamepadHomeView: View {
#endif
}
// MARK: - The shell's layers (see GamepadShell.swift)
/// The launcher itself everything the pre-shell body was, minus the backdrop (hoisted to
/// the shell) and the presentation modifiers (below).
private var homeLayer: some View {
GeometryReader { geo in
hero(for: geo.size)
}
// Pinned inside the safe area, out of the carousel's vertical budget never clipped.
.safeAreaInset(edge: .top, spacing: 0) {
titleBar
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints)
// Equal distance from the left and bottom edges the pill's corner inset was the
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
.padding(.leading, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 4 : 8)
}
}
#if os(iOS)
/// The screen the shell shows over the launcher derived from the same triggers every
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
private var topScreen: GamepadScreen? {
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
return nil
}
@ViewBuilder private func screenLayer(_ screen: GamepadScreen) -> some View {
// The layer owns the controller only once the push settles and nothing rides over the
// shell (the connect/wake takeover is an overlay in ContentView, above these layers).
let active = !transitioning && waker.waking == nil && model.phase != .connecting
Group {
switch screen {
case .settings:
GamepadSettingsView(
store: store,
close: { if !transitioning { showSettings = false } },
controllerActive: active)
case .addHost:
GamepadAddHostView(
onAdd: { store.add($0) },
close: { if !transitioning { showAddHost = false } },
controllerActive: active)
case .library(let host):
GamepadLibraryScreen(
store: store, host: host,
onLaunch: { launchTitle(host, $0) },
close: { if !transitioning { libraryTarget = nil } },
controllerActive: active)
}
}
.environment(\.gamepadHostedInShell, true)
}
#endif
private var covered: Bool {
#if os(iOS)
topScreen != nil
#else
false
#endif
}
private var topScreenID: String? {
#if os(iOS)
topScreen?.id
#else
nil
#endif
}
/// The backdrop's calm target: 1 under a form screen, 0 under the launcher/library. The
/// macOS sheets / tvOS covers mount their own calmed field, so the launcher behind them
/// keeps its aurora exactly what shipped.
private var calmTarget: Double {
#if os(iOS)
topScreen?.isForm == true ? 1 : 0
#else
0
#endif
}
/// Stop consuming the controller while another screen (or the connect/wake takeover) is on
/// top otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
/// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
/// takeover's Connecting phase; `waker.waking` its Waking phase. On iOS the shell adds the
/// transition's input drop, during which NOBODY polls.
private var homeOwnsController: Bool {
#if os(iOS)
topScreen == nil && !transitioning
&& waker.waking == nil && model.phase != .connecting
#else
libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
#endif
}
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
@ViewBuilder private func hero(for size: CGSize) -> some View {
@@ -229,12 +370,7 @@ struct GamepadHomeView: View {
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
// Stop consuming the controller while another screen (or the connect/wake takeover) is on
// top otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
// takeover's Connecting phase; `waker.waking` covers its Waking phase.
isActive: libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
isActive: homeOwnsController
) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
}
@@ -402,10 +538,15 @@ private struct GamepadHostTile: View {
.foregroundStyle(ink.fg(0.5))
}
if tile.isOnline {
// Status colours stay palette-independent (a pip must not change meaning
// with the wallpaper) only the glow softens on a pale field, where it
// reads as a smudge at full strength.
Circle()
.fill(Color.green)
.fill(GamepadInk.onlineGreen)
.frame(width: Self.pipSide, height: Self.pipSide)
.shadow(color: .green.opacity(0.7), radius: 5)
.shadow(
color: GamepadInk.onlineGreen.opacity(ink.isLight ? 0.45 : 0.7),
radius: 5)
}
}
}
@@ -441,7 +582,7 @@ private struct GamepadHostTile: View {
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
.shadow(color: ink.shadow(0.45), radius: 20, y: 14)
}
private var monogramBadge: some View {
@@ -37,6 +37,12 @@ struct GamepadInk: Equatable, Sendable {
func accent(_ alpha: Double) -> Color { accent.opacity(alpha) }
/// A wash under text: `alpha` is the dark-field strength, scaled for a pale one.
func shade(_ alpha: Double) -> Color { shade.opacity(alpha * shadeScale) }
/// The glass base at `alpha` what a surface's material is washed with so it carries the
/// palette's hue (the console fills its panels with exactly this colour).
func glass(_ alpha: Double) -> Color { glass.opacity(alpha) }
/// A drop shadow: always black a white shadow is not a shadow but softened on a pale
/// field, where full-strength black under every card reads as a smear rather than depth.
func shadow(_ alpha: Double) -> Color { .black.opacity(alpha * (isLight ? 0.4 : 1)) }
static func of(_ p: GamepadPalette) -> GamepadInk {
let accent = Color(red: p.accent.x, green: p.accent.y, blue: p.accent.z)
@@ -60,6 +66,10 @@ struct GamepadInk: Equatable, Sendable {
/// The shipped dark look what a preview or a test composition gets.
static let dark = GamepadInk.of(GamepadPalette.named("violet"))
/// The online pip deliberately NOT palette-derived: a status colour must not change
/// meaning with the wallpaper (the console's rule; this is its `ONLINE_GREEN` verbatim).
static let onlineGreen = Color(red: 0.20, green: 0.84, blue: 0.29)
}
private struct GamepadInkKey: EnvironmentKey {
@@ -111,7 +111,9 @@ struct GamepadKeyboard: View {
.font(.geist(15, .semibold, relativeTo: .callout))
}
}
.foregroundStyle(focused ? Color.black : ink.fg)
// The focused keycap sits on `ink.accent`, so `onAccent` is what reads on it a dark
// accent palette got black-on-dark with the old literal black.
.foregroundStyle(focused ? ink.onAccent : ink.fg)
.frame(maxWidth: .infinity, minHeight: compact ? 34 : 42)
.background {
RoundedRectangle(cornerRadius: 9, style: .continuous)
@@ -0,0 +1,61 @@
// The library as one of the gamepad shell's in-place layers (iOS): console chrome a pinned
// title and a close styled like the settings screen's around the shared LibraryView, whose
// gamepad branch renders the coverflow. The cover presentation used to get its title and Close
// from the wrapping NavigationStack's bar; a shell layer has no bar, so this restores both in
// the console's own grammar. Everything data-shaped (the fetch, the loading/error/empty states,
// the image session lifecycle) stays LibraryView's.
import PunktfunkKit
import SwiftUI
#if os(iOS)
struct GamepadLibraryScreen: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
let host: StoredHost
let onLaunch: (String) -> Void
let close: () -> Void
var controllerActive = true
/// `.compact` in a landscape phone window tighter chrome, like every gamepad screen.
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
var body: some View {
LibraryView(
store: store, host: host, onLaunch: onLaunch,
onClose: close, controllerActive: controllerActive)
.safeAreaInset(edge: .top, spacing: 0) {
Text("\(host.displayName) — Library")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayScrim(edge: .top) }
}
.gamepadPaletteInk()
}
/// Touch/click fallback for closing the controller path is B (the coverflow's onDismiss),
/// and it also covers the loading/error/empty states, which the coverflow (and its B) never
/// mounts under. A hardware keyboard's Esc rides the cancel action.
private var closeButton: some View {
Button { close() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.consoleGlassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
.keyboardShortcut(.cancelAction)
.accessibilityLabel("Close library")
}
}
#endif
@@ -0,0 +1,95 @@
// 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
@@ -26,6 +26,11 @@ struct LibraryCoverflowView: View {
/// Button B (back) dismisses the library screen. No touch equivalent needed here (the toolbar
/// Close button already covers that); this is what makes gamepad-only exit possible.
var onDismiss: (() -> Void)?
/// Whether the carousel owns the controller the in-place shell gates it (mid-transition,
/// and under the connect takeover after A launches a title, where this coverflow used to
/// keep polling underneath). Cover/sheet presentations keep the default.
var controllerActive = true
@Environment(\.gamepadHostedInShell) private var hostedInShell
#if os(iOS)
/// `.compact` in a landscape phone window drives a tighter poster so everything still fits.
@@ -46,7 +51,11 @@ struct LibraryCoverflowView: View {
.padding(.leading, 22)
.padding(.vertical, compact ? 6 : 10)
}
.background { GamepadScreenBackground() }
// Hosted in the shell, the field is the shell's own persistent aurora (the library is
// an aurora screen the calm mix simply stays 0, so nothing even chases).
.background {
if !hostedInShell { GamepadScreenBackground() }
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
@@ -81,7 +90,8 @@ struct LibraryCoverflowView: View {
spacing: 34,
onActivate: { onLaunch?($0.id) },
onBack: { onDismiss?() },
shoulderJump: 5
shoulderJump: 5,
isActive: controllerActive
) { game in
cover(game, width: coverWidth, height: coverHeight)
}
@@ -103,7 +113,7 @@ struct LibraryCoverflowView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(ink.fg(0.12), lineWidth: 1)
}
.shadow(color: .black.opacity(0.5), radius: 16, y: 12)
.shadow(color: ink.shadow(0.5), radius: 16, y: 12)
.scrollTransition { content, phase in
let v = phase.value
let d = CGFloat(min(abs(v), 1))
@@ -12,6 +12,13 @@ struct LibraryView: View {
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
/// through). `nil` browse-only (cards aren't tappable).
var onLaunch: ((String) -> Void)? = nil
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil every sheet/cover
/// presentation falls back to the environment dismiss.
var onClose: (() -> Void)? = nil
/// Whether the gamepad coverflow owns the controller the shell gates it during a push/pop
/// and while the connect takeover is up. Presentations that cover the launcher keep the
/// default (their being up IS the launcher's gate).
var controllerActive = true
@Environment(\.dismiss) private var dismiss
@State private var games: [GameEntry] = []
@@ -72,7 +79,8 @@ struct LibraryView: View {
if gamepadUIActive {
LibraryCoverflowView(
games: games, imageSession: imageSession, onLaunch: onLaunch,
onDismiss: { dismiss() })
onDismiss: { (onClose ?? { dismiss() })() },
controllerActive: controllerActive)
} else {
grid
}
@@ -243,7 +243,7 @@ private struct ShotGamepadHome: View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in })
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
}
}
@@ -301,7 +301,7 @@ private struct ShotConnect: View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _, _ in }, connectDiscovered: { _ in })
connect: { _, _ in }, connectDiscovered: { _ in }, launchTitle: { _, _ in })
} else {
ShotHome()
}
@@ -0,0 +1,187 @@
// The gamepad settings' "select" value as a REAL band: every option sits on a drum rotating
// about a vertical axis the current one faces you flat, its neighbours curve away with
// perspective, shrinking and fading toward the edges. The old presentation animated a single
// Text keyed by its value (an old-out/new-in crossfade that merely implied motion), which fell
// apart under fast repeated steps: each press restarted the fade. Here the drum's position is
// one continuous value driven by a spring, and SwiftUI's spring retargeting preserves velocity
// rapid presses accumulate into one accelerating spin instead of five restarted crossfades.
//
// The band is purely presentational: stepping semantics (left/right clamps with a boundary thud,
// A cycles forward wrapping, disabled rows refuse input) stay in GamepadSettingsView's row
// closures. Font and ink come from the environment the row applies the same value font/colour
// it always did, and the drum's own opacity ramp multiplies on top.
import Foundation
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
struct GamepadOptionBand: View {
let options: [String]
/// The committed selection the caller's clamp/wrap already applied.
let selection: Int
let focused: Bool
/// The band's footprint, FIXED by the row: a step must never reflow the row (the old
/// free-width value shifted the chevrons with every label), and the drum needs its stage
/// even when the facing label is short.
let width: CGFloat
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Where the drum rests, in option steps UNBOUNDED: a forward wrap keeps adding 1, never
/// modded back, so the ring distance below is what brings option 0 around from the right.
/// Rendering only ever reads it modulo `options.count`.
@State private var drumPosition: Double
init(options: [String], selection: Int, focused: Bool, width: CGFloat) {
self.options = options
self.selection = selection
self.focused = focused
self.width = width
_drumPosition = State(initialValue: Double(selection))
}
var body: some View {
Group {
if reduceMotion {
// No drum, no travel: today's quiet crossfade, minus even the 14 pt slip.
ZStack {
Text(current)
.lineLimit(1)
.id(selection)
.transition(.opacity)
}
.animation(.smooth(duration: 0.2), value: selection)
} else {
Drum(
options: options,
rotation: drumPosition,
neighborGate: focused ? 1 : 0,
target: drumPosition,
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge.
radius: width * 0.72)
}
}
.frame(width: width)
.clipped()
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut.
.mask {
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: .black, location: 0.12),
.init(color: .black, location: 0.88),
.init(color: .clear, location: 1),
],
startPoint: .leading, endPoint: .trailing)
}
.onChange(of: selection) { old, new in step(from: old, to: new) }
// The options list itself can mutate under the drum (a custom resolution appears, a
// controller connects, the buffer options re-derive from a new refresh rate) the ring
// math is only valid while drumPosition selection (mod count), so re-seat without a spin.
.onChange(of: options.count) { _, _ in snap() }
// One element to VoiceOver the neighbour texts are rendering, not content.
.accessibilityElement(children: .ignore)
.accessibilityLabel(current)
}
private var current: String {
options.indices.contains(selection) ? options[selection] : ""
}
/// One step spins the drum; anything else (an external write from the touch settings, a
/// re-derived options list) re-seats it a spin to a value the user didn't step to would
/// read as the UI acting on its own.
private func step(from old: Int, to new: Int) {
let n = options.count
let raw = new - old
let delta: Int? = if n > 1 && old == n - 1 && new == 0 {
1 // A's wrap from the last option: keep spinning FORWARD, the way the thumb pressed.
} else if abs(raw) == 1 {
raw
} else {
nil
}
guard let delta, !reduceMotion else { return snap() }
withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {
drumPosition += Double(delta)
}
}
private func snap() {
var tx = Transaction()
tx.disablesAnimations = true
withTransaction(tx) { drumPosition = Double(selection) }
}
}
/// The rotating drum itself. `Animatable` so SwiftUI re-evaluates the body with the INTERPOLATED
/// rotation every frame of the spring each option's offset/scale/opacity follows the real arc,
/// and options more than one step away genuinely enter and leave mid-spin. (A plain `.animation`
/// on independent modifiers can't do that: each modifier would lerp its own endpoints and the
/// in-between options would never appear.)
private struct Drum: View, Animatable {
let options: [String]
/// The interpolated drum position, in option steps.
var rotation: Double
/// 1 while the row is focused the resting drum shows its neighbours only under focus (an
/// unfocused row is one flat Text, visually and costwise what it was before the band).
var neighborGate: Double
/// Where the spring is headed (jumps instantly on a step; only `rotation` chases it). The
/// distance between them is "how mid-flight are we" it keeps the neighbours visible while
/// an unfocused drum finishes settling, fading them continuously as it lands.
let target: Double
/// Drum radius in points (from the band width see the caller).
let radius: Double
var animatableData: AnimatablePair<Double, Double> {
get { AnimatablePair(rotation, neighborGate) }
set {
rotation = newValue.first
neighborGate = newValue.second
}
}
/// Angular pitch between adjacent options on the drum.
private static let stepAngle = 34.0 * .pi / 180.0
var body: some View {
let n = options.count
let flight = min(1, abs(rotation - target) * 3)
let content = ZStack {
ForEach(0..<n, id: \.self) { i in
// Signed ring distance to the drum position, wrapped into (-n/2, n/2] the
// whole wrap story: a monotonically grown position brings option 0 around from
// the right of the last option with no special casing.
let d = (Double(i) - rotation).remainder(dividingBy: Double(n))
if abs(d) <= 2.5 {
option(i, distance: d, gate: max(neighborGate, flight))
}
}
}
#if os(tvOS)
// Flatten the transform stack while spinning the 10-foot GPU already made these rows
// drop Liquid Glass, and five projected texts per step is the same class of cost.
content.drawingGroup()
#else
content
#endif
}
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
let angle = d * Self.stepAngle
let depth = cos(angle)
// The facing option never gates: an unfocused row still shows its value.
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
Text(options[i])
.lineLimit(1)
.scaleEffect(0.70 + 0.30 * depth)
// Foreshorten the label as it turns away this is what sells the cylinder.
.rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4)
.offset(x: radius * sin(angle))
.opacity(alpha)
.zIndex(depth)
}
}
#endif
@@ -47,10 +47,18 @@ enum GpSettingsTab: String, CaseIterable, Hashable {
struct GamepadSettingsView: View {
@Environment(\.gamepadInk) private var ink
@Environment(\.dismiss) private var dismiss
@Environment(\.gamepadHostedInShell) private var hostedInShell
/// The saved-host store the pin picker writes `setPinned` through it and the profile rows
/// count pins from its live hosts. Threaded in from GamepadHomeView like the home screen
/// itself (ContentView owns the instance).
@ObservedObject var store: HostStore
/// How the in-place shell (iOS) closes this screen; nil (the macOS sheet, the tvOS cover)
/// falls back to the environment dismiss. See `performClose`.
var close: (() -> Void)?
/// Whether this screen owns the controller. The shell holds it false during a push/pop (the
/// console's input drop) and while the connect takeover is up; a system presentation never
/// needs the gate and keeps the default.
var controllerActive = true
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@@ -127,7 +135,8 @@ struct GamepadSettingsView: View {
onAdjust: { row, delta in adjust(id: row.id, by: delta) },
onActivate: { activate(id: $0.id) },
onBack: { back() },
onShoulder: { step(tabBy: $0) }
onShoulder: { step(tabBy: $0) },
isActive: controllerActive
) { row, focused in
rowView(row, focused: focused)
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
@@ -135,7 +144,7 @@ struct GamepadSettingsView: View {
}
.frame(maxWidth: .infinity)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: compact ? 4 : 8) {
VStack(spacing: gamepadHeaderSpacing(compact: compact)) {
Text(title)
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
@@ -146,7 +155,7 @@ struct GamepadSettingsView: View {
if pinTarget == nil { tabStrip }
}
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
@@ -168,8 +177,12 @@ struct GamepadSettingsView: View {
}
// The launcher's living field, calmed (GamepadFormBackground) the glass rows keep real
// colour and luminance to lens without the launcher's contrast, and the palette setting
// applies here too, so this screen previews the row you're stepping.
.background { GamepadFormBackground() }
// applies here too, so this screen previews the row you're stepping. Hosted in the
// shell, the field is the SHELL's (one persistent backdrop, calm-chased) mounting a
// second would double the mesh and snap where the shell crossfades.
.background {
if !hostedInShell { GamepadFormBackground() }
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
@@ -275,15 +288,21 @@ struct GamepadSettingsView: View {
focusID = landing
}
/// Close this screen through whichever mechanism presents it: the shell's layer pop on iOS,
/// the environment dismiss under a macOS sheet / tvOS cover.
private func performClose() {
if let close { close() } else { dismiss() }
}
/// Touch/click fallback for closing the controller path is B, a hardware keyboard's Esc
/// rides the cancel action.
private var closeButton: some View {
Button { dismiss() } label: {
Button { performClose() } label: {
Image(systemName: "xmark")
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
.foregroundStyle(ink.fg)
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
.glassBackground(Circle(), interactive: true)
.consoleGlassBackground(Circle(), interactive: true)
.contentShape(Circle())
}
.buttonStyle(.plain)
@@ -338,7 +357,7 @@ struct GamepadSettingsView: View {
pinTarget = nil
focusID = "profile-\(profile.id)"
} else {
dismiss()
performClose()
}
}
@@ -364,24 +383,33 @@ struct GamepadSettingsView: View {
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(
ink.fg(focused && row.adjustable && row.enabled ? 0.6 : 0))
// Keyed by the value so a change slides the new option in instead of
// hard-swapping the string a QUIET horizontal slip following the user's
// motion (a right-step enters from the right), crossfading over ~14 pt.
// Deliberately not `.push`: that travels the whole container width, loud
// and visibly outside the row. The ZStack is the stable home the
// removed/inserted texts transition within.
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
ZStack {
Text(row.value)
if let labels = row.optionLabels, let idx = row.selectedIndex {
// A choice row's value is a REAL band the options ride a rotating
// drum, so fast repeated steps spin it instead of restarting a fade.
GamepadOptionBand(
options: labels, selection: idx, focused: focused, width: bandWidth)
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
.lineLimit(1)
.id(row.value)
.transition(.asymmetric(
insertion: .offset(x: slide).combined(with: .opacity),
removal: .offset(x: -slide).combined(with: .opacity)))
} else {
// Toggles and the flat rows keep the quiet slip: keyed by the value so
// a change slides the new string in following the user's motion (a
// right-step enters from the right), crossfading over ~14 pt. The
// ZStack is the stable home the removed/inserted texts transition
// within. (A two-position switch on a drum would read as a coin flip
// both sibling clients keep toggles a different control, too.)
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
ZStack {
Text(row.value)
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
.lineLimit(1)
.id(row.value)
.transition(.asymmetric(
insertion: .offset(x: slide).combined(with: .opacity),
removal: .offset(x: -slide).combined(with: .opacity)))
}
.animation(.smooth(duration: 0.22), value: row.value)
}
.animation(.smooth(duration: 0.22), value: row.value)
Image(systemName: "chevron.right")
.font(.system(size: m.chevronFont, weight: .semibold))
.foregroundStyle(
@@ -411,6 +439,17 @@ struct GamepadSettingsView: View {
rows.first { $0.id == focusID }?.detail ?? " "
}
/// The option band's fixed stage. A portrait phone is the one place the full 240 pt starves
/// the row's label (everywhere else the 620 pt row cap leaves room to spare), so it alone
/// narrows the stage.
private var bandWidth: CGFloat {
#if os(iOS)
hSizeClass == .compact && vSizeClass == .regular ? 170 : GamepadFormMetrics.bandWidth
#else
GamepadFormMetrics.bandWidth
#endif
}
// MARK: - Row model
private struct Row: Identifiable {
@@ -423,6 +462,11 @@ struct GamepadSettingsView: View {
let value: String
/// One-line explanation shown near the hint bar while this row is focused.
let detail: String
/// A choice row's full option list (labels only the tags stay inside the closures)
/// and where its drum currently rests. nil the value renders as plain text (toggles,
/// actions, profiles a two-position switch is not a drum; see GamepadOptionBand).
var optionLabels: [String]?
var selectedIndex: Int?
/// Whether left/right means anything here false hides the value's chevrons (the
/// Profiles rows navigate, and the placeholder rows do nothing at all).
var adjustable = true
@@ -793,6 +837,10 @@ struct GamepadSettingsView: View {
id: id, tab: tab, icon: icon, label: label,
value: index.map { options[$0].label } ?? "",
detail: detail,
// The band mounts only once the value is a known option the "" of an unknown
// current renders flat, and the first step's snap-to-first seats the drum.
optionLabels: index != nil ? options.map(\.label) : nil,
selectedIndex: index,
enabled: enabled,
adjust: { delta in
// Unknown current value: snap to the first option on any step.
@@ -70,12 +70,17 @@ extension View {
// MARK: - Console glass (gamepad host tiles + settings rows)
/// Liquid Glass tuned for the gamepad UI's dark "console" surfaces the host-carousel tiles and
/// Liquid Glass tuned for the gamepad UI's "console" surfaces the host-carousel tiles and
/// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately
/// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the
/// glass toward a color (the brand violet on the focused / primary surface); `interactive` makes
/// it flex on press. The pre-26 fallback is `.ultraThinMaterial` forced dark these surfaces
/// always sit on the near-black backdrop, so the material must stay dark even in a light appearance.
/// glass toward a color (the palette accent on the focused / primary surface); `interactive` makes
/// it flex on press.
///
/// Every tier is WASHED with the palette's `ink.glass` the same surface colour the console
/// fills its panels with so switching the background palette recolours the surfaces, not just
/// the text on them. The wash alphas are tune-on-device values with one fixed direction: the
/// pale palettes' white frost needs MORE body than the dark glass (the console's 0.66-vs-0.62
/// pair), because a thin white wash over a colourful field reads as haze, not as a surface.
private struct ConsoleGlass<S: Shape>: ViewModifier {
let shape: S
var tint: Color?
@@ -86,16 +91,19 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
@Environment(\.gamepadInk) private var ink
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
/// The palette wash over the material tiers (the material itself supplies the blur body).
private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) }
func body(content: Content) -> some View {
#if os(tvOS)
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
// Apple TV's GPU (same class of call GlassProminentButton already makes glass fights
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
// the 10-foot platform). The wash and tint ride overlays two flat fills, no GPU cost.
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
.overlay {
if let tint { shape.fill(tint) }
}
@@ -104,7 +112,14 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
if #available(iOS 26, macOS 26, *) {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme)
} else {
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, scheme) }
content.background {
shape.fill(.ultraThinMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(materialWash) }
.overlay {
if let tint { shape.fill(tint) }
}
}
}
#endif
}
@@ -112,8 +127,13 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
#if !os(tvOS)
@available(iOS 26, macOS 26, *)
private var glass: Glass {
var g: Glass = .regular
if let tint { g = g.tint(tint) }
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share
// it: mixed 60 % toward the caller's (the focused row must still read accented on
// every palette) over the palette base. If device QA finds the mixed focus wash too
// weak, the escape hatch is `tint ?? wash` today's focused look, bit for bit.
let wash = ink.glass(ink.isLight ? 0.60 : 0.45)
var g: Glass = .regular.tint(
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash)
if interactive { g = g.interactive() }
return g
}
@@ -121,9 +141,51 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
}
extension View {
/// Liquid Glass for a dark console surface (a host tile / settings row), or `.ultraThinMaterial`
/// (forced dark) pre-26. Pass the surface's shape explicitly glass defaults to a Capsule.
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial`
/// pre-26 both washed with the palette's own glass colour, both frosting to the palette's
/// scheme. Pass the surface's shape explicitly glass defaults to a Capsule.
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
}
}
// MARK: - Console floating glass (the gamepad screens' close buttons)
/// `glassBackground` for a floating control INSIDE the gamepad UI (the close ): same shape
/// contract, but washed with the palette's ink and frosted to the palette's scheme plain
/// `glassBackground` follows the SYSTEM appearance, which leaves the frost dark under dark ink
/// when a pale palette is up. The non-gamepad floating surfaces (the HUD, the trust card, the
/// touch connect modal) keep plain `glassBackground`: they sit over video or the touch UI,
/// where the palette means nothing.
private struct ConsoleGlassBackground<S: Shape>: ViewModifier {
let shape: S
var interactive = false
@Environment(\.gamepadInk) private var ink
private var scheme: ColorScheme { ink.isLight ? .light : .dark }
func body(content: Content) -> some View {
if #available(iOS 26, macOS 26, tvOS 26, *) {
content
.glassEffect(
(interactive ? Glass.regular.interactive() : .regular)
.tint(ink.glass(ink.isLight ? 0.60 : 0.45)),
in: shape)
.environment(\.colorScheme, scheme)
} else {
content.background {
shape.fill(.regularMaterial)
.environment(\.colorScheme, scheme)
.overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) }
}
}
}
}
extension View {
/// Palette-washed floating glass for the gamepad screens' own controls. Same fallback story
/// as `glassBackground` (`.regularMaterial` pre-26), plus the ink wash and scheme flip.
func consoleGlassBackground<S: Shape>(_ shape: S, interactive: Bool = false) -> some View {
modifier(ConsoleGlassBackground(shape: shape, interactive: interactive))
}
}