ci / web (pull_request) Successful in 1m17s
ci / docs-site (pull_request) Successful in 1m42s
ci / rust-arm64 (pull_request) Successful in 2m36s
android / android (pull_request) Successful in 3m33s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 8m37s
ci / rust (pull_request) Successful in 8m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 3m47s
apple / swift (pull_request) Successful in 1m29s
apple / screenshots (pull_request) Skipped
The console settings were one 30-row scroll, which on a Deck meant thumbing past Video and Audio to reach the pad settings. They are now split across sections — Stream · Video · Audio · Controller · Interface · Profiles, plus Input on the desktop console, which alone carries the touch/mouse rows. L1/R1 walks them, each section remembers where its cursor was, and the names are the same word on every client so a setting is where you looked for it last. Shoulders are not the only route, because a D-pad remote hasn't got any: on Android, Up from the first row moves onto the strip (left/right walks sections there, A drops back in), and on tvOS the pills are focusable, so the focus engine handles it — a Siri Remote has no extended gamepad profile and never reaches the input poll at all. The desktop console needs neither; PageUp and PageDown already map to the same events. New "Background" row, six palettes: Violet (the brand default), Tide, Forest, Ember, Rose, Graphite. A palette is a hue rotation plus a saturation scale over the ONE colour field each client already draws, so every palette inherits its structure and Violet is the identity transform — existing installs see exactly what they see today. The maths is ported three times (Rust/Swift/Kotlin) under one shared `ui_palette` key, with the same assertions pinned in each language. It is presentation only, so it is a device preference and never part of a profile. The form screens no longer have a backdrop of their own. Settings, add-host and pair used to sit on a still gradient; they now wear the same living field at a calm mix — pools dimmed onto the palette's own corner colour, vignette halved so rows that run to the edges don't get crushed. On the desktop console that collapsed the old aurora-over-static crossfade into one shader pass with a chased uniform. Motion speed is identical in both modes on purpose: changing it would make the field jump mid-transition. Nothing in the gamepad UI is backed by a static image now, and Reduce Motion (Apple) / "remove animations" (Android) still freeze it. Also: the settings screen had no raster coverage at all — the eyeball dump is `#[ignore]`d — so a new test draws every tab, and the Android screenshot set gains a console-settings scene. Both earned their keep immediately: the renders showed the extra hint pushing "Done" off a 360 dp phone (the legend scrolls now, and the Section cell only appears where shoulders exist) and the form backdrop crushing its own edges.
251 lines
11 KiB
Swift
251 lines
11 KiB
Swift
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list
|
|
// for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves
|
|
// a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B
|
|
// backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the
|
|
// focus cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
|
//
|
|
// On tvOS the rows are focusable Buttons and the NATIVE FOCUS ENGINE replaces the poll entirely
|
|
// (Siri Remote and pads both drive it: up/down moves focus, select activates, Menu — via
|
|
// onExitCommand — backs out). Left/right value-adjust isn't wired there; select cycles a value
|
|
// forward exactly like A does elsewhere, the standard tvOS settings interaction. The iOS/macOS
|
|
// poll-driven behavior is untouched by the tvOS mode.
|
|
//
|
|
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
|
|
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
|
|
// first-class fallback — tapping a row focuses AND activates it (rows are always fully visible, so
|
|
// the carousel's "first tap re-centers" step would only add friction here), and free finger
|
|
// scrolling is never hijacked back to the focused row until the next controller move.
|
|
//
|
|
// Feedback is dual-channel like the carousel: `.sensoryFeedback` ticks the DEVICE Taptic engine,
|
|
// `MenuHaptics` ticks the CONTROLLER. Moves and value changes get the crisp detent; a refused
|
|
// move at either end gets the dull boundary thud plus a short vertical recoil.
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
#if os(iOS) || os(macOS) || os(tvOS)
|
|
|
|
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
|
|
let items: [Item]
|
|
/// Output only: the list WRITES the focused item's id here (e.g. for a caller's hint bar).
|
|
@Binding var focusID: Item.ID?
|
|
/// Left/right on the focused row. Return whether the value actually changed — true plays the
|
|
/// move detent, false the boundary thud (end of a clamped range, or nothing to adjust).
|
|
var onAdjust: ((Item, Int) -> Bool)?
|
|
/// A → activate the focused row (toggle it, open it, run it — the caller decides).
|
|
let onActivate: (Item) -> Void
|
|
/// B → back/dismiss; nil disables it.
|
|
var onBack: (() -> Void)?
|
|
/// L1 (`-1`) / R1 (`+1`) — a step SIDEWAYS out of the list: the settings screen's section
|
|
/// tabs. Wired on tvOS too, where the focus engine owns up/down but leaves the shoulders
|
|
/// to the poll. nil ⇒ the shoulders do nothing.
|
|
var onShoulder: ((Int) -> Void)?
|
|
/// Whether this list currently owns controller input — same handoff contract as
|
|
/// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad).
|
|
var isActive: Bool = true
|
|
@ViewBuilder let row: (Item, _ focused: Bool) -> Row
|
|
|
|
@State private var input = GamepadMenuInput(manager: .shared)
|
|
@State private var haptics = MenuHaptics(manager: .shared)
|
|
#if os(tvOS)
|
|
/// tvOS: the focus engine is the navigation authority for UP/DOWN — `cursor` chases this, so
|
|
/// the caller's `focused` row styling always matches real system focus. LEFT/RIGHT adjust
|
|
/// comes from the POLL (see `wire`), never from `.onMoveCommand`: the command stream is
|
|
/// 4-way with no axis data (diagonal scroll wobble buckets into left/right), and its
|
|
/// interception of up/down proved INPUT-SOURCE-DEPENDENT on hardware — keyboard arrows were
|
|
/// intercepted but a pad's dpad was not, so programmatic stepping double-moved every press.
|
|
@FocusState private var focusedID: Item.ID?
|
|
#endif
|
|
/// Authoritative focus cursor (index into `items`).
|
|
@State private var cursor = 0
|
|
/// A short vertical recoil when a move is refused at a list end.
|
|
@State private var bumpOffset: CGFloat = 0
|
|
/// `.sensoryFeedback` counters (see GamepadCarousel): device ticks for activate / value-change
|
|
/// / end-stop events; moves trigger on `cursor` itself.
|
|
@State private var activateTick = 0
|
|
@State private var adjustTick = 0
|
|
@State private var boundaryTick = 0
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView(.vertical) {
|
|
LazyVStack(spacing: 6) {
|
|
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
|
#if os(tvOS)
|
|
// A focusable Button per row: the engine moves between them, select
|
|
// activates (`tap` keeps the cursor in step before firing). The row's
|
|
// own `focused` styling is the focus treatment — the bare style adds
|
|
// no system chrome on top of it.
|
|
Button { tap(idx) } label: {
|
|
row(item, focusedID == item.id)
|
|
}
|
|
.buttonStyle(ConsoleBareButtonStyle())
|
|
.focused($focusedID, equals: item.id)
|
|
.id(item.id)
|
|
#else
|
|
row(item, idx == cursor && isActive)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { tap(idx) }
|
|
.id(item.id)
|
|
#endif
|
|
}
|
|
}
|
|
.padding(.vertical, 10)
|
|
}
|
|
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden.
|
|
.scrollIndicators(.never)
|
|
.offset(y: bumpOffset)
|
|
.onChange(of: cursor) { _, newValue in
|
|
guard newValue >= 0, newValue < items.count else { return }
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
proxy.scrollTo(items[newValue].id)
|
|
}
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
// Focus moved (remote swipe / pad dpad) — keep the cursor, the caller's focusID mirror,
|
|
// and the controller detent in step. Menu = the list's back action (both tvOS callers
|
|
// pass one; the screen behind would otherwise catch the press and peel too far).
|
|
.onChange(of: focusedID) { _, newValue in
|
|
guard let id = newValue, let idx = items.firstIndex(where: { $0.id == id }),
|
|
idx != cursor else { return }
|
|
cursor = idx
|
|
focusID = id
|
|
haptics.move()
|
|
}
|
|
.defaultFocus($focusedID, items.first?.id)
|
|
.onExitCommand { onBack?() }
|
|
#endif
|
|
.sensoryFeedback(.selection, trigger: cursor)
|
|
.sensoryFeedback(.selection, trigger: adjustTick)
|
|
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
|
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
|
.onAppear {
|
|
reconcile()
|
|
wire()
|
|
if isActive { input.start() }
|
|
}
|
|
.onDisappear {
|
|
input.stop()
|
|
haptics.stop()
|
|
}
|
|
.onChange(of: isActive) { _, active in
|
|
if active {
|
|
wire()
|
|
input.start()
|
|
} else {
|
|
input.stop()
|
|
haptics.stop()
|
|
}
|
|
}
|
|
// Re-seed a dropped focus AND re-wire the input callbacks so they capture the current
|
|
// `items` value (a plain array — it would otherwise go stale in the stored closures).
|
|
.onChange(of: items.map(\.id)) { _, _ in
|
|
reconcile()
|
|
wire()
|
|
}
|
|
}
|
|
|
|
// MARK: - Input wiring
|
|
|
|
private func wire() {
|
|
#if os(tvOS)
|
|
// The focus engine owns up/down and select (Button rows) and Menu (onExitCommand) — the
|
|
// poll carries ONLY the horizontal axis, where its dominant-axis deadzone + hold-repeat
|
|
// are exactly the adjust feel the other platforms have, and where the focus engine has
|
|
// nothing to move to in a vertical list. Vertical poll directions are deliberately
|
|
// dropped: acting on them would double the engine's own focus moves. (The Siri Remote
|
|
// never reaches this poll — no extended profile — so remote users cycle values with
|
|
// select instead, which `activate` already does.)
|
|
input.onMove = { direction in
|
|
switch direction {
|
|
case .left: adjust(by: -1)
|
|
case .right: adjust(by: 1)
|
|
case .up, .down: break
|
|
}
|
|
}
|
|
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
|
|
#else
|
|
input.onMove = { direction in
|
|
switch direction {
|
|
case .up: step(by: -1)
|
|
case .down: step(by: 1)
|
|
case .left: adjust(by: -1)
|
|
case .right: adjust(by: 1)
|
|
}
|
|
}
|
|
input.onConfirm = { activate() }
|
|
input.onBack = onBack
|
|
input.onShoulder = { forward in onShoulder?(forward ? 1 : -1) }
|
|
#endif
|
|
}
|
|
|
|
private func step(by delta: Int) {
|
|
guard !items.isEmpty else { return }
|
|
let target = cursor + delta
|
|
guard target >= 0, target < items.count else { return boundaryBump(forward: delta > 0) }
|
|
cursor = target
|
|
focusID = items[target].id
|
|
haptics.move()
|
|
}
|
|
|
|
|
|
private func adjust(by delta: Int) {
|
|
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
|
|
if onAdjust(items[cursor], delta) {
|
|
adjustTick &+= 1
|
|
haptics.move()
|
|
} else {
|
|
boundaryTick &+= 1
|
|
haptics.boundary()
|
|
}
|
|
}
|
|
|
|
private func activate() {
|
|
guard cursor >= 0, cursor < items.count else { return }
|
|
activateTick &+= 1
|
|
haptics.confirm()
|
|
onActivate(items[cursor])
|
|
}
|
|
|
|
/// Touch fallback: a tap focuses the row and activates it in one go.
|
|
private func tap(_ idx: Int) {
|
|
guard idx >= 0, idx < items.count else { return }
|
|
if cursor != idx {
|
|
cursor = idx
|
|
focusID = items[idx].id
|
|
}
|
|
activate()
|
|
}
|
|
|
|
/// Keep `cursor`/`focusID` consistent with `items`: seed on appear; on a list change keep the
|
|
/// same focused item when it survives, else clamp the cursor into range.
|
|
private func reconcile() {
|
|
guard !items.isEmpty else {
|
|
cursor = 0
|
|
if focusID != nil { focusID = nil }
|
|
return
|
|
}
|
|
if let id = focusID, let idx = items.firstIndex(where: { $0.id == id }) {
|
|
cursor = idx
|
|
} else {
|
|
cursor = min(max(cursor, 0), items.count - 1)
|
|
focusID = items[cursor].id
|
|
}
|
|
#if os(tvOS)
|
|
// Keep real focus on the reconciled row when its old target vanished from the list.
|
|
if focusedID == nil || !items.contains(where: { $0.id == focusedID }), cursor < items.count {
|
|
focusedID = items[cursor].id
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func boundaryBump(forward: Bool) {
|
|
boundaryTick &+= 1
|
|
haptics.boundary()
|
|
let recoil: CGFloat = forward ? -14 : 14
|
|
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil }
|
|
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 }
|
|
}
|
|
}
|
|
#endif
|