Files
punktfunk/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift
T
enricobuehler c010139e6e
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
feat(client/apple): the gamepad UI moves and colours like the console it mirrors
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.
2026-08-07 13:59:40 +02:00

188 lines
8.2 KiB
Swift

// 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