fix(client/apple): the gamepad UI answers its first field test
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 19s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m48s
ci / docs-site (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 4m25s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / bun-nix (pull_request) Successful in 19s
apple / swift (pull_request) Successful in 1m22s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m48s
ci / docs-site (pull_request) Successful in 1m53s
ci / rust (pull_request) Successful in 4m25s
Six findings from the on-device pass over #91, all iOS-facing: - The tray blurs no longer grow into place on a push: the screen layer resolves its internal layout (safe-area trays, the scrims' full-bleed) in a geometryGroup BEFORE the insertion animates. - The option band is LINEAR now, not a ring. A ring showed the first option waiting to the right of the last one - unreachable, since left/right clamps - and on a 2-option ring the unselected item flipped sides with every step (the 60/120 Hz row). Positions are fixed, the ends are the ends, and A's wrap travels back across the list. Options other than the facing one exist only while the drum is moving, so a long label never sits under a resting neighbour as overlapping text. - Toggles (and the pin rows) ride the band too: Off left of On, matching the left-off/right-on step semantics. - The close X is gone from settings, add-host and the library - a gamepad UI exits with B. A chromeless cancel button keeps hardware Esc and the macOS sheet working, and the library's loading/error/empty states gain a zero-size B listener so a controller-only user is never trapped where the coverflow (and its B) doesn't exist yet. - The heading is a real heading: leading-aligned with the 24 pt content inset, 24/34 pt (was 20/30), top margin 18/28 (was 10/18) - launcher, settings, add-host and library alike. The launcher's hidden-mirror chip trick died with the centred title that needed it. Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0, swift test 208 passed / 0 failed.
This commit is contained in:
@@ -53,26 +53,24 @@ struct GamepadAddHostView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like every gamepad heading — and no close chrome (B is the exit).
|
||||
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.")
|
||||
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
@@ -95,6 +93,18 @@ struct GamepadAddHostView: View {
|
||||
.onChange(of: port) { _, value in
|
||||
if value.count > 5 { port = String(value.prefix(5)) }
|
||||
}
|
||||
#if !os(tvOS)
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
.background {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom
|
||||
// tray — the remote and the pad both drive it natively. Same `editing` state as the
|
||||
@@ -161,24 +171,6 @@ struct GamepadAddHostView: View {
|
||||
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 { performClose() } 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)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||
#endif
|
||||
.accessibilityLabel("Cancel")
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
private struct Row: Identifiable {
|
||||
|
||||
@@ -32,7 +32,7 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
||||
#elseif os(tvOS)
|
||||
24
|
||||
#else
|
||||
compact ? 10 : 18
|
||||
compact ? 18 : 28
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -57,12 +57,13 @@ func gamepadHeaderSpacing(compact: Bool) -> CGFloat {
|
||||
}
|
||||
|
||||
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
|
||||
/// in-hand compact-aware sizes elsewhere.
|
||||
/// in-hand compact-aware sizes elsewhere. Sized as a proper screen heading — the field verdict
|
||||
/// on the smaller first cut was "way too small" once the title moved off-centre.
|
||||
func gamepadTitleSize(compact: Bool) -> CGFloat {
|
||||
#if os(tvOS)
|
||||
44
|
||||
#else
|
||||
compact ? 20 : 30
|
||||
compact ? 24 : 34
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -82,8 +83,6 @@ enum GamepadFormMetrics {
|
||||
static let rowCorner: CGFloat = 18
|
||||
static let rowMaxWidth: CGFloat = 920
|
||||
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
|
||||
@@ -97,8 +96,6 @@ enum GamepadFormMetrics {
|
||||
static let rowCorner: CGFloat = 14
|
||||
static let rowMaxWidth: CGFloat = 620
|
||||
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
|
||||
|
||||
@@ -124,6 +124,11 @@ struct GamepadHomeView: View {
|
||||
#if os(iOS)
|
||||
if let screen = topScreen {
|
||||
screenLayer(screen)
|
||||
// Resolve the screen's internal layout (safe-area trays, the scrims'
|
||||
// `.ignoresSafeArea()` full-bleed) BEFORE the insertion animates: without
|
||||
// this the tray blurs entered at their content bounds and visibly grew to
|
||||
// the screen edges mid-push.
|
||||
.geometryGroup()
|
||||
.zIndex(1)
|
||||
.id(screen.id)
|
||||
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
|
||||
@@ -322,32 +327,27 @@ struct GamepadHomeView: View {
|
||||
// MARK: - Chrome
|
||||
|
||||
private var titleBar: some View {
|
||||
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
|
||||
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
|
||||
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
|
||||
// optically centred AND clear of the chip at every width; the title shrinks a little
|
||||
// before it would ever truncate.
|
||||
// Leading title (a console heading, not a floating label — field ask), chip trailing.
|
||||
// The old hidden-mirror trick existed only to keep a CENTRED title clear of the chip;
|
||||
// a leading title needs none of it — the flexible frame keeps the two apart, and the
|
||||
// title shrinks a little before it would ever truncate.
|
||||
HStack(spacing: 12) {
|
||||
statusChip(hidden: true)
|
||||
Text("Select a Host")
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(maxWidth: .infinity)
|
||||
statusChip(hidden: false)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
statusChip
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
|
||||
/// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a
|
||||
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
|
||||
/// chip purely as a width reserve.
|
||||
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
|
||||
/// compact-height phone gives the pixels to the carousel instead.
|
||||
@ViewBuilder private var statusChip: some View {
|
||||
if !compact, let active = gamepads.active {
|
||||
ControllerStatusChip(controller: active)
|
||||
.opacity(hidden ? 0 : 1)
|
||||
.accessibilityHidden(hidden)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,35 +27,29 @@ struct GamepadLibraryScreen: View {
|
||||
store: store, host: host, onLaunch: onLaunch,
|
||||
onClose: close, controllerActive: controllerActive)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
// Leading, like every gamepad heading — no close chrome, B is the exit (the
|
||||
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
|
||||
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) }
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.background { GamepadTrayScrim(edge: .top) }
|
||||
}
|
||||
// A hardware keyboard's Esc still closes, without chrome.
|
||||
.background {
|
||||
Button("Close") { close() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.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
|
||||
|
||||
@@ -65,6 +65,17 @@ struct LibraryView: View {
|
||||
imageSession?.finishTasksAndInvalidate()
|
||||
imageSession = nil
|
||||
}
|
||||
#if os(iOS) || os(macOS)
|
||||
// B closes the library even before the coverflow exists (loading / error / empty):
|
||||
// the coverflow's carousel owns B once games render; until then this zero-size
|
||||
// listener does — without it a controller-only user is trapped on an error screen
|
||||
// (the gamepad screens carry no close chrome).
|
||||
.background {
|
||||
if gamepadUIActive && games.isEmpty {
|
||||
LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
@@ -210,6 +221,30 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Zero-size controller listener for the library's pre-coverflow states — B backs out. The same
|
||||
/// shape as ConnectOverlay's `ConnectControllerInput`; `GamepadMenuInput.needsSnapshot` swallows
|
||||
/// the held press that opened the screen. Unmounts the moment the coverflow (and its own B) is up.
|
||||
private struct LibraryBackCatcher: View {
|
||||
let active: Bool
|
||||
let onBack: () -> Void
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(width: 0, height: 0)
|
||||
.onAppear {
|
||||
input.onBack = onBack
|
||||
if active { input.start() }
|
||||
}
|
||||
.onChange(of: active) { _, nowActive in
|
||||
if nowActive { input.start() } else { input.stop() }
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// One poster tile. Steam vs custom is marked with a badge; the art walks the candidate URLs
|
||||
/// (portrait → header → hero) and finally a text placeholder.
|
||||
private struct GameCard: View {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
// 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 gamepad settings' "select" value as a REAL band: the options sit side by side on a drum
|
||||
// segment curving about a vertical axis — the current one faces you flat, and a step rotates the
|
||||
// next one in with perspective. 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 travel instead of five restarted crossfades.
|
||||
//
|
||||
// The band is LINEAR, not a ring (field verdict on the first cut): a ring showed the first
|
||||
// option waiting to the right of the last one, which left/right can't reach (adjust clamps) —
|
||||
// a promise the navigation doesn't keep. And on a 2-option ring the unselected option flipped
|
||||
// sides with every step. So positions are fixed: option i sits i steps from the start, the ends
|
||||
// are the ends, and A's wrap from the last option travels BACK across the list to the first.
|
||||
// Options other than the facing one exist only while the drum is actually moving — at rest a row
|
||||
// shows exactly its value (a resting neighbour under a long label rendered as overlapping,
|
||||
// unreadable text).
|
||||
//
|
||||
// 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
|
||||
@@ -28,9 +37,8 @@ struct GamepadOptionBand: View {
|
||||
|
||||
@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`.
|
||||
/// Where the drum rests, in option steps — always chasing `Double(selection)`; only the
|
||||
/// spring's interpolation ever puts it between integers.
|
||||
@State private var drumPosition: Double
|
||||
|
||||
init(options: [String], selection: Int, focused: Bool, width: CGFloat) {
|
||||
@@ -56,7 +64,6 @@ struct GamepadOptionBand: View {
|
||||
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)
|
||||
@@ -77,8 +84,8 @@ struct GamepadOptionBand: View {
|
||||
}
|
||||
.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.
|
||||
// controller connects, the buffer options re-derive from a new refresh rate) — re-seat
|
||||
// without a travel.
|
||||
.onChange(of: options.count) { _, _ in snap() }
|
||||
// One element to VoiceOver — the neighbour texts are rendering, not content.
|
||||
.accessibilityElement(children: .ignore)
|
||||
@@ -89,22 +96,15 @@ struct GamepadOptionBand: View {
|
||||
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.
|
||||
/// A step (or A's wrap — which on a linear band is a fast travel back to the start) springs
|
||||
/// the drum; anything else (an external write from the touch settings, a re-derived options
|
||||
/// list) re-seats it — a travel 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() }
|
||||
let wrapped = options.count > 1 && old == options.count - 1 && new == 0
|
||||
guard (abs(new - old) == 1 || wrapped), !reduceMotion else { return snap() }
|
||||
withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) {
|
||||
drumPosition += Double(delta)
|
||||
drumPosition = Double(new)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,51 +117,44 @@ struct GamepadOptionBand: View {
|
||||
|
||||
/// 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
|
||||
/// and options along the travel genuinely enter and leave mid-flight. (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.
|
||||
/// distance between them is "how mid-flight are we" — the neighbours exist exactly as long
|
||||
/// as the drum is moving, fading continuously as it lands, so a resting row is one flat
|
||||
/// Text and a long label never sits under a resting neighbour.
|
||||
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
|
||||
}
|
||||
var animatableData: Double {
|
||||
get { rotation }
|
||||
set { rotation = newValue }
|
||||
}
|
||||
|
||||
/// 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))
|
||||
ForEach(0..<options.count, id: \.self) { i in
|
||||
// Plain signed distance — the band is linear, so option i has ONE home and the
|
||||
// ends are the ends (nothing waits beyond the last option).
|
||||
let d = Double(i) - rotation
|
||||
if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
|
||||
option(i, distance: d, gate: 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.
|
||||
// Flatten the transform stack while travelling — 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
|
||||
@@ -171,7 +164,7 @@ private struct Drum: View, Animatable {
|
||||
@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.
|
||||
// The facing option never gates: a resting row still shows its value.
|
||||
let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate)
|
||||
Text(options[i])
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -144,12 +144,14 @@ struct GamepadSettingsView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
// Leading, like a console section heading — centred read as a floating label,
|
||||
// and a gamepad UI needs no close chrome next to it (B is the exit).
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) }
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
// The picker is one layer deeper — its rows aren't sections of anything, so the
|
||||
// strip would be a control that does nothing while it's up.
|
||||
if pinTarget == nil { tabStrip }
|
||||
@@ -191,6 +193,18 @@ struct GamepadSettingsView: View {
|
||||
gamepads.startDiscovery()
|
||||
}
|
||||
.onDisappear { gamepads.stopDiscovery() }
|
||||
#if !os(tvOS)
|
||||
// The visible close ✕ is gone (a gamepad UI exits with B) — this keeps a hardware
|
||||
// keyboard's Esc and the macOS sheet's cancel working without chrome.
|
||||
.background {
|
||||
Button("Close") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The section switcher. Horizontally scrollable so a narrow phone in landscape never has to
|
||||
@@ -294,24 +308,6 @@ struct GamepadSettingsView: View {
|
||||
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 { performClose() } 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)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||
#endif
|
||||
.accessibilityLabel("Close settings")
|
||||
}
|
||||
|
||||
/// "Settings", or "Pin “Work”" while the pin picker is up — the title is what says which
|
||||
/// layer the row list currently is.
|
||||
private var title: String {
|
||||
@@ -391,12 +387,10 @@ struct GamepadSettingsView: View {
|
||||
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
|
||||
.foregroundStyle(focused ? ink.fg : ink.fg(0.6))
|
||||
} 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.)
|
||||
// The flat rows (profile pin counts, placeholders) keep the quiet slip:
|
||||
// keyed by the value so a change slides the new string in following the
|
||||
// user's motion, crossfading over ~14 pt. The ZStack is the stable home
|
||||
// the removed/inserted texts transition within.
|
||||
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
|
||||
ZStack {
|
||||
Text(row.value)
|
||||
@@ -771,6 +765,8 @@ struct GamepadSettingsView: View {
|
||||
value: pinned ? "Pinned" : "Off",
|
||||
detail: "A pinned profile appears as its own card on the host — one press "
|
||||
+ "connects with it.",
|
||||
optionLabels: ["Off", "Pinned"],
|
||||
selectedIndex: pinned ? 1 : 0,
|
||||
adjust: { delta in
|
||||
let target = delta > 0
|
||||
guard pinned != target else { return false }
|
||||
@@ -868,6 +864,10 @@ struct GamepadSettingsView: View {
|
||||
id: id, tab: tab, icon: icon, label: label,
|
||||
value: value.wrappedValue ? "On" : "Off",
|
||||
detail: detail,
|
||||
// Toggles ride the band too (field ask): Off sits left of On, matching the
|
||||
// directional semantics below, so a right-step slides On in from the right.
|
||||
optionLabels: ["Off", "On"],
|
||||
selectedIndex: value.wrappedValue ? 1 : 0,
|
||||
enabled: enabled,
|
||||
adjust: { delta in
|
||||
// Directional semantics: left = off, right = on; a no-op reads as a boundary.
|
||||
|
||||
Reference in New Issue
Block a user