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
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user