The Apple client catches up with the console-UI overhaul: grid, sort, Collections, the v2 motion contract #293
@@ -54,7 +54,20 @@ struct GamepadAddHostView: View {
|
||||
@State private var port = "9777"
|
||||
@State private var focusID: String?
|
||||
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||
@State private var editing: String?
|
||||
@State private var editing: String? = Self.initialEditing
|
||||
/// Shot harness only: open with a field being edited (`PUNKTFUNK_SHOT_EDITING=address`),
|
||||
/// so the keyboard tray and the row seated above it can be rendered without a pad.
|
||||
private static var initialEditing: String? {
|
||||
#if DEBUG
|
||||
guard ScreenshotMode.isActive else { return nil }
|
||||
let field = ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_EDITING"] ?? ""
|
||||
return field.isEmpty ? nil : field
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
/// The edited row's flight between its place in the list and its seat above the keyboard.
|
||||
@Namespace private var fieldFlight
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
@@ -67,6 +80,20 @@ struct GamepadAddHostView: View {
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
// While the tray edits this row, the row IS the one seated above the keyboard
|
||||
// (see `bottomTray`); its slot here stays empty and keeps the list's layout.
|
||||
.opacity(editing == row.id ? 0 : 1)
|
||||
// The flight's origin/destination: an invisible frame-provider that exists only
|
||||
// while the row is HERE. When editing starts it unmounts and the seated row is
|
||||
// inserted with the same id, so SwiftUI animates the seated row in FROM this
|
||||
// frame; when editing ends it returns and the seated row's removal flies back to
|
||||
// it. Exactly one matched view per id at any time — two live ones with the
|
||||
// source flag swapped sent the invisible list row flying instead.
|
||||
.overlay {
|
||||
if editing != row.id {
|
||||
Color.clear.matchedGeometryEffect(id: row.id, in: fieldFlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
@@ -131,12 +158,16 @@ struct GamepadAddHostView: View {
|
||||
// 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)
|
||||
// Not while the keyboard tray is up: Esc is the tray's Done then (see
|
||||
// GamepadKeyboard), and a shortcut here would fire first and close the whole screen.
|
||||
if editing == nil {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
@@ -173,29 +204,43 @@ struct GamepadAddHostView: View {
|
||||
#else
|
||||
if let editing {
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
text: editingBinding(editing),
|
||||
allowed: allowedCharacters(editing),
|
||||
onDone: { closeKeyboard() })
|
||||
// Fresh keyboard per field: a touch user can retarget the tray by tapping
|
||||
// another field row, and the keyboard's input wiring captured the previous
|
||||
// binding on appear — new identity forces a rewire to the new field.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
// "Type" names what A does to the key under the keyboard's cursor. There is
|
||||
// no tap equivalent — a touch user types by tapping the keycap itself — so
|
||||
// this one cell stays a label.
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||
action: { backspace(editing) }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { closeKeyboard() }),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// The field being typed into sits HERE, directly above the keys — flown in from
|
||||
// its place in the list on the tray's spring — so what the keyboard covers no
|
||||
// longer depends on where the list happened to be scrolled. The same row view,
|
||||
// so it reads as the row itself having come down to the keyboard.
|
||||
if let row = rows.first(where: { $0.id == editing }) {
|
||||
rowView(row, focused: true)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
.matchedGeometryEffect(id: row.id, in: fieldFlight)
|
||||
.frame(maxWidth: .infinity)
|
||||
.transition(.opacity)
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
text: editingBinding(editing),
|
||||
allowed: allowedCharacters(editing),
|
||||
onDone: { closeKeyboard() })
|
||||
// Fresh keyboard per field: a touch user can retarget the tray by tapping
|
||||
// another field row, and the keyboard's input wiring captured the previous
|
||||
// binding on appear — new identity forces a rewire to the new field.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
// "Type" names what A does to the key under the keyboard's cursor. There is
|
||||
// no tap equivalent — a touch user types by tapping the keycap itself — so
|
||||
// this one cell stays a label.
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||
action: { backspace(editing) }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { closeKeyboard() }),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
GamepadHintBar(hints: [
|
||||
.init(
|
||||
|
||||
@@ -40,6 +40,10 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
/// insets center exactly one at a time.
|
||||
let itemWidth: CGFloat
|
||||
let spacing: CGFloat
|
||||
/// The item to open ON when the strip mounts — a remembered position (the library's last
|
||||
/// opened title). Consulted once, before the first `reconcile()`; ignored when it isn't in
|
||||
/// `items`, in which case the strip opens on the first item as it always has.
|
||||
var initialItemID: Item.ID?
|
||||
/// A → activate the centered item.
|
||||
let onActivate: (Item) -> Void
|
||||
/// Y → the screen's secondary action (e.g. open a host's library); nil disables it.
|
||||
@@ -57,6 +61,9 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
var onUp: (() -> Void)?
|
||||
/// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders.
|
||||
var shoulderJump: Int = 0
|
||||
/// L1 (`false`) / R1 (`true`) → the screen's own shoulder action (the Collections screen steps
|
||||
/// its sort with them). Set, it takes the shoulders away from `shoulderJump`.
|
||||
var onShoulder: ((Bool) -> Void)?
|
||||
/// Whether this carousel currently owns controller input. A presenting screen (e.g. the host
|
||||
/// launcher) stays mounted behind a presented one (e.g. the library), and both carousels would
|
||||
/// otherwise poll the SAME controller at once — driving both. The parent sets this false while
|
||||
@@ -172,9 +179,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
.safeAreaPadding(.horizontal, inset)
|
||||
.offset(x: bumpOffset)
|
||||
#if os(tvOS)
|
||||
// Land initial focus on the first card (the launcher's first host / the coverflow's
|
||||
// first title) instead of wherever the engine guesses.
|
||||
.defaultFocus($focusedID, items.first?.id)
|
||||
// Land initial focus on the remembered card when there is one, else the first
|
||||
// (the launcher's first host / the coverflow's first title) instead of wherever
|
||||
// the engine guesses.
|
||||
.defaultFocus(
|
||||
$focusedID,
|
||||
initialItemID.flatMap { index(of: $0) != nil ? $0 : nil } ?? items.first?.id)
|
||||
// Focus moved (remote swipe / pad dpad) — chase it: cursor, detail selection,
|
||||
// controller detent, and an imperative center scroll.
|
||||
.onChange(of: focusedID) { _, newValue in
|
||||
@@ -209,6 +219,12 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
onBack: onBack)
|
||||
#endif
|
||||
.onAppear {
|
||||
// Seat the cursor on the remembered item before the first reconcile publishes it as
|
||||
// the scroll target — only while nothing has been aligned yet (a re-appear keeps
|
||||
// wherever the strip already is).
|
||||
if scrolledID == nil, let id = initialItemID, let idx = index(of: id) {
|
||||
cursor = idx
|
||||
}
|
||||
reconcile()
|
||||
wire()
|
||||
if isActive { input.start() }
|
||||
@@ -329,7 +345,7 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
input.onBack = onBack
|
||||
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||
input.onShoulder = onShoulder ?? (shoulderJump > 0 ? { shoulder(right: $0) } : nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -162,14 +162,28 @@ struct GamepadHomeView: View {
|
||||
.geometryGroup()
|
||||
.zIndex(1)
|
||||
.id(screen.id)
|
||||
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
|
||||
// Reduce Motion: a crossfade — no slide, no scale (the desktop's rule).
|
||||
.transition(
|
||||
reduceMotion
|
||||
? .opacity
|
||||
: .gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
|
||||
}
|
||||
// Back mid-push (the desktop's interruptible transition): while a push is in flight
|
||||
// no layer owns the controller, so this zero-size listener takes B alone and turns
|
||||
// the entering screen around. A and the rest stay dropped until the spring has
|
||||
// passed 0.85 of its travel (`transitioning`), which keeps a double-tapped A from
|
||||
// pushing two screens.
|
||||
if transitioning, topScreen != nil {
|
||||
MidPushBackCatcher { backOutOfPush() }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
// 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)
|
||||
// derived id catches every writer. Reduce Motion crossfades on the reduced spring.
|
||||
.animation(
|
||||
reduceMotion ? GamepadShellMotion.reducedScreen : 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`.
|
||||
@@ -195,7 +209,9 @@ struct GamepadHomeView: View {
|
||||
transitionEpoch += 1
|
||||
let epoch = transitionEpoch
|
||||
transitioning = true
|
||||
let hold = reduceMotion ? 0.05 : GamepadShellMotion.duration + 0.02
|
||||
// The gate opens when the spring has passed 0.85 of its travel, not when it has
|
||||
// settled — the wall is gone, the double-tap protection stays.
|
||||
let hold = reduceMotion ? 0.05 : GamepadShellMotion.inputOpensAfter
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
|
||||
if epoch == transitionEpoch { transitioning = false }
|
||||
}
|
||||
@@ -348,6 +364,24 @@ struct GamepadHomeView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Back pressed while a push is still in flight: clear the trigger that raised the top
|
||||
/// screen, so the same spring carries it back down — the desktop retargets its NAV spring
|
||||
/// to 0 mid-push; SwiftUI does the equivalent when the identity flips back before the
|
||||
/// insertion has settled.
|
||||
private func backOutOfPush() {
|
||||
guard transitioning, let screen = topScreen else { return }
|
||||
switch screen {
|
||||
case .pair: pairingTarget = nil
|
||||
case .editHost: editTarget = nil
|
||||
case .hostOptions: hostOptionsTarget = nil
|
||||
case .settings: showSettings = false
|
||||
case .addHost: showAddHost = false
|
||||
case .library: libraryTarget = nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private var topScreenID: String? {
|
||||
#if os(iOS)
|
||||
topScreen?.id
|
||||
@@ -808,3 +842,23 @@ private struct GamepadHostTile: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Zero-size controller listener for a push in flight — B alone. The same shape as LibraryView's
|
||||
/// `LibraryBackCatcher`; `GamepadMenuInput.needsSnapshot` swallows the still-held A that pushed
|
||||
/// the screen, so only a FRESH B mid-push counts. Unmounts the moment the gate opens.
|
||||
private struct MidPushBackCatcher: View {
|
||||
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
|
||||
input.start()
|
||||
}
|
||||
.onDisappear { input.stop() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
//
|
||||
// Edits are applied to the binding live (the caller's field row shows every keystroke), so
|
||||
// closing the keyboard is always "done" — there is no separate cancel/commit step to get wrong.
|
||||
// Touch stays a fallback: every keycap is tappable.
|
||||
// Touch stays a fallback: every keycap is tappable. A HARDWARE keyboard (an iPad on a Magic
|
||||
// Keyboard, a Mac) types straight into the field while the tray is up — characters insert,
|
||||
// ⌫ deletes, arrows move the key cursor, Return and Esc are Done — with no system keyboard ever
|
||||
// raised, because there is no text field to raise it.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -26,6 +29,9 @@ struct GamepadKeyboard: View {
|
||||
@State private var cursor = GridPos(row: 1, col: 0) // opens on "q"
|
||||
@State private var pressTick = 0
|
||||
@State private var boundaryTick = 0
|
||||
/// Hardware-keyboard focus: the tray takes it on appear so key presses land here (the row
|
||||
/// list behind it hands its own key navigation off while editing).
|
||||
@FocusState private var hardwareFocus: Bool
|
||||
#if os(iOS)
|
||||
/// `.compact` (landscape phone): shorter keycaps so the tray leaves room for the field rows.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
@@ -85,9 +91,15 @@ struct GamepadKeyboard: View {
|
||||
.sensoryFeedback(.selection, trigger: cursor)
|
||||
.sensoryFeedback(.impact(weight: .light), trigger: pressTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
// Hardware keys. No focus ring — the tray draws its own cursor.
|
||||
.focusable()
|
||||
.focusEffectDisabled()
|
||||
.focused($hardwareFocus)
|
||||
.onKeyPress(phases: .down) { hardwareKey($0) }
|
||||
.onAppear {
|
||||
wire()
|
||||
input.start()
|
||||
hardwareFocus = true
|
||||
}
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
@@ -181,5 +193,55 @@ struct GamepadKeyboard: View {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
}
|
||||
|
||||
/// A hardware key while the tray is up. Return and Esc are both Done — the binding already
|
||||
/// holds the text, so there is nothing to cancel; ⌫ deletes; arrows drive the key cursor
|
||||
/// (Return does NOT type the highlighted key — a keyboard user types letters, not caps);
|
||||
/// anything with ⌘ (⌘Q, ⌘W…) is left to the system.
|
||||
private func hardwareKey(_ key: KeyPress) -> KeyPress.Result {
|
||||
if key.modifiers.contains(.command) { return .ignored }
|
||||
switch key.key {
|
||||
case .escape, .return:
|
||||
haptics.confirm()
|
||||
onDone()
|
||||
return .handled
|
||||
case .delete, .deleteForward:
|
||||
DispatchQueue.main.async { press(.backspace) }
|
||||
return .handled
|
||||
case .leftArrow: move(.left); return .handled
|
||||
case .rightArrow: move(.right); return .handled
|
||||
case .upArrow: move(.up); return .handled
|
||||
case .downArrow: move(.down); return .handled
|
||||
case .space:
|
||||
DispatchQueue.main.async { press(.space) }
|
||||
return .handled
|
||||
default:
|
||||
break
|
||||
}
|
||||
// ⌫ can also arrive as a bare control character rather than a named key.
|
||||
if key.characters == "\u{7F}" || key.characters == "\u{08}" {
|
||||
DispatchQueue.main.async { press(.backspace) }
|
||||
return .handled
|
||||
}
|
||||
// Printable characters — including ones the on-screen grid doesn't offer (a capital,
|
||||
// an umlaut): the on-screen set is deliberately small, the field's own `allowed` set is
|
||||
// the real rule.
|
||||
let typed = key.characters
|
||||
guard !typed.isEmpty,
|
||||
typed.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) })
|
||||
else { return .ignored }
|
||||
if let allowed, !typed.unicodeScalars.allSatisfy(allowed.contains) {
|
||||
refuse()
|
||||
return .handled
|
||||
}
|
||||
// Off the key-event dispatch: writing the binding synchronously here re-rendered the
|
||||
// screen mid-delivery and a fast burst of keystrokes lost every other one.
|
||||
DispatchQueue.main.async {
|
||||
text.append(typed)
|
||||
pressTick &+= 1
|
||||
haptics.move()
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -26,15 +26,24 @@ struct GamepadLibraryScreen: View {
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
/// The collection the shelf is drilled into, for the title — `host · profile · collection`.
|
||||
@State private var collection: String?
|
||||
|
||||
private var title: String {
|
||||
let base = target.title(in: profiles)
|
||||
guard let collection else { return "\(base) — Library" }
|
||||
return "\(base) \u{b7} \(collection) — Library"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LibraryView(
|
||||
store: store, target: target, onLaunch: onLaunch,
|
||||
onClose: close, controllerActive: controllerActive)
|
||||
onClose: close, controllerActive: controllerActive,
|
||||
onCollectionChanged: { collection = $0 })
|
||||
.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("\(target.title(in: profiles)) — Library")
|
||||
Text(title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
|
||||
@@ -51,21 +51,31 @@ enum GamepadScreen: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`).
|
||||
/// The console shell's motion, mapped to SwiftUI from the numbers `ConsoleMotion` pins against
|
||||
/// the shared vectors' `motion_spring` block (version 2). Source of truth:
|
||||
/// `crates/pf-console-ui/src/shell.rs` (the NAV spring) and `shell/render.rs` (the geometry).
|
||||
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)
|
||||
/// One transition, both layers — the console's `springs::NAV`. A spring rather than the old
|
||||
/// 0.26 s ease-out-cubic, so a Back pressed mid-push turns the entering screen around instead
|
||||
/// of waiting for a tween to finish (`ConsoleMotion.interruptible`).
|
||||
static let screen = Animation.spring(
|
||||
response: ConsoleMotion.response, dampingFraction: ConsoleMotion.damping)
|
||||
/// Reduce Motion: a plain crossfade on the desktop's `REDUCED_NAV` — no slide, no scale.
|
||||
static let reducedScreen = Animation.spring(
|
||||
response: ConsoleMotion.reducedResponse, dampingFraction: ConsoleMotion.reducedDamping)
|
||||
/// 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 }
|
||||
static func slide(compact: Bool) -> CGFloat {
|
||||
compact ? 27 : CGFloat(ConsoleMotion.pushSlideDp)
|
||||
}
|
||||
/// 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
|
||||
static let inScale = CGFloat(ConsoleMotion.enterScale)
|
||||
static let underScale = CGFloat(ConsoleMotion.exitScale)
|
||||
/// When a fresh push starts accepting input other than Back (the desktop's
|
||||
/// `NAV_INPUT_OPENS` 0.85 of the spring's travel, as a time).
|
||||
static let inputOpensAfter: TimeInterval = ConsoleMotion.inputOpensAfter
|
||||
}
|
||||
|
||||
extension AnyTransition {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// The gamepad library's live view/sort bar — the desktop console's `screens/library.rs` bar, in
|
||||
// SwiftUI: one band across the top of the field with two anchored pill groups, `SORT` (Default ·
|
||||
// A–Z · Platform · Store) leading and `VIEW` (Shelf · Grid) trailing. Both controls apply by
|
||||
// WRITING THE SETTING (`LibraryConsoleView` owns the keys), so the field re-sorts / re-arranges
|
||||
// live under the strip and the Interface settings row can never disagree with it.
|
||||
//
|
||||
// Focus is the WHOLE bar: an accent wash over the pill row — no border, no halo, no glass. The
|
||||
// C7 note on the desktop's wash being taller than the row it highlighted is the trap here: wash
|
||||
// the pill row's extent, not the band's. On Apple the bar is a TRAY the container slides down over
|
||||
// the field on ▲ (a fixed band ate a third of a landscape phone), so it is only ever drawn focused;
|
||||
// while down ◀▶ step the sort (clamped, no wrap), L1/R1 pick the arrangement outright, and
|
||||
// ▼ / A / B hand the controller back — that routing lives in the container, this view only draws.
|
||||
// Pills are tappable/clickable where there is a pointer, and focusable Buttons on tvOS.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct LibraryBarView: View {
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
let sort: LibrarySortKey
|
||||
let arrangement: LibraryArrangement
|
||||
/// The bar owns the controller (▲ from the field). Draws the wash.
|
||||
let focused: Bool
|
||||
/// The Collections screen shows the SORT group alone (its arrangement is the tiles).
|
||||
var showsView = true
|
||||
var compact = false
|
||||
var onSort: (LibrarySortKey) -> Void
|
||||
var onArrangement: (LibraryArrangement) -> Void
|
||||
@Namespace private var sortHighlight
|
||||
@Namespace private var viewHighlight
|
||||
|
||||
/// The band's height, matching the settings tab strip's rhythm.
|
||||
static let bandHeight: CGFloat = 46
|
||||
/// The air under the band before the field.
|
||||
static let gap: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
// One row where it fits (every landscape screen); a portrait phone gets the two groups
|
||||
// stacked rather than a row spilling off the edge.
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 0) {
|
||||
sortGroup
|
||||
Spacer(minLength: 12)
|
||||
if showsView { viewGroup }
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sortGroup
|
||||
if showsView { viewGroup }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 5)
|
||||
// The focus wash: the pill row's extent, corner 14, accent at 14 % — reads as "this row
|
||||
// has the controller" without pretending to be a panel.
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(ink.accent(0.14))
|
||||
.opacity(focused ? 1 : 0)
|
||||
}
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: Self.bandHeight)
|
||||
}
|
||||
|
||||
private var sortGroup: some View {
|
||||
group(caption: "SORT") {
|
||||
ForEach(LibrarySortKey.all, id: \.self) { key in
|
||||
pill(key.label, selected: key == sort, namespace: sortHighlight) { onSort(key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var viewGroup: some View {
|
||||
group(caption: "VIEW") {
|
||||
ForEach(LibraryArrangement.all, id: \.self) { view in
|
||||
pill(view.label, selected: view == arrangement, namespace: viewHighlight) {
|
||||
onArrangement(view)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked small-caps caption followed by its pills.
|
||||
private func group<Pills: View>(caption: String, @ViewBuilder pills: () -> Pills) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(caption)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(ink.fg(0.45))
|
||||
HStack(spacing: 6, content: pills)
|
||||
}
|
||||
}
|
||||
|
||||
private func pill(
|
||||
_ label: String, selected: Bool, namespace: Namespace.ID, action: @escaping () -> Void
|
||||
) -> some View {
|
||||
let text = Text(label)
|
||||
.font(.geist(compact ? 12 : metrics.tabFont, .semibold, relativeTo: .footnote))
|
||||
// `onAccent`, not `fg`: the selected pill is FILLED with the accent (see the settings
|
||||
// strip for the pale-palette lesson).
|
||||
.foregroundStyle(selected ? ink.onAccent : ink.fg(0.55))
|
||||
.padding(.horizontal, metrics.rowHPad * 0.7)
|
||||
.padding(.vertical, metrics.rowVPad * 0.4)
|
||||
.background {
|
||||
// One capsule per group that MOVES between its pills, the settings strip's move.
|
||||
if selected {
|
||||
Color.clear
|
||||
.consoleGlass(Capsule(), tint: ink.accent(0.85))
|
||||
.matchedGeometryEffect(id: "pill", in: namespace)
|
||||
}
|
||||
}
|
||||
.contentShape(Capsule())
|
||||
#if os(tvOS)
|
||||
return Button(action: action) { text }.buttonStyle(ConsoleBareButtonStyle())
|
||||
#else
|
||||
return text.onTapGesture(perform: action)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,240 @@
|
||||
// The gamepad library's COLLECTIONS place — the desktop console's `screens/collections.rs`: the
|
||||
// user's flow verbatim is "group by platform → walk the platforms → pick PS3 → see its games". One
|
||||
// tile per collated group in the home carousel's tile language (same size, same glass, same
|
||||
// recede), each carrying its label, its count, its kind (`LAUNCHERS` / `PLATFORM` / `STORE`) and
|
||||
// a DECK of up to three covers from its sort-first titles. A opens the group as a filtered shelf,
|
||||
// Y (Collections root only) opens the plain shelf as "All titles", L1/R1 step the sort — WRAPPING
|
||||
// here, unlike the shelf's bar, which clamps; the desktop draws the same distinction — and B pops.
|
||||
//
|
||||
// The deck: front cover 118 tall (2:3), corner 11, in a reserved 120×130 box at the tile's padded
|
||||
// top-leading; each slot further back is 7 % smaller, 18 pt right, 7 pt up and 6° turned — four
|
||||
// cues, drawn back-to-front so the sort-first cover is on top. Under each a HARD contact plate
|
||||
// (no blur — the plate is what gives the stack depth on a Deck-class GPU, and the same reasoning
|
||||
// holds for a phone). A launcher slot fans its brand mark and is never fetched; a group with fewer
|
||||
// than three titles fans fewer — a one-title group never fakes a stack.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// One tile of the Collections strip. `Identifiable` on the group's key so the carousel keeps its
|
||||
/// cursor across a resort (the groups' ORDER may change; their identity does not).
|
||||
struct CollectionTile: Identifiable, Hashable {
|
||||
let group: LibraryGroup
|
||||
var id: String {
|
||||
switch group.key {
|
||||
case .launchers: return "launchers"
|
||||
case .platform(let name): return "platform:\(name)"
|
||||
case .store(let name): return "store:\(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryCollectionsView: View {
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
/// The catalog in the model's order; the groups' indices point into it.
|
||||
let games: [GameEntry]
|
||||
/// `LibraryCollation.collate(games, sort, .platform)` — the container derives it.
|
||||
let groups: [LibraryGroup]
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
/// The focused tile's id (`CollectionTile.id`), published for the container's legend.
|
||||
@Binding var focusID: String?
|
||||
/// A — open this group as a filtered shelf.
|
||||
var onOpen: (LibraryGroupKey) -> Void
|
||||
/// Y — "All titles" (the Collections ROOT only); nil disables it.
|
||||
var onAllTitles: (() -> Void)?
|
||||
var onBack: (() -> Void)?
|
||||
/// L1 (-1) / R1 (+1) — step the sort, wrapping.
|
||||
var onSortStep: (Int) -> Void
|
||||
/// ▲ — the sort tray (the container's).
|
||||
var onUp: (() -> Void)?
|
||||
var controllerActive = true
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false
|
||||
#endif
|
||||
|
||||
private var tiles: [CollectionTile] { groups.map { CollectionTile(group: $0) } }
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let size = geo.size
|
||||
#if os(tvOS)
|
||||
let cardWidth = min(560, size.width * 0.34)
|
||||
let cardHeight = min(350, max(240, size.height - 40))
|
||||
let spacing: CGFloat = 44
|
||||
#else
|
||||
let cardWidth = min(340, size.width * 0.84)
|
||||
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
|
||||
let spacing: CGFloat = 30
|
||||
#endif
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
if tiles.isEmpty {
|
||||
Text("Nothing to collect yet — this library is still loading.")
|
||||
.font(.geist(15, relativeTo: .body))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
GamepadCarousel(
|
||||
items: tiles,
|
||||
selection: $focusID,
|
||||
itemWidth: cardWidth,
|
||||
spacing: spacing,
|
||||
onActivate: { onOpen($0.group.key) },
|
||||
onSecondary: onAllTitles,
|
||||
onBack: onBack,
|
||||
onUp: onUp,
|
||||
onShoulder: { right in onSortStep(right ? 1 : -1) },
|
||||
isActive: controllerActive
|
||||
) { tile, entrance in
|
||||
card(tile, size: CGSize(width: cardWidth, height: cardHeight), entrance: entrance)
|
||||
}
|
||||
.frame(height: cardHeight + 40)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
/// The tile plus the home strip's focus treatment (recede in scale, brightness, saturation,
|
||||
/// blur and alpha) — the same arithmetic `GamepadHomeView.hostCard` applies.
|
||||
private func card(_ tile: CollectionTile, size: CGSize, entrance: CardEntrance) -> some View {
|
||||
CollectionTileView(tile: tile, games: games, artLoader: artLoader, size: size, compact: compact)
|
||||
.modifier(entrance)
|
||||
.scrollTransition { content, phase in
|
||||
let d = CGFloat(min(abs(phase.value), 1))
|
||||
return content
|
||||
.scaleEffect(1 - d * 0.12)
|
||||
.brightness(Double(-d * 0.24))
|
||||
.saturation(Double(1 - d * 0.42))
|
||||
.blur(radius: d * 3)
|
||||
.opacity(Double(1 - d * 0.22))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One collection tile: the deck top-leading, the kind caption top-trailing, the label and count
|
||||
/// on the bottom rail — on the home tile's glass, to the pixel.
|
||||
private struct CollectionTileView: View {
|
||||
@Environment(\.gamepadInk) private var ink
|
||||
let tile: CollectionTile
|
||||
let games: [GameEntry]
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
let size: CGSize
|
||||
/// A landscape phone's shorter tile: the deck shrinks so it clears the label rail.
|
||||
var compact = false
|
||||
|
||||
#if os(tvOS)
|
||||
private static let titleFont: CGFloat = 33
|
||||
private static let countFont: CGFloat = 19
|
||||
private static let kindFont: CGFloat = 15
|
||||
private static let pad: CGFloat = 28
|
||||
private static let corner: CGFloat = 30
|
||||
private static let coverH: CGFloat = 170
|
||||
#else
|
||||
private static let titleFont: CGFloat = 23
|
||||
private static let countFont: CGFloat = 13
|
||||
private static let kindFont: CGFloat = 11
|
||||
private static let pad: CGFloat = 20
|
||||
private static let corner: CGFloat = 26
|
||||
private static let coverH: CGFloat = 118
|
||||
#endif
|
||||
/// The deck's cues per slot further back: scale, right, up, turn.
|
||||
private static let fanScaleStep: CGFloat = 0.07
|
||||
private static let fanDX: CGFloat = 18
|
||||
private static let fanDY: CGFloat = 7
|
||||
private static let fanRotDeg: Double = 6
|
||||
private static let fan = 3
|
||||
|
||||
private var count: Int { tile.group.indices.count }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
deck
|
||||
Spacer(minLength: 0)
|
||||
Text(tile.group.key.kindCaption)
|
||||
.font(.geist(Self.kindFont, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(ink.fg(0.45))
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Text(tile.group.label)
|
||||
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Text(count == 1 ? "1 title" : "\(count) titles")
|
||||
.font(.geist(Self.countFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(1)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding(Self.pad)
|
||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||
// The home tile's glass — `forceMaterial` for the same reason it gives: this card is
|
||||
// transformed by the entrance and the recede, and Liquid Glass cannot sample through that.
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||
tint: ink.accent(0.20),
|
||||
forceMaterial: true)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
colors: [ink.fg(0.22), ink.fg(0.04)],
|
||||
startPoint: .top, endPoint: .bottom),
|
||||
lineWidth: 1)
|
||||
}
|
||||
.shadow(color: ink.shadow(0.45), radius: 20, y: 14)
|
||||
}
|
||||
|
||||
/// Up to three covers from the group's sort-first titles, fanned back-to-front.
|
||||
private var deck: some View {
|
||||
let coverH = compact ? Self.coverH * 0.7 : Self.coverH
|
||||
let coverW = coverH * 2 / 3
|
||||
let slots = min(Self.fan, max(1, count))
|
||||
// The box the fan lives in: front cover + the back slots' rightward/upward drift.
|
||||
let boxW = coverW + Self.fanDX * CGFloat(slots - 1) + 8
|
||||
let boxH = coverH + Self.fanDY * CGFloat(slots - 1) + 8
|
||||
return ZStack(alignment: .bottomLeading) {
|
||||
ForEach((0..<slots).reversed(), id: \.self) { slot in
|
||||
let game = games[tile.group.indices[slot]]
|
||||
cover(game, width: coverW, height: coverH, slot: slot)
|
||||
.scaleEffect(1 - Self.fanScaleStep * CGFloat(slot), anchor: .bottomLeading)
|
||||
.rotationEffect(.degrees(Self.fanRotDeg * Double(slot)), anchor: .bottomLeading)
|
||||
.offset(x: Self.fanDX * CGFloat(slot), y: -Self.fanDY * CGFloat(slot))
|
||||
}
|
||||
}
|
||||
.frame(width: boxW, height: boxH, alignment: .bottomLeading)
|
||||
}
|
||||
|
||||
private func cover(_ game: GameEntry, width: CGFloat, height: CGFloat, slot: Int) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: 11, style: .continuous)
|
||||
return PosterImage(
|
||||
// A launcher fans its brand mark and is never fetched.
|
||||
candidates: game.isLauncher ? [] : game.art.posterCandidates,
|
||||
title: game.title, loader: artLoader, icon: game.iconToken,
|
||||
drawnSize: CGSize(width: width, height: height))
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(shape)
|
||||
// Back cards recede: a shade proportional to their depth, and a stronger hairline.
|
||||
.overlay { shape.fill(ink.shade(0.14 * Double(slot))) }
|
||||
.overlay { shape.strokeBorder(ink.fg(slot == 0 ? 0.18 : 0.28), lineWidth: 1) }
|
||||
// The HARD contact plate: outset 3, offset (1.5, 2), softer on a pale palette.
|
||||
.background {
|
||||
shape
|
||||
.inset(by: -3)
|
||||
.fill(ink.shade(0.38 * (ink.isLight ? 0.40 : 1)))
|
||||
.offset(x: 1.5, y: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,596 @@
|
||||
// The gamepad presentation of one library shelf — the chrome the desktop console's library
|
||||
// screen owns and both of its arrangements feed: the view/sort bar across the top, the field
|
||||
// (coverflow or grid, one persisted setting apart), the detail band under it, and the legend.
|
||||
// `LibraryView` still owns the DATA (fetch, cache, wake, running, art loader); this view owns
|
||||
// how the controller reads it.
|
||||
//
|
||||
// The sort and the arrangement are the cross-client `library_sort` / `library_view` keys, written
|
||||
// here and by the Interface settings rows alike, so the two surfaces can never disagree. Both
|
||||
// apply live: the collated shelf is re-derived from the setting and the focused TITLE survives —
|
||||
// the strip's cursor keeps its item across a list change and the grid re-anchors by id — so a
|
||||
// sort change never resets the cursor (the desktop's `sync` rule).
|
||||
//
|
||||
// Bar focus is routed here: ▲ from the field hands the controller to the bar (the field goes
|
||||
// inert), ◀▶ step the sort clamped, L1/R1 pick the arrangement outright, ▼ / A / B hand it back.
|
||||
// The legend swaps with it. This is the same "one owner of the controller at a time" hand-over
|
||||
// the shell does between its layers.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
struct LibraryConsoleView: View {
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
/// The catalog in the model's order (`LibraryOrder.display`) — this view collates it.
|
||||
let games: [GameEntry]
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
var running: [String: RunningGame] = [:]
|
||||
var staleness: LibraryStaleness = .none
|
||||
/// The title last opened from this shelf — both arrangements open on it.
|
||||
var initialSelection: String?
|
||||
/// Button B at the shelf — dismisses the library screen.
|
||||
var onDismiss: (() -> Void)?
|
||||
/// Copy a title's `punktfunk://` link — offered from the title's Options menu (X). nil where
|
||||
/// the platform has no clipboard (tvOS), which drops the row and, with nothing else in the
|
||||
/// menu worth a press, the X hint.
|
||||
var onCopyLink: ((GameEntry) -> Void)?
|
||||
/// The host's name, for the Options menu's explainer.
|
||||
var hostName: String?
|
||||
/// Whether this screen owns the controller — the shell gates it mid-transition and under the
|
||||
/// connect takeover.
|
||||
var controllerActive = true
|
||||
/// The collection the shelf is filtered to (its label), or nil — the container reports it so
|
||||
/// the screen's title can read `host · profile · collection` like the desktop's.
|
||||
var onCollectionChanged: ((String?) -> Void)?
|
||||
/// Screenshot/dev overrides: force an arrangement, open with the bar focused, or start on
|
||||
/// the Collections tiles regardless of the setting.
|
||||
var arrangementOverride: LibraryArrangement?
|
||||
var barFocusedInitially = false
|
||||
var startInCollectionsOverride: Bool?
|
||||
/// Screenshot override: open the first title's Options menu on mount.
|
||||
var optionsInitially = false
|
||||
|
||||
@Environment(\.gamepadHostedInShell) private var hostedInShell
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
@Environment(\.horizontalSizeClass) private var hSizeClass
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false
|
||||
#endif
|
||||
@AppStorage(DefaultsKey.librarySort) private var sortRaw = ""
|
||||
@AppStorage(DefaultsKey.libraryView) private var viewRaw = ""
|
||||
@AppStorage(DefaultsKey.libraryCollections) private var startInCollections = false
|
||||
/// Where the controller is inside this shelf: the plain shelf, the Collections tiles, or a
|
||||
/// filtered shelf — pushed and popped INSIDE this layer (see `LibraryPlaceStack`).
|
||||
@State private var places = LibraryPlaceStack(root: .shelf(filter: nil))
|
||||
/// The "start in collections" hand-over is decided ONCE per shelf.
|
||||
@State private var handoverDecided = false
|
||||
/// The focused Collections tile.
|
||||
@State private var focusedTileID: String?
|
||||
/// The bar owns the controller.
|
||||
@State private var barFocused = false
|
||||
/// The focused title (the strip's centred cover / the grid's cell), published by the
|
||||
/// arrangement — feeds the detail band and every hint, read at press time.
|
||||
@State private var focusID: String?
|
||||
/// The title whose Options menu is up (X) — a layer over the field that takes the controller.
|
||||
@State private var optionsFor: GameEntry?
|
||||
@State private var barInput = GamepadMenuInput(manager: .shared)
|
||||
@State private var barHaptics = MenuHaptics(manager: .shared)
|
||||
@State private var barBoundaryTick = 0
|
||||
|
||||
private var sort: LibrarySortKey { LibrarySortKey(stored: sortRaw) }
|
||||
private var arrangement: LibraryArrangement {
|
||||
arrangementOverride ?? LibraryArrangement(stored: viewRaw)
|
||||
}
|
||||
/// The shelf, collated: launchers lead in host order, then the titles under `sort`
|
||||
/// (`filtered(nil)` flattens every group in collated order); on a drilled shelf, that
|
||||
/// group's titles alone.
|
||||
private var displayed: [GameEntry] {
|
||||
LibraryCollation.filtered(games, sort: sort, filter: places.top.filter).map { games[$0] }
|
||||
}
|
||||
/// The Collections tiles: group by platform under the current sort.
|
||||
private var groups: [LibraryGroup] {
|
||||
LibraryCollation.collate(games, sort: sort, groupBy: .platform)
|
||||
}
|
||||
private var focused: GameEntry? { displayed.first { $0.id == focusID } }
|
||||
/// The field owns the controller only while neither the bar nor a title's Options menu does.
|
||||
private var fieldActive: Bool { controllerActive && !barFocused && optionsFor == nil }
|
||||
/// Whether a title has an Options menu worth opening (today: only the Copy link row).
|
||||
private var offersOptions: Bool { onCopyLink != nil }
|
||||
/// Whether Y opens Collections here: an unfiltered root shelf over a library worth browsing.
|
||||
private var canOpenCollections: Bool {
|
||||
places.canOpenCollections && LibraryCollation.worthBrowsing(games)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Keyed on the top place: a push or pop mounts the incoming place fresh (its own entrance,
|
||||
// its own cursor seeded on the focused title) and moves it with the shell's own push/pop
|
||||
// choreography — the same slide-out-of-a-fade the shell uses between its layers.
|
||||
let top = places.top
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
field
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
if !top.isCollections {
|
||||
detailPanel
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
.padding(.bottom, compact ? 2 : 10)
|
||||
} else {
|
||||
// The tiles carry their own text; keep the band's height so the strip doesn't
|
||||
// jump when a place is pushed over the shelf.
|
||||
Color.clear.frame(height: compact ? 30 : 60)
|
||||
}
|
||||
}
|
||||
// A push/pop is a state change inside `placeMotion` (see the mutations below), so the
|
||||
// outgoing place leaves and the incoming one arrives with the shell's own transition.
|
||||
.id(top)
|
||||
.transition(
|
||||
reduceMotion
|
||||
? .opacity
|
||||
: .gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
|
||||
// Under a title's Options menu the field recedes exactly as the launcher does under a
|
||||
// shell layer (`covered` in GamepadHomeView): out of sight, a touch smaller, inert.
|
||||
.opacity(optionsFor == nil ? 1 : 0)
|
||||
.scaleEffect(optionsFor == nil ? 1 : GamepadShellMotion.underScale)
|
||||
.allowsHitTesting(optionsFor == nil)
|
||||
// The view/sort bar is a TRAY, not a band: it slides down over the field on ▲ (and
|
||||
// the legend's `Sort & view` cell) and back up on ▼/A/B, so the field keeps every
|
||||
// point of height it has — a fixed band ate a third of a landscape phone. While it
|
||||
// is down it owns the controller (`fieldActive`), and the legend says so.
|
||||
.overlay(alignment: .top) {
|
||||
if barFocused, optionsFor == nil {
|
||||
LibraryBarView(
|
||||
sort: sort, arrangement: arrangement, focused: true,
|
||||
showsView: !top.isCollections, compact: compact,
|
||||
onSort: { setSort($0) }, onArrangement: { setArrangement($0) }
|
||||
)
|
||||
.padding(.top, compact ? 4 : 8)
|
||||
.padding(.bottom, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
// A title's Options menu rides over the field as its own layer, with its own title
|
||||
// band and legend; the field underneath goes inert until it closes.
|
||||
if let game = optionsFor {
|
||||
LibraryTitleOptionsView(
|
||||
game: game, hostName: hostName, onCopyLink: onCopyLink,
|
||||
close: { closeOptions() }, controllerActive: controllerActive)
|
||||
.zIndex(2)
|
||||
.transition(
|
||||
reduceMotion
|
||||
? .opacity
|
||||
: .gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
|
||||
}
|
||||
}
|
||||
// The legend, over a bottom tray blur — rows scroll under it, so it needs the same
|
||||
// material the settings screen's tray has (bare, the grid drew straight through it).
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
if optionsFor == nil {
|
||||
GamepadHintBar(hints: barFocused ? barHints : (top.isCollections ? collectionHints : hints))
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .bottom) }
|
||||
}
|
||||
}
|
||||
// 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()
|
||||
.onAppear {
|
||||
decideHandover()
|
||||
barFocused = barFocusedInitially
|
||||
if optionsInitially, offersOptions, let first = displayed.first { optionsFor = first }
|
||||
wireBar()
|
||||
if barFocused, controllerActive { barInput.start() }
|
||||
}
|
||||
.onChange(of: games.map(\.id)) { _, _ in
|
||||
// A later list (the host answering behind a cached catalog) may make the library
|
||||
// browsable — but only an undecided shelf may still move to the tiles.
|
||||
decideHandover()
|
||||
}
|
||||
.onChange(of: places) { _, stack in
|
||||
onCollectionChanged?(stack.top.filter?.label)
|
||||
}
|
||||
.onChange(of: barFocused) { _, focusedNow in
|
||||
if focusedNow, controllerActive { barInput.start() } else { barInput.stop() }
|
||||
}
|
||||
.onChange(of: controllerActive) { _, active in
|
||||
if !active { barInput.stop() } else if barFocused { barInput.start() }
|
||||
}
|
||||
.onDisappear {
|
||||
barInput.stop()
|
||||
barHaptics.stop()
|
||||
}
|
||||
#if os(iOS) || os(macOS)
|
||||
// Hardware keyboard while the bar has focus: arrows step the sort, Return/Esc hand back.
|
||||
.gamepadKeyNavigation(
|
||||
active: barFocused && controllerActive,
|
||||
onMove: { barMove($0) },
|
||||
onConfirm: { leaveBar() },
|
||||
onBack: { leaveBar() })
|
||||
#endif
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: barBoundaryTick)
|
||||
}
|
||||
|
||||
/// The arrangement — one shelf, two fields; the persisted setting picks. Keyed on the
|
||||
/// arrangement so a switch mounts the other field fresh (its own entrance, its own cursor
|
||||
/// seeded on the focused title).
|
||||
@ViewBuilder private var field: some View {
|
||||
if places.top.isCollections {
|
||||
LibraryCollectionsView(
|
||||
games: games, groups: groups, artLoader: artLoader, focusID: $focusedTileID,
|
||||
onOpen: { openCollection($0) },
|
||||
onAllTitles: places.offersAllTitles ? { openAllTitles() } : nil,
|
||||
onBack: { back() },
|
||||
onSortStep: { stepSort(by: $0, wrapping: true) },
|
||||
onUp: { enterBar() },
|
||||
controllerActive: fieldActive)
|
||||
} else {
|
||||
switch arrangement {
|
||||
case .shelf:
|
||||
LibraryCoverflowView(
|
||||
games: displayed, artLoader: artLoader, focusID: $focusID, onLaunch: onLaunch,
|
||||
running: running, initialSelection: focusID ?? initialSelection,
|
||||
onBack: { back() },
|
||||
onSecondary: { openCollections() },
|
||||
onTertiary: offersOptions ? { openOptions() } : nil,
|
||||
onUp: { enterBar() },
|
||||
controllerActive: fieldActive)
|
||||
case .grid:
|
||||
LibraryGridView(
|
||||
games: displayed, artLoader: artLoader, focusID: $focusID, onLaunch: onLaunch,
|
||||
running: running, initialSelection: focusID ?? initialSelection,
|
||||
onBack: { back() },
|
||||
onSecondary: { openCollections() },
|
||||
onTertiary: offersOptions ? { openOptions() } : nil,
|
||||
onUp: { enterBar() },
|
||||
controllerActive: fieldActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detail band
|
||||
|
||||
/// Whether the band spells out `STORE · PLATFORM` under the title. Not on the shelf — the
|
||||
/// cover's own chip already says it, and the line only made the band tight — and not in a
|
||||
/// landscape phone's height on the grid either; the grid keeps it elsewhere, since its cells
|
||||
/// draw no chip.
|
||||
private var showsSubtitle: Bool { arrangement == .grid && !compact }
|
||||
|
||||
/// The focused title (+ its provenance where `showsSubtitle`) — empty (not hidden) so the
|
||||
/// layout doesn't jump. The staleness note shares the second line, leading, the way the
|
||||
/// desktop shelf draws it: the titles stay, the wording says where they came from.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let game = focused
|
||||
VStack(spacing: compact ? 3 : 6) {
|
||||
Text(game?.title ?? " ")
|
||||
.font(.geist(compact ? 20 : 25, .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.multilineTextAlignment(.center)
|
||||
// Three columns of equal give: the note leads and truncates, the subtitle stays
|
||||
// centred, the trailing column is air — so on a narrow phone the two never overlap.
|
||||
HStack(spacing: 8) {
|
||||
Group {
|
||||
if let note = staleness.text {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: staleness.symbol)
|
||||
Text(note)
|
||||
}
|
||||
.font(.geist(11, relativeTo: .caption2))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
Color.clear.frame(height: 1)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let game, showsSubtitle {
|
||||
Text(subtitle(for: game))
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(ink.fg(0.5))
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
Color.clear.frame(maxWidth: .infinity, maxHeight: 1)
|
||||
}
|
||||
// With neither a subtitle nor a note the second line collapses to a hairline of air.
|
||||
.frame(minHeight: showsSubtitle || staleness.text != nil ? nil : 1)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.animation(.smooth(duration: 0.25), value: focusID)
|
||||
}
|
||||
|
||||
/// `STORE · LAUNCHER` for a launcher, `STORE · PLATFORM` when the host named a platform, else
|
||||
/// `STORE` — the desktop's detail-band rule.
|
||||
private func subtitle(for game: GameEntry) -> String {
|
||||
let store = game.storeLabel.uppercased()
|
||||
if game.isLauncher { return "\(store) · LAUNCHER" }
|
||||
if let platform = game.platform?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!platform.isEmpty {
|
||||
return "\(store) · \(platform.uppercased())"
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// MARK: - Legend
|
||||
|
||||
/// Whether the legend advertises the shoulder jump. Held back on a phone — regular WIDTH on a
|
||||
/// Pro Max in landscape notwithstanding, its legend is already at its width — and never on
|
||||
/// tvOS (a Siri Remote has no shoulders); the settings strip's own rule, height-aware.
|
||||
private var showsShoulderHint: Bool {
|
||||
#if os(tvOS)
|
||||
false
|
||||
#elseif os(iOS)
|
||||
hSizeClass == .regular && !compact
|
||||
#else
|
||||
true
|
||||
#endif
|
||||
}
|
||||
|
||||
/// A portrait phone: the legend pill has room for three cells, not six. X and ▲ keep working;
|
||||
/// their cells go, the way the settings strip drops its shoulder cell on a phone.
|
||||
private var narrowLegend: Bool {
|
||||
#if os(iOS)
|
||||
hSizeClass == .compact && !compact
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// The field's legend, in the desktop's order: A Resume|Open|Play · X Copy link · L1/R1 Jump
|
||||
/// · ▲ Sort & view · B Back. Every cell re-resolves the focused title at fire time.
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if let onLaunch {
|
||||
let game = focused
|
||||
let text: String
|
||||
if let game, running[game.id] != nil {
|
||||
text = "Resume"
|
||||
} else if game?.isLauncher == true {
|
||||
text = "Open"
|
||||
} else {
|
||||
text = "Play"
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: text,
|
||||
action: { if let id = focusID { onLaunch(id) } }))
|
||||
}
|
||||
// Hidden when there is nothing to browse (one platform, one store) and on any drilled
|
||||
// shelf — the way back from those is B.
|
||||
if canOpenCollections {
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Collections",
|
||||
action: { openCollections() }))
|
||||
}
|
||||
// The desktop's `X Options` — a title's own actions live in a menu, not on a face button.
|
||||
if offersOptions, !narrowLegend {
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Options",
|
||||
action: { openOptions() }))
|
||||
}
|
||||
if showsShoulderHint {
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "Jump"))
|
||||
}
|
||||
if !narrowLegend {
|
||||
hints.append(.init(glyph: "arrow.up", text: "Sort & view", action: { enterBar() }))
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { onDismiss?() }))
|
||||
return hints
|
||||
}
|
||||
|
||||
/// The Collections legend: A Open · Y All titles (root only) · L1/R1 Sort · B Back.
|
||||
private var collectionHints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = [
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Open",
|
||||
action: {
|
||||
if let id = focusedTileID,
|
||||
let g = groups.first(where: { CollectionTile(group: $0).id == id }) {
|
||||
openCollection(g.key)
|
||||
}
|
||||
}),
|
||||
]
|
||||
if places.offersAllTitles {
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "All titles",
|
||||
action: { openAllTitles() }))
|
||||
}
|
||||
// The shoulders step the sort where the legend has room to say so; the tray (▲) is the
|
||||
// way that is always advertised, and the only one a pointer can reach.
|
||||
if showsShoulderHint {
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "Sort"))
|
||||
} else {
|
||||
hints.append(.init(glyph: "arrow.up", text: "Sort", action: { enterBar() }))
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { back() }))
|
||||
return hints
|
||||
}
|
||||
|
||||
/// The legend while the bar has the controller — it REPLACES the field's.
|
||||
private var barHints: [GamepadHint] {
|
||||
[
|
||||
.init(glyph: "arrow.left.and.right", text: "Sort"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"),
|
||||
text: "View"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { leaveBar() }),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
/// X: the FOCUSED title's Options menu — read at press time, inert with nothing focused.
|
||||
private func openOptions() {
|
||||
guard offersOptions, let game = focused else { return }
|
||||
leaveBar()
|
||||
barHaptics.move()
|
||||
withAnimation(placeMotion) { optionsFor = game }
|
||||
}
|
||||
|
||||
private func closeOptions() {
|
||||
withAnimation(placeMotion) { optionsFor = nil }
|
||||
}
|
||||
|
||||
private func setSort(_ key: LibrarySortKey) {
|
||||
guard key != sort else { return }
|
||||
sortRaw = key.stored
|
||||
}
|
||||
|
||||
/// Step the sort by ±1 — clamped on the bar, WRAPPING on the Collections tiles.
|
||||
private func stepSort(by delta: Int, wrapping: Bool) {
|
||||
let all = LibrarySortKey.all
|
||||
guard let i = all.firstIndex(of: sort) else { return setSort(all[0]) }
|
||||
var target = i + delta
|
||||
if wrapping {
|
||||
target = (target % all.count + all.count) % all.count
|
||||
} else if !all.indices.contains(target) {
|
||||
return barBoundary()
|
||||
}
|
||||
barHaptics.move()
|
||||
setSort(all[target])
|
||||
}
|
||||
|
||||
// MARK: - Places
|
||||
|
||||
/// The push/pop choreography between places — the shell's own (`GamepadShellMotion`), a plain
|
||||
/// crossfade under Reduce Motion.
|
||||
private var placeMotion: Animation {
|
||||
reduceMotion ? GamepadShellMotion.reducedScreen : GamepadShellMotion.screen
|
||||
}
|
||||
|
||||
/// The "start in collections" hand-over — decided once per shelf. This view is mounted only
|
||||
/// while there is a catalog (a cached one counts), so "ready" is true by construction here.
|
||||
private func decideHandover() {
|
||||
let on = startInCollectionsOverride ?? startInCollections
|
||||
switch CollectionsHandover.decide(
|
||||
settingOn: on, alreadyDecided: handoverDecided, drilled: !places.isRoot,
|
||||
ready: !games.isEmpty, worthBrowsing: LibraryCollation.worthBrowsing(games))
|
||||
{
|
||||
case .wait:
|
||||
return
|
||||
case .shelf:
|
||||
handoverDecided = true
|
||||
case .collections:
|
||||
handoverDecided = true
|
||||
// The Collections place REPLACES the shelf as the root (stack length unchanged),
|
||||
// exactly as the desktop's hand-over does.
|
||||
places = LibraryPlaceStack(root: .collections)
|
||||
}
|
||||
}
|
||||
|
||||
/// Y on the shelf: push Collections — or a boundary pulse where it is refused (a drilled
|
||||
/// shelf, or nothing worth browsing).
|
||||
private func openCollections() {
|
||||
guard canOpenCollections else { return barBoundary() }
|
||||
barHaptics.move()
|
||||
leaveBar()
|
||||
withAnimation(placeMotion) { places.push(.collections) }
|
||||
}
|
||||
|
||||
/// A on a tile: push that group as a filtered shelf.
|
||||
private func openCollection(_ key: LibraryGroupKey) {
|
||||
withAnimation(placeMotion) { places.push(.shelf(filter: key)) }
|
||||
}
|
||||
|
||||
/// Y on the Collections root: the plain shelf, as a drill-in ("All titles").
|
||||
private func openAllTitles() {
|
||||
guard places.offersAllTitles else { return barBoundary() }
|
||||
withAnimation(placeMotion) { places.push(.shelf(filter: nil)) }
|
||||
}
|
||||
|
||||
/// B: pop a place; at the root, dismiss the layer.
|
||||
private func back() {
|
||||
leaveBar()
|
||||
guard !places.isRoot else { return onDismiss?() ?? () }
|
||||
_ = withAnimation(placeMotion) { places.pop() }
|
||||
}
|
||||
|
||||
private func setArrangement(_ view: LibraryArrangement) {
|
||||
guard view != arrangement else { return }
|
||||
viewRaw = view.stored
|
||||
}
|
||||
|
||||
// MARK: - Bar input
|
||||
|
||||
/// The tray's slide — the console's INDICATOR spring (the keyboard tray's, too); a plain
|
||||
/// fade under Reduce Motion.
|
||||
private var trayMotion: Animation {
|
||||
reduceMotion ? .easeOut(duration: 0.15) : .spring(response: 0.32, dampingFraction: 0.86)
|
||||
}
|
||||
|
||||
private func enterBar() {
|
||||
guard !barFocused else { return }
|
||||
barHaptics.move()
|
||||
withAnimation(trayMotion) { barFocused = true }
|
||||
}
|
||||
|
||||
private func leaveBar() {
|
||||
guard barFocused else { return }
|
||||
withAnimation(trayMotion) { barFocused = false }
|
||||
}
|
||||
|
||||
private func wireBar() {
|
||||
barInput.onMove = { barMove($0) }
|
||||
barInput.onConfirm = { leaveBar() }
|
||||
barInput.onBack = { leaveBar() }
|
||||
barInput.onSecondary = nil
|
||||
barInput.onTertiary = nil
|
||||
// L1 = Shelf, R1 = Grid — outright, no wrap, and a repeat press is a boundary, not a
|
||||
// flip-flop.
|
||||
barInput.onShoulder = { right in
|
||||
let want: LibraryArrangement = right ? .grid : .shelf
|
||||
guard want != arrangement else { return barBoundary() }
|
||||
barHaptics.move()
|
||||
setArrangement(want)
|
||||
}
|
||||
}
|
||||
|
||||
private func barMove(_ direction: GamepadMenuInput.Direction) {
|
||||
switch direction {
|
||||
case .left, .right:
|
||||
// Step the sort, CLAMPED — no wrap (the Collections tiles' shoulders wrap, this bar
|
||||
// does not; the desktop draws the same distinction).
|
||||
stepSort(by: direction == .right ? 1 : -1, wrapping: false)
|
||||
case .down:
|
||||
leaveBar()
|
||||
case .up:
|
||||
barBoundary()
|
||||
}
|
||||
}
|
||||
|
||||
private func barBoundary() {
|
||||
barBoundaryTick &+= 1
|
||||
barHaptics.boundary()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,17 +1,16 @@
|
||||
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS/tvOS — see LibraryView's
|
||||
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
|
||||
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
|
||||
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
||||
// the controller-glyph hints. A steps through covers, A launches the centered title, B closes, and
|
||||
// the shoulders (L1/R1) jump a handful at a time through a long library.
|
||||
// The gamepad library's SHELF arrangement (iOS/iPadOS/macOS/tvOS — see LibraryView's
|
||||
// `gamepadUIActive` branch): a classic coverflow. All the scrolling/snapping/navigation/haptics
|
||||
// live in GamepadCarousel; this file is the coverflow card (poster + the 3D recede treatment via
|
||||
// `.scrollTransition`) and the LAUNCHERS/GAMES heading. The detail band, the legend, the backdrop
|
||||
// and the view/sort bar belong to `LibraryConsoleView`, which hosts this arrangement and the grid
|
||||
// alike — one shelf, two arrangements, one set of chrome, exactly the desktop console's split.
|
||||
//
|
||||
// Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
|
||||
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
|
||||
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
|
||||
// status bar / home indicator. As a background it draws behind without affecting layout, so the
|
||||
// GeometryReader is sized to the safe area, and the controller-glyph hints are pinned inside it with
|
||||
// `.safeAreaInset(.bottom, alignment: .leading)`. Cover size is then derived from the height that
|
||||
// remains, so a tall 2:3 poster + the detail line always fit.
|
||||
// A steps through covers, A launches the centered title, B closes, the shoulders (L1/R1) jump a
|
||||
// handful at a time through a long library, and ▲ hands the controller to the bar above.
|
||||
//
|
||||
// Layout discipline (so nothing is EVER clipped, portrait or landscape): the container hands this
|
||||
// view the height that remains after its own chrome; cover size is derived from that, so a tall
|
||||
// 2:3 poster always fits.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
@@ -19,44 +18,45 @@ import SwiftUI
|
||||
import GameController
|
||||
|
||||
struct LibraryCoverflowView: View {
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — this screen publishes that
|
||||
/// value itself and so sits above its own copy (see `GamepadInk.stored`).
|
||||
/// Resolved from the stored palette, NOT from `\.gamepadInk` — the container publishes that
|
||||
/// value and this view sits above its own copy (see `GamepadInk.stored`).
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
/// The shelf in DISPLAY order — collated by the container (launchers lead, then the sort).
|
||||
let games: [GameEntry]
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
/// The centred title, published for the container's detail band and legend. Output only —
|
||||
/// the carousel's cursor is the authority (see GamepadCarousel's header).
|
||||
@Binding var focusID: String?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
/// Which titles the host already has up, keyed by library id — so a card the player can return
|
||||
/// to says `Resume` rather than looking like every other one. Empty on an older host.
|
||||
var running: [String: RunningGame] = [:]
|
||||
/// 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)?
|
||||
/// Button X — copy the centered title's `punktfunk://` link. The coverflow's answer to the
|
||||
/// touch grid's context menu: a controller has no right-click, so the one per-game action
|
||||
/// there is gets a face button and a legend entry rather than a menu holding a single row.
|
||||
/// nil where the platform has no clipboard (tvOS), which also drops the hint.
|
||||
var onCopyLink: ((GameEntry) -> 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.
|
||||
/// The title to assemble the strip around on mount — the one last opened from this shelf
|
||||
/// (`LibraryScrollMemory`), so coming back from a stream lands where the player left rather
|
||||
/// than on the first cover. Ignored when it is no longer in the list.
|
||||
var initialSelection: String?
|
||||
/// Button B (back) — the container decides what that means (pop a place, or dismiss).
|
||||
var onBack: (() -> Void)?
|
||||
/// Button Y — the container's secondary action (Collections); nil disables it.
|
||||
var onSecondary: (() -> Void)?
|
||||
/// Button X — copy the centred title's link; nil where the platform has no clipboard.
|
||||
var onTertiary: (() -> Void)?
|
||||
/// ▲ — hand the controller to the view/sort bar above the field. Wiring it makes vertical
|
||||
/// the bar's axis (down goes inert), the reading the desktop shelf has.
|
||||
var onUp: (() -> Void)?
|
||||
/// Whether the carousel owns the controller — the container gates it while the bar has
|
||||
/// focus, and the shell gates it mid-transition and under the connect takeover.
|
||||
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.
|
||||
/// `.compact` in a landscape phone window — a tighter poster so the title band gets air.
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false // no size classes on macOS
|
||||
#endif
|
||||
@State private var selection: String?
|
||||
/// The copy hint's acknowledgement. There is no toast on this surface, so the legend entry
|
||||
/// says it itself — the same answer `GamepadHostOptionsView` gives, in the place the user is
|
||||
/// already looking. Transient here (the screen stays up, unlike that menu), and cleared the
|
||||
/// moment the strip moves, since "Copied" was about the cover that WAS centred.
|
||||
@State private var copied = false
|
||||
|
||||
/// How many covers have settled (art loaded, or every candidate exhausted).
|
||||
@State private var artSettled = 0
|
||||
/// The backstop below has fired: play the entrance regardless of what the art is doing.
|
||||
@@ -74,39 +74,20 @@ struct LibraryCoverflowView: View {
|
||||
GeometryReader { geo in
|
||||
content(for: geo.size)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
GamepadHintBar(hints: hints)
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, compact ? 6 : 10)
|
||||
}
|
||||
// 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()
|
||||
// The entrance's backstop (see `contentReady`).
|
||||
.task {
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
artWaitOver = true
|
||||
}
|
||||
// "Copied" is an acknowledgement, not a state — it goes away on its own, and at once if
|
||||
// the strip moves off the cover it was about.
|
||||
.task(id: copied) {
|
||||
guard copied else { return }
|
||||
try? await Task.sleep(for: .milliseconds(1600))
|
||||
withAnimation(.smooth(duration: 0.2)) { copied = false }
|
||||
}
|
||||
.onChange(of: selection) { _, _ in copied = false }
|
||||
}
|
||||
|
||||
@ViewBuilder private func content(for size: CGSize) -> some View {
|
||||
// Fit the tallest poster into the height the detail line + paddings leave (the hints are a
|
||||
// safe-area inset, already out of this budget) — capped so it never dwarfs a large iPad and
|
||||
// Fit the tallest poster into the height the container left us (the detail band and the
|
||||
// legend are already out of this budget) — capped so it never dwarfs a large iPad and
|
||||
// clamped by width on a narrow screen.
|
||||
let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0)
|
||||
// In a landscape phone's height the title band sits right under the strip; hold a
|
||||
// little of the cover's height back so the two don't touch.
|
||||
let reserved: CGFloat = (compact ? 26 : 8) + (showsGroupHeading ? 26 : 0)
|
||||
let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9))
|
||||
let coverWidth = coverHeight * 2 / 3
|
||||
|
||||
@@ -116,8 +97,6 @@ struct LibraryCoverflowView: View {
|
||||
groupHeading.padding(.bottom, 6)
|
||||
}
|
||||
carousel(coverWidth: coverWidth, coverHeight: coverHeight)
|
||||
detailPanel
|
||||
.padding(.top, 12)
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -126,12 +105,15 @@ struct LibraryCoverflowView: View {
|
||||
private func carousel(coverWidth: CGFloat, coverHeight: CGFloat) -> some View {
|
||||
GamepadCarousel(
|
||||
items: games,
|
||||
selection: $selection,
|
||||
selection: $focusID,
|
||||
itemWidth: coverWidth,
|
||||
spacing: 34,
|
||||
initialItemID: initialSelection,
|
||||
onActivate: { onLaunch?($0.id) },
|
||||
onTertiary: onCopyLink.map { copy in { copyCentered(copy) } },
|
||||
onBack: { onDismiss?() },
|
||||
onSecondary: onSecondary,
|
||||
onTertiary: onTertiary,
|
||||
onBack: onBack,
|
||||
onUp: onUp,
|
||||
shoulderJump: 5,
|
||||
isActive: controllerActive,
|
||||
contentReady: contentReady
|
||||
@@ -150,7 +132,10 @@ struct LibraryCoverflowView: View {
|
||||
) -> some View {
|
||||
PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
icon: game.iconToken, onLoaded: { artSettled += 1 })
|
||||
icon: game.iconToken,
|
||||
// Decode at the size drawn (the container's poster box), never at the CDN's.
|
||||
drawnSize: CGSize(width: width, height: height),
|
||||
onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(alignment: .topLeading) {
|
||||
@@ -186,14 +171,6 @@ struct LibraryCoverflowView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand the CENTERED title to the copy action. Read at press time, not when the legend or
|
||||
/// the carousel was built (the same rule A's hint follows), and inert with nothing centred.
|
||||
private func copyCentered(_ copy: (GameEntry) -> Void) {
|
||||
guard let game = games.first(where: { $0.id == selection }) else { return }
|
||||
copy(game)
|
||||
withAnimation(.smooth(duration: 0.2)) { copied = true }
|
||||
}
|
||||
|
||||
/// Does this library have both groups? Only then does the heading earn its row — a
|
||||
/// launcher-less library gets exactly the layout it had before design D4.
|
||||
private var showsGroupHeading: Bool {
|
||||
@@ -204,63 +181,11 @@ struct LibraryCoverflowView: View {
|
||||
/// rail (a whole new up/down nav model for two or three tiles) the heading names the group and
|
||||
/// changes as the selection crosses the boundary — the launcher entries lead the strip.
|
||||
private var groupHeading: some View {
|
||||
let selected = games.first { $0.id == selection }
|
||||
let selected = games.first { $0.id == focusID }
|
||||
return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES")
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(ink.fg(0.45))
|
||||
}
|
||||
|
||||
/// The centered title + store tag — empty (not hidden) so the layout doesn't jump.
|
||||
@ViewBuilder private var detailPanel: some View {
|
||||
let game = games.first { $0.id == selection }
|
||||
VStack(spacing: 6) {
|
||||
Text(game?.title ?? " ")
|
||||
.font(.geist(compact ? 22 : 25, .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.multilineTextAlignment(.center)
|
||||
if let game {
|
||||
// main's richer store label, in the palette's ink.
|
||||
Text(
|
||||
game.isLauncher
|
||||
? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased()
|
||||
)
|
||||
.font(.geist(11, .semibold, relativeTo: .caption2))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(ink.fg(0.5))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 24)
|
||||
.animation(.smooth(duration: 0.25), value: selection)
|
||||
}
|
||||
|
||||
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if let onLaunch {
|
||||
// You *open* a launcher and *launch* a game — the hint follows the focused entry.
|
||||
let opens = games.first { $0.id == selection }?.isLauncher == true
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: opens ? "Open" : "Launch",
|
||||
// Reads `selection` when it fires, not when the legend was built (see the
|
||||
// launcher's twin) — and does nothing with no title centred, which is exactly
|
||||
// what A does.
|
||||
action: { if let id = selection { onLaunch(id) } }))
|
||||
}
|
||||
if let onCopyLink {
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"),
|
||||
text: copied ? "Copied" : "Copy link",
|
||||
action: { copyCentered(onCopyLink) }))
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close",
|
||||
action: { onDismiss?() }))
|
||||
return hints
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
// The gamepad library's GRID arrangement — the desktop console's grid (`screens/library.rs`),
|
||||
// in SwiftUI: 2:3 poster cells in rows that scroll vertically behind a viewport (a move scrolls
|
||||
// only as far as it must to keep the focused cell in view; a restored cursor is seated a third of
|
||||
// the way down), the launcher rows sitting squarely above the game rows with a heading between. Every cell is equally readable (no coverflow recede); focus is a ×1.06 pop, an
|
||||
// accent ring OUTSIDE the cover, and the shadow. The `Resume` badge keeps the coverflow's corner —
|
||||
// the same badge in two different corners on two arrangements of the same shelf would read as
|
||||
// two different badges.
|
||||
//
|
||||
// The cursor is `LibraryGridCursor` over ONE `LibraryGridShape` that this view builds from what
|
||||
// it actually laid out (the column count is a render fact, published before navigation is
|
||||
// allowed — a move that arrives before the first layout is declined rather than guessed). ◀▶
|
||||
// walk the row and refuse at its true ends with a bump; ▲▼ change row carrying the remembered
|
||||
// column; L1/R1 page three rows and land on the ends; ▲ from the TOP row hands the controller to
|
||||
// the bar. The detail band, legend and backdrop are `LibraryConsoleView`'s.
|
||||
//
|
||||
// Sizing follows the desktop's `k`: cells are 150×225 design units, gap 16, margin 48, scaled by
|
||||
// `min(width, height) / 800` clamped to 0.75…3, columns = what fits, clamped 2…8, and the columns
|
||||
// then stretch to fill the field's width. A Deck-shaped window gets 7, an iPad in landscape 6–7,
|
||||
// a phone in landscape 7, an Apple TV 8.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
struct LibraryGridView: View {
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
/// The shelf in DISPLAY order — collated by the container (launchers lead, then the sort).
|
||||
let games: [GameEntry]
|
||||
let artLoader: (any LibraryArtSource)?
|
||||
/// The focused title, published for the container's detail band and legend.
|
||||
@Binding var focusID: String?
|
||||
var onLaunch: ((String) -> Void)?
|
||||
var running: [String: RunningGame] = [:]
|
||||
/// The title to open on — see `LibraryCoverflowView.initialSelection`.
|
||||
var initialSelection: String?
|
||||
var onBack: (() -> Void)?
|
||||
var onSecondary: (() -> Void)?
|
||||
var onTertiary: (() -> Void)?
|
||||
/// ▲ from the top row: hand the controller to the bar.
|
||||
var onUp: (() -> Void)?
|
||||
var controllerActive = true
|
||||
|
||||
@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 — `focusID` chases it.
|
||||
@FocusState private var tvFocus: String?
|
||||
#endif
|
||||
/// The column the user last CHOSE (a horizontal move), carried across vertical ones.
|
||||
@State private var colHint = 0
|
||||
/// Columns as laid out — 0 until the first layout pass, during which navigation declines.
|
||||
@State private var cols = 0
|
||||
/// Boundary recoil, deflected along the axis pushed.
|
||||
@State private var bump = CGSize.zero
|
||||
@State private var boundaryTick = 0
|
||||
@State private var activateTick = 0
|
||||
/// The field's entrance, one timeline (see GamepadCarousel's `entranceProgress`).
|
||||
@State private var entranceProgress: Double = 0
|
||||
@State private var entranceArmed = false
|
||||
@State private var entranceAnchor = 0
|
||||
@State private var artSettled = 0
|
||||
@State private var artWaitOver = false
|
||||
/// The first scroll on mount is seated (no animation), later ones are sprung.
|
||||
@State private var seated = false
|
||||
/// The field's offset — ONE sprung scalar, this view's own (see `body`).
|
||||
@State private var scrollY: CGFloat = 0
|
||||
/// Where the last scroll started, so rows stay rendered across the whole travel.
|
||||
@State private var prevScrollY: CGFloat = 0
|
||||
/// A touch drag's starting offset (iOS).
|
||||
@State private var dragStart: CGFloat?
|
||||
|
||||
/// Launchers lead by construction (`LibraryOrder` / `LibraryCollation`), so the launcher run
|
||||
/// is a prefix.
|
||||
private var launcherCount: Int { games.prefix { $0.isLauncher }.count }
|
||||
private var shape: LibraryGridShape {
|
||||
LibraryGridShape(len: games.count, cols: cols, launchers: launcherCount)
|
||||
}
|
||||
private var cursor: Int {
|
||||
guard let id = focusID, let i = games.firstIndex(where: { $0.id == id }) else { return 0 }
|
||||
return i
|
||||
}
|
||||
private var contentReady: Bool {
|
||||
artWaitOver || artSettled >= min(6, games.count)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let g = GridGeometry(size: geo.size, len: games.count, launchers: launcherCount)
|
||||
// The field: every row at its exact place, the whole thing shifted by `scrollY`, and
|
||||
// clipped to the viewport. NOT a ScrollView — this view owns the offset. A
|
||||
// ScrollView-driven grid needed `scrollTo` against a lazy layout, and on glass one
|
||||
// step down read as TWO impulses (the scroll settled, then moved again as the lazy
|
||||
// rows laid out under it). All of this geometry is fixed and known, so the offset is
|
||||
// computed and sprung here, exactly as the desktop console does it — and rows are
|
||||
// culled by the same arithmetic instead of by a lazy container's guess.
|
||||
ZStack(alignment: .topLeading) {
|
||||
heading(g.shape.split > 0 ? "LAUNCHERS" : nil, height: g.headingH, k: g.k)
|
||||
.padding(.horizontal, g.margin)
|
||||
if g.shape.split > 0 {
|
||||
heading("GAMES", height: g.headingH, k: g.k)
|
||||
.padding(.horizontal, g.margin)
|
||||
.offset(y: g.gamesHeadingTop)
|
||||
}
|
||||
ForEach(0..<g.shape.rows, id: \.self) { r in
|
||||
if g.isNear(row: r, scroll: scrollY, previous: prevScrollY) {
|
||||
row(r, g: g)
|
||||
.offset(x: g.margin, y: g.rowTop(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: g.contentH, alignment: .topLeading)
|
||||
.offset(y: -scrollY)
|
||||
.frame(width: geo.size.width, height: geo.size.height, alignment: .topLeading)
|
||||
.clipped()
|
||||
.contentShape(Rectangle())
|
||||
.offset(bump)
|
||||
#if os(iOS)
|
||||
// A finger scrolls the field directly (no momentum — the pad and the cursor are the
|
||||
// primary drivers, this keeps a touch user from being stuck).
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 8)
|
||||
.onChanged { value in
|
||||
if dragStart == nil { dragStart = scrollY }
|
||||
prevScrollY = scrollY
|
||||
scrollY = min(max((dragStart ?? 0) - value.translation.height, 0), g.maxScroll)
|
||||
}
|
||||
.onEnded { _ in dragStart = nil })
|
||||
#endif
|
||||
.onAppear {
|
||||
if cols != g.cols { cols = g.cols }
|
||||
seed()
|
||||
wire()
|
||||
if controllerActive { input.start() }
|
||||
// Seated, not sprung: the field opens where the cursor is (a restored cursor a
|
||||
// third of the way down — the desktop's `view_h·0.34`).
|
||||
scrollY = g.seat(row: g.shape.cell(of: cursor).row)
|
||||
prevScrollY = scrollY
|
||||
seated = true
|
||||
armEntrance()
|
||||
}
|
||||
.onChange(of: g.cols) { _, c in
|
||||
cols = c
|
||||
// A resize re-flows the rows; keep the focused title in view, seated.
|
||||
scrollY = g.reveal(row: g.shape.cell(of: cursor).row, from: scrollY)
|
||||
prevScrollY = scrollY
|
||||
}
|
||||
.onChange(of: focusID) { _, id in
|
||||
guard id != nil, seated else { return }
|
||||
// A MOVE scrolls only as far as it must to keep the focused cell (and its ring)
|
||||
// in view — never re-seats it: on glass, the "focused row rides at 34 %" rule cut
|
||||
// the row above half away after ONE step down with the whole field still in reach.
|
||||
// On the top row, all the way up so the heading band shows too.
|
||||
let row = g.shape.cell(of: cursor).row
|
||||
let target = row == 0 ? 0 : g.reveal(row: row, from: scrollY)
|
||||
guard target != scrollY else { return }
|
||||
prevScrollY = scrollY
|
||||
if reduceMotion {
|
||||
scrollY = target
|
||||
} else {
|
||||
// `springs::FOCUS` — the desktop's scroll chase. ONE sprung scalar.
|
||||
withAnimation(.spring(response: 0.30, dampingFraction: 0.80)) { scrollY = target }
|
||||
}
|
||||
}
|
||||
.onChange(of: games.map(\.id)) { _, ids in
|
||||
// The list changed under us (a resort, a running title arriving): keep the
|
||||
// focused TITLE if it survives, else clamp — never reset to the first cell.
|
||||
if let id = focusID, ids.contains(id) { return }
|
||||
focusID = ids.isEmpty ? nil : ids[min(cursor, ids.count - 1)]
|
||||
wire()
|
||||
}
|
||||
.onChange(of: contentReady) { _, _ in armEntrance() }
|
||||
.onChange(of: controllerActive) { _, active in
|
||||
if active { input.start() } else { input.stop() }
|
||||
}
|
||||
#if os(tvOS)
|
||||
// Land initial focus on the seeded cell, then chase focus into `focusID`.
|
||||
.defaultFocus($tvFocus, focusID)
|
||||
.onChange(of: tvFocus) { _, id in
|
||||
guard let id, id != focusID, let i = games.firstIndex(where: { $0.id == id })
|
||||
else { return }
|
||||
haptics.move()
|
||||
colHint = shape.cell(of: i).col
|
||||
focusID = id
|
||||
}
|
||||
#endif
|
||||
.onDisappear {
|
||||
input.stop()
|
||||
haptics.stop()
|
||||
}
|
||||
#if os(iOS) || os(macOS)
|
||||
// Hardware keyboard: arrows move the cursor, Return launches — same as the strip.
|
||||
.gamepadKeyNavigation(
|
||||
active: controllerActive,
|
||||
onMove: { move($0) },
|
||||
onConfirm: { activate() },
|
||||
onBack: onBack)
|
||||
#endif
|
||||
.sensoryFeedback(.selection, trigger: focusID)
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick)
|
||||
}
|
||||
.task {
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
artWaitOver = true
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of cells at their exact places. Fixed-width columns so the two sections' cells
|
||||
/// align vertically and a partial last row sits leading — exactly the shape the cursor was told.
|
||||
private func row(_ r: Int, g: GridGeometry) -> some View {
|
||||
let start = g.shape.rowStart(r)
|
||||
let n = g.shape.rowLen(r)
|
||||
return HStack(alignment: .top, spacing: g.gap) {
|
||||
ForEach(start..<(start + n), id: \.self) { index in
|
||||
let game = games[index]
|
||||
#if os(tvOS)
|
||||
// A focusable Button per cell: the focus engine does the navigating (remote
|
||||
// swipes and pad dpad alike — a Siri Remote is no extended gamepad, so the poll
|
||||
// above never sees it), select activates. The bare style keeps the cell's own
|
||||
// look; the ring + pop below is the focus treatment, since `focusID` chases focus.
|
||||
Button { activate() } label: {
|
||||
cell(game, index: index, width: g.cellW, height: g.cellH, k: g.k, shape: g.shape)
|
||||
}
|
||||
.buttonStyle(ConsoleBareButtonStyle())
|
||||
.focused($tvFocus, equals: game.id)
|
||||
#else
|
||||
cell(game, index: index, width: g.cellW, height: g.cellH, k: g.k, shape: g.shape)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
// Rendered rows are keyed on their index so a row that scrolls out and back in remounts
|
||||
// fresh (its posters re-request from the loader's cache), like the desktop's cull.
|
||||
.id("row-\(r)")
|
||||
}
|
||||
|
||||
/// A heading band: leading, tracked, `fg(0.45)` — or the same band empty (the top inset).
|
||||
/// The caption sits mid-band: the air on either side is what a focused cell's ×1.06 pop and
|
||||
/// its ring rise into, from the row above as much as the row below, so a heading never
|
||||
/// collides with either.
|
||||
private func heading(_ text: String?, height: CGFloat, k: CGFloat) -> some View {
|
||||
HStack {
|
||||
if let text {
|
||||
// Captions scale with the field but never below legibility.
|
||||
Text(text)
|
||||
.font(.geist(max(9, 11 * k), .semibold, relativeTo: .caption2))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(ink.fg(0.45))
|
||||
}
|
||||
}
|
||||
.frame(height: height, alignment: .leading)
|
||||
}
|
||||
|
||||
private func cell(
|
||||
_ game: GameEntry, index: Int, width: CGFloat, height: CGFloat, k: CGFloat,
|
||||
shape: LibraryGridShape
|
||||
) -> some View {
|
||||
let focused = game.id == focusID
|
||||
let corner = 12 * k
|
||||
return PosterImage(
|
||||
candidates: game.art.posterCandidates, title: game.title, loader: artLoader,
|
||||
icon: game.iconToken,
|
||||
drawnSize: CGSize(width: width, height: height),
|
||||
onLoaded: { artSettled += 1 })
|
||||
.frame(width: width, height: height)
|
||||
.clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous))
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if running[game.id] != nil { RunningBadge(solid: true) }
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: corner, style: .continuous)
|
||||
.strokeBorder(ink.fg(0.12), lineWidth: 1)
|
||||
}
|
||||
// The focus ring goes OUTSIDE the cover, so it reads as a ring around it rather than
|
||||
// a border painted onto it.
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: corner + 3, style: .continuous)
|
||||
.inset(by: -3)
|
||||
.strokeBorder(ink.accent(0.9), lineWidth: 2)
|
||||
.opacity(focused ? 1 : 0)
|
||||
}
|
||||
.shadow(color: ink.shadow(focused ? 0.5 : 0.22), radius: focused ? 14 : 8, y: focused ? 10 : 5)
|
||||
.scaleEffect(focused ? 1.06 : 1)
|
||||
// `springs::FOCUS` — the pop, with a whisker of overshoot.
|
||||
.animation(
|
||||
reduceMotion ? .easeOut(duration: 0.12) : .spring(response: 0.30, dampingFraction: 0.80),
|
||||
value: focused)
|
||||
.modifier(entrance(index: index, shape: shape))
|
||||
.zIndex(focused ? 1 : 0)
|
||||
#if !os(tvOS)
|
||||
// Pointer/touch: a press on the focused cell activates, any other only brings it to
|
||||
// front — the carousel's rule.
|
||||
.contentShape(RoundedRectangle(cornerRadius: corner, style: .continuous))
|
||||
.onTapGesture {
|
||||
if focused { activate() } else { focus(index) }
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Entrance
|
||||
|
||||
private func armEntrance() {
|
||||
guard !entranceArmed, contentReady, !games.isEmpty else { return }
|
||||
entranceArmed = true
|
||||
entranceAnchor = cursor
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
withAnimation(
|
||||
reduceMotion ? .easeOut(duration: 0.28) : .linear(duration: CardEntrance.total)
|
||||
) {
|
||||
entranceProgress = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cell's share of the field's entrance, fanning on `|Δrow| + |Δcol|` from the focused
|
||||
/// cell (the desktop's grid rule) — the same stagger and cap the strip uses.
|
||||
private func entrance(index: Int, shape: LibraryGridShape) -> GridCellEntrance {
|
||||
let a = shape.cell(of: min(entranceAnchor, max(shape.len - 1, 0)))
|
||||
let c = shape.cell(of: index)
|
||||
let distance = abs(a.row - c.row) + abs(a.col - c.col)
|
||||
let delay = min(CardEntrance.maxDelay, Double(distance) * 0.12)
|
||||
return GridCellEntrance(
|
||||
progress: entranceProgress, start: delay / CardEntrance.total, reduceMotion: reduceMotion)
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
private func seed() {
|
||||
guard !games.isEmpty else { return }
|
||||
if let id = focusID, games.contains(where: { $0.id == id }) {
|
||||
colHint = shape.cell(of: cursor).col
|
||||
return
|
||||
}
|
||||
if let id = initialSelection, games.contains(where: { $0.id == id }) {
|
||||
focusID = id
|
||||
} else {
|
||||
focusID = games[0].id
|
||||
}
|
||||
colHint = shape.cell(of: cursor).col
|
||||
}
|
||||
|
||||
private func wire() {
|
||||
input.onMove = { move($0) }
|
||||
input.onConfirm = { activate() }
|
||||
input.onSecondary = onSecondary
|
||||
input.onTertiary = onTertiary
|
||||
input.onBack = onBack
|
||||
input.onShoulder = { right in page(forward: right) }
|
||||
}
|
||||
|
||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||
let dir: LibraryGridDirection
|
||||
switch direction {
|
||||
case .left: dir = .left
|
||||
case .right: dir = .right
|
||||
case .up: dir = .up
|
||||
case .down: dir = .down
|
||||
}
|
||||
step(dir)
|
||||
}
|
||||
|
||||
private func page(forward: Bool) {
|
||||
step(forward ? .pageForward : .pageBack)
|
||||
}
|
||||
|
||||
private func step(_ dir: LibraryGridDirection) {
|
||||
// No layout yet: decline rather than guess (the desktop's `grid_cols_last`).
|
||||
guard cols > 0, !games.isEmpty else { return }
|
||||
let s = shape
|
||||
switch LibraryGridCursor.step(cursor, shape: s, colHint: colHint, direction: dir) {
|
||||
case .moved(let to):
|
||||
colHint = LibraryGridCursor.colHint(shape: s, previous: colHint, direction: dir, landed: to)
|
||||
haptics.move()
|
||||
focusID = games[to].id
|
||||
case .boundary:
|
||||
// ▲ off the top row is the way to the bar, not a wall.
|
||||
if dir == .up, s.cell(of: cursor).row == 0, let onUp {
|
||||
onUp()
|
||||
return
|
||||
}
|
||||
boundaryBump(dir)
|
||||
}
|
||||
}
|
||||
|
||||
private func focus(_ index: Int) {
|
||||
guard games.indices.contains(index) else { return }
|
||||
colHint = shape.cell(of: index).col
|
||||
haptics.move()
|
||||
focusID = games[index].id
|
||||
}
|
||||
|
||||
private func activate() {
|
||||
guard let id = focusID, games.contains(where: { $0.id == id }) else { return }
|
||||
activateTick &+= 1
|
||||
haptics.confirm()
|
||||
onLaunch?(id)
|
||||
}
|
||||
|
||||
/// The recoil is deflected along the axis pushed — horizontal shifts x, vertical shifts the
|
||||
/// scroll (here, the field). Travel dropped under Reduce Motion, haptic kept.
|
||||
private func boundaryBump(_ dir: LibraryGridDirection) {
|
||||
boundaryTick &+= 1
|
||||
haptics.boundary()
|
||||
guard !reduceMotion else { return }
|
||||
let recoil: CGSize
|
||||
switch dir {
|
||||
case .left: recoil = CGSize(width: 16, height: 0)
|
||||
case .right: recoil = CGSize(width: -16, height: 0)
|
||||
case .up, .pageBack: recoil = CGSize(width: 0, height: 16)
|
||||
case .down, .pageForward: recoil = CGSize(width: 0, height: -16)
|
||||
}
|
||||
withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bump = recoil }
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bump = .zero }
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid's geometry, computed once per layout pass from the viewport — every number the field
|
||||
/// and the cursor share. Cells are 150×225 design units, gap 16, margin 48, heading band 30, a
|
||||
/// 10-unit label gap under each row, all × `k = min(w, h)/800` clamped 0.75…3; columns = what
|
||||
/// fits, clamped 2…8.
|
||||
private struct GridGeometry {
|
||||
let k: CGFloat
|
||||
let cellW: CGFloat, cellH: CGFloat, gap: CGFloat, margin: CGFloat
|
||||
let headingH: CGFloat, labelGap: CGFloat
|
||||
/// The air a focused cell's ring and ×1.06 pop need past its frame.
|
||||
let halo: CGFloat
|
||||
let cols: Int
|
||||
let shape: LibraryGridShape
|
||||
let viewH: CGFloat
|
||||
|
||||
init(size: CGSize, len: Int, launchers: Int) {
|
||||
// The desktop's k, floored at 0.75 (its smallest field is a Deck's 800): on a phone the
|
||||
// covers stay poster-sized rather than thumbnails, and the field scrolls.
|
||||
k = min(max(min(size.width, size.height) / 800, 0.75), 3)
|
||||
gap = 16 * k; margin = 48 * k
|
||||
// The heading band never drops below what a caption plus a focused cell's pop and ring
|
||||
// need on either side of it (the caption sits mid-band).
|
||||
headingH = max(32, 30 * k); labelGap = 10 * k; halo = max(8, 10 * k)
|
||||
let avail = size.width - 2 * margin
|
||||
let base = 150 * k
|
||||
cols = min(max(Int((avail + gap) / (base + gap)), 2), 8)
|
||||
// The columns FILL the field: whatever width the last cell would have left over is
|
||||
// shared out, so the grid spans the safe area on every screen instead of stopping short
|
||||
// of the right edge (on a phone the slack was a fifth of the width). Cells keep 2:3.
|
||||
cellW = max(base, (avail - CGFloat(cols - 1) * gap) / CGFloat(cols))
|
||||
cellH = cellW * 1.5
|
||||
shape = LibraryGridShape(len: len, cols: cols, launchers: launchers)
|
||||
viewH = size.height
|
||||
}
|
||||
|
||||
/// Row pitch: the cell, the gap, and the label air under it.
|
||||
var pitch: CGFloat { cellH + gap + labelGap }
|
||||
/// The GAMES heading's top: after the launcher rows.
|
||||
var gamesHeadingTop: CGFloat { headingH + CGFloat(shape.splitRow) * pitch }
|
||||
|
||||
/// A row's top edge. Row 0 sits under the (unconditional) top heading band; the game rows
|
||||
/// under the launcher rows sit under a second band.
|
||||
func rowTop(_ r: Int) -> CGFloat {
|
||||
if shape.split > 0, r >= shape.splitRow {
|
||||
return gamesHeadingTop + headingH + CGFloat(r - shape.splitRow) * pitch
|
||||
}
|
||||
return headingH + CGFloat(r) * pitch
|
||||
}
|
||||
|
||||
/// The whole field, plus a trailing band so the last row keeps its pop and ring at max scroll.
|
||||
var contentH: CGFloat {
|
||||
guard shape.rows > 0 else { return headingH * 2 }
|
||||
return rowTop(shape.rows - 1) + cellH + labelGap + headingH
|
||||
}
|
||||
var maxScroll: CGFloat { max(0, contentH - viewH) }
|
||||
|
||||
/// Whether a row is drawn: it overlaps the span the field is travelling across (from the
|
||||
/// previous offset to the current one), with one row of look-ahead on either side.
|
||||
func isNear(row r: Int, scroll: CGFloat, previous: CGFloat) -> Bool {
|
||||
let lo = min(scroll, previous) - pitch
|
||||
let hi = max(scroll, previous) + viewH + pitch
|
||||
let top = rowTop(r)
|
||||
return top < hi && top + cellH > lo
|
||||
}
|
||||
|
||||
/// The offset that keeps `row` (ring and pop included) inside the viewport, moving no
|
||||
/// further than it must from `current`.
|
||||
func reveal(row r: Int, from current: CGFloat) -> CGFloat {
|
||||
let top = rowTop(r) - halo
|
||||
let bottom = rowTop(r) + cellH + halo
|
||||
var y = current
|
||||
if top < y { y = top }
|
||||
if bottom > y + viewH { y = bottom - viewH }
|
||||
return min(max(y, 0), maxScroll)
|
||||
}
|
||||
|
||||
/// Where a restored cursor is SEATED on mount: a third of the way down the viewport (the
|
||||
/// desktop's `view_h·0.34`), clamped; row 0 is simply the top.
|
||||
func seat(row r: Int) -> CGFloat {
|
||||
guard r > 0 else { return 0 }
|
||||
return min(max(rowTop(r) - viewH * 0.34, 0), maxScroll)
|
||||
}
|
||||
}
|
||||
|
||||
/// How a grid cell arrives when its field does: small, low and invisible, then it grows and rises
|
||||
/// into place on a spring soft enough to overshoot — the coverflow's `CardEntrance` without the
|
||||
/// turn (the desktop's grid entrance has rise, scale and fade; the turn is the shelf's alone).
|
||||
struct GridCellEntrance: ViewModifier, Animatable {
|
||||
var progress: Double
|
||||
let start: Double
|
||||
let reduceMotion: Bool
|
||||
|
||||
var animatableData: Double {
|
||||
get { progress }
|
||||
set { progress = newValue }
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let span = CardEntrance.perCard / CardEntrance.total
|
||||
let raw = min(max((progress - start) / span, 0), 1)
|
||||
let travel = Self.easeOutBack(raw)
|
||||
let fade = Self.easeOut(min(raw / 0.34, 1))
|
||||
let away = reduceMotion ? 0 : 1 - travel
|
||||
return content
|
||||
.opacity(reduceMotion ? raw : fade)
|
||||
.scaleEffect(1 - 0.26 * away)
|
||||
.offset(y: 34 * away)
|
||||
}
|
||||
|
||||
private static func easeOutBack(_ t: Double) -> Double {
|
||||
let c1 = 1.2, c3 = c1 + 1
|
||||
let u = t - 1
|
||||
return 1 + c3 * u * u * u + c1 * u * u
|
||||
}
|
||||
|
||||
private static func easeOut(_ t: Double) -> Double {
|
||||
1 - pow(1 - t, 3)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,183 @@
|
||||
// A library title's own actions — reached with X on the shelf or the grid: the desktop console's
|
||||
// per-title Options menu (`screens/options.rs`, `Subject::Game`), and the console answer to the
|
||||
// touch grid's context menu on a poster. It holds Copy link and Cancel — deliberately not
|
||||
// [Play, …]: the menu does not repeat the field's own A press, and Copy link leads so the cursor,
|
||||
// which starts on row 0, is already on the row nearly everyone came for. Rendered as a layer over
|
||||
// the field inside the library screen (it takes the controller while it is up), in the host
|
||||
// options menu's idiom: a title band, glass rows, an explainer band, `A Select · B Back`.
|
||||
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
|
||||
struct LibraryTitleOptionsView: View {
|
||||
@AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet"
|
||||
private var ink: GamepadInk { .stored(paletteID) }
|
||||
@Environment(\.gamepadMetrics) private var metrics
|
||||
@Environment(\.displayBottomInset) private var displayBottomInset
|
||||
|
||||
/// The title this menu was opened on, by value.
|
||||
let game: GameEntry
|
||||
/// The host's name, for the explainer ("Actions for this title on {host}").
|
||||
var hostName: String?
|
||||
/// Copy the title's `punktfunk://` link; nil where there is no clipboard (the row is then
|
||||
/// omitted, and the menu is Cancel alone — the caller hides X in that case anyway).
|
||||
var onCopyLink: ((GameEntry) -> Void)?
|
||||
let close: () -> Void
|
||||
var controllerActive = true
|
||||
|
||||
#if os(iOS)
|
||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||
private var compact: Bool { vSizeClass == .compact }
|
||||
#else
|
||||
private let compact = false
|
||||
#endif
|
||||
@State private var copied = false
|
||||
@State private var focusID: String?
|
||||
|
||||
private enum Action: String {
|
||||
case copyLink
|
||||
case cancel
|
||||
}
|
||||
|
||||
private struct Row: Identifiable {
|
||||
let action: Action
|
||||
let label: String
|
||||
let icon: String
|
||||
var id: String { action.rawValue }
|
||||
}
|
||||
|
||||
private var rows: [Row] {
|
||||
var rows: [Row] = []
|
||||
if onCopyLink != nil {
|
||||
rows.append(Row(action: .copyLink, label: copied ? "Copied" : "Copy link", icon: "link"))
|
||||
}
|
||||
rows.append(Row(action: .cancel, label: "Cancel", icon: "xmark"))
|
||||
return rows
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
items: rows,
|
||||
focusID: $focusID,
|
||||
onActivate: { run($0.action) },
|
||||
onBack: { close() },
|
||||
isActive: controllerActive
|
||||
) { row, focused in
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: gamepadHeaderSpacing(compact: compact)) {
|
||||
Text(game.title)
|
||||
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
if !compact {
|
||||
Text(blurb)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .top) }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(detail)
|
||||
.font(.geist(metrics.detailFont, relativeTo: .caption))
|
||||
.foregroundStyle(ink.fg(0.55))
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.animation(.smooth(duration: 0.2), value: focusID)
|
||||
GamepadHintBar(hints: hints)
|
||||
}
|
||||
.padding(.leading, compact ? 12 : 18)
|
||||
.padding(.trailing, 22)
|
||||
.padding(
|
||||
.bottom,
|
||||
gamepadLegendBottomPadding(
|
||||
compact ? 12 : 18, tier: metrics.tier, displayBottom: displayBottomInset))
|
||||
.padding(.top, compact ? 6 : 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background { GamepadTrayBlur(edge: .bottom) }
|
||||
}
|
||||
.task(id: copied) {
|
||||
guard copied else { return }
|
||||
try? await Task.sleep(for: .milliseconds(1600))
|
||||
withAnimation(.smooth(duration: 0.2)) { copied = false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop's per-subject blurb: "Actions for this title on {host}."
|
||||
private var blurb: String {
|
||||
if let hostName, !hostName.isEmpty { return "Actions for this title on \(hostName)." }
|
||||
return "Actions for this title."
|
||||
}
|
||||
|
||||
private var detail: String {
|
||||
switch rows.first(where: { $0.id == focusID })?.action {
|
||||
case .copyLink:
|
||||
return "Copy a punktfunk:// link that opens straight into this title."
|
||||
case .cancel, .none:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
[
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select",
|
||||
action: {
|
||||
if let id = focusID, let row = rows.first(where: { $0.id == id }) { run(row.action) }
|
||||
}),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { close() }),
|
||||
]
|
||||
}
|
||||
|
||||
private func run(_ action: Action) {
|
||||
switch action {
|
||||
case .copyLink:
|
||||
onCopyLink?(game)
|
||||
// No toast machinery on this surface — the row says so itself.
|
||||
withAnimation(.smooth(duration: 0.2)) { copied = true }
|
||||
case .cancel:
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||
let m = metrics
|
||||
return HStack(spacing: 14) {
|
||||
Image(systemName: row.icon)
|
||||
.font(.system(size: m.iconFont))
|
||||
.foregroundStyle(focused ? ink.accent : ink.fg(0.55))
|
||||
.frame(width: m.iconWidth)
|
||||
Text(row.label)
|
||||
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||
.foregroundStyle(ink.fg)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
}
|
||||
.padding(.horizontal, m.rowHPad)
|
||||
.padding(.vertical, m.rowVPad)
|
||||
.consoleGlass(
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||
tint: focused ? ink.accent(0.30) : nil,
|
||||
interactive: focused)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||
.strokeBorder(ink.fg(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(focused ? 1.0 : 0.98)
|
||||
.animation(.smooth(duration: 0.18), value: focused)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -68,6 +68,16 @@ struct LibraryView: View {
|
||||
/// 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
|
||||
/// The collection the gamepad shelf is drilled into (its label), or nil — reported so a host
|
||||
/// screen (GamepadLibraryScreen's pinned title) can read `host · profile · collection`.
|
||||
var onCollectionChanged: ((String?) -> Void)?
|
||||
/// The same, for this view's own navigation title (the sheet/cover presentations).
|
||||
@State private var collectionLabel: String?
|
||||
/// The touch grid's sort (the shared `library_sort` key, the same one the console's bar
|
||||
/// writes) and its grouping (touch-only — sections are the touch analogue of the console's
|
||||
/// Collections place).
|
||||
@AppStorage(DefaultsKey.librarySort) private var sortRaw = ""
|
||||
@AppStorage(DefaultsKey.libraryGroupBy) private var groupByRaw = ""
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// Resolves a pinned shelf's profile NAME for the title (the target carries only its id).
|
||||
@ObservedObject private var profiles = ProfileStore.shared
|
||||
@@ -118,15 +128,23 @@ struct LibraryView: View {
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationTitle("\(target.title(in: profiles)) — Library")
|
||||
.navigationTitle("\(shelfTitle) — Library")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
#if os(macOS)
|
||||
ToolbarItemGroup { reloadButton }
|
||||
ToolbarItemGroup {
|
||||
if !gamepadUIActive { sortMenu }
|
||||
reloadButton
|
||||
}
|
||||
#else
|
||||
ToolbarItem(placement: .primaryAction) { reloadButton }
|
||||
// The console presentation carries its own sort/view bar; the plain grid gets a
|
||||
// menu in the bar it already has.
|
||||
if !gamepadUIActive {
|
||||
ToolbarItem(placement: .primaryAction) { sortMenu }
|
||||
}
|
||||
#endif
|
||||
// A gamepad-only user can't swipe-to-dismiss the sheet this view is presented in
|
||||
// (ContentView's `.sheet(item: $libraryTarget)`) — give it a focusable, dpad-reachable
|
||||
@@ -152,7 +170,12 @@ struct LibraryView: View {
|
||||
// (the gamepad screens carry no close chrome).
|
||||
.background {
|
||||
if gamepadUIActive && games.isEmpty {
|
||||
LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() }
|
||||
LibraryBackCatcher(
|
||||
active: controllerActive,
|
||||
// A = the on-screen Retry button, only while there is an error to retry
|
||||
// (a press during the load itself would start a second fetch).
|
||||
onConfirm: errorText != nil && !loading ? { Task { await load() } } : nil,
|
||||
onBack: { (onClose ?? { dismiss() })() })
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -172,10 +195,10 @@ struct LibraryView: View {
|
||||
/// and never as an error: a cached library is a working library, and a host that is still
|
||||
/// waking is the case this whole path exists to serve.
|
||||
@ViewBuilder private var staleNote: some View {
|
||||
if servedFromCacheAt != nil {
|
||||
if let text = staleness.text {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: loading ? "arrow.clockwise" : "wifi.slash")
|
||||
Text(loading ? "Waking the host…" : "Showing this host's last known library")
|
||||
Image(systemName: staleness.symbol)
|
||||
Text(text)
|
||||
}
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -196,14 +219,24 @@ struct LibraryView: View {
|
||||
consoleField(emptyState)
|
||||
} else {
|
||||
if gamepadUIActive {
|
||||
LibraryCoverflowView(
|
||||
LibraryConsoleView(
|
||||
games: ordered, artLoader: artLoader, onLaunch: launchAndRemember,
|
||||
running: running,
|
||||
staleness: staleness,
|
||||
// The title last opened from this shelf — the coverflow assembles around it,
|
||||
// the way the plain grid scrolls back to it, so the round trip browse → play →
|
||||
// quit → browse lands where the player left rather than at the first cover.
|
||||
initialSelection: LibraryScrollMemory.last(forHost: host.id.uuidString),
|
||||
onDismiss: { (onClose ?? { dismiss() })() },
|
||||
// Nil where there is nothing to copy into (tvOS), which is what drops the
|
||||
// hint from the legend rather than leaving a button that does nothing.
|
||||
// Options row and its hint rather than leaving a menu with nothing in it.
|
||||
onCopyLink: LinkClipboard.isAvailable ? { copyLink($0) } : nil,
|
||||
controllerActive: controllerActive)
|
||||
hostName: host.displayName,
|
||||
controllerActive: controllerActive,
|
||||
onCollectionChanged: { label in
|
||||
collectionLabel = label
|
||||
onCollectionChanged?(label)
|
||||
})
|
||||
} else {
|
||||
// Above the grid rather than over it: the coverflow owns its whole surface and has
|
||||
// its own legend row, so the note rides the plain-grid presentation only.
|
||||
@@ -223,33 +256,73 @@ struct LibraryView: View {
|
||||
/// the titles land and the coverflow takes over.
|
||||
///
|
||||
/// Only in gamepad mode: the plain grid's states belong on the system background, as before.
|
||||
///
|
||||
/// In gamepad mode these states also carry the legend the coverflow carries — `A Retry` on an
|
||||
/// error, `B Back` always — because a controller-only user on an error screen otherwise had
|
||||
/// no visible way out (the desktop console shows the same two hints there).
|
||||
@ViewBuilder private func consoleField(_ view: some View) -> some View {
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
view.background {
|
||||
if gamepadUIActive, !hostedInShell { GamepadScreenBackground() }
|
||||
}
|
||||
view
|
||||
.background {
|
||||
if gamepadUIActive, !hostedInShell { GamepadScreenBackground() }
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||
if gamepadUIActive {
|
||||
GamepadHintBar(hints: stateHints)
|
||||
.padding(.leading, 22)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
#else
|
||||
view
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
/// The legend under the loading / error / empty states: A retries a failed fetch (the same
|
||||
/// action as the on-screen Retry button), B backs out. Read at press time, like every hint.
|
||||
private var stateHints: [GamepadHint] {
|
||||
var hints: [GamepadHint] = []
|
||||
if errorText != nil, !loading {
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Retry",
|
||||
action: { Task { await load() } }))
|
||||
}
|
||||
hints.append(.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back",
|
||||
action: { (onClose ?? { dismiss() })() }))
|
||||
return hints
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The grid's sections: the catalog collated by the shared rules — launchers lead (design
|
||||
/// D4), then one section per group under the chosen grouping (none = one section of games),
|
||||
/// each in the chosen sort. Headers only when there is more than one section, so an
|
||||
/// ungrouped, launcher-less library renders exactly as it always did.
|
||||
private var sections: [(label: String, games: [GameEntry])] {
|
||||
let groupBy: LibraryGroupBy?
|
||||
switch groupByRaw {
|
||||
case "platform": groupBy = .platform
|
||||
case "store": groupBy = .store
|
||||
default: groupBy = nil
|
||||
}
|
||||
return LibraryCollation.collate(ordered, sort: LibrarySortKey(stored: sortRaw), groupBy: groupBy)
|
||||
.map { group in
|
||||
// The ungrouped bucket names itself "All"; on this grid it has always been "Games".
|
||||
let label = (groupBy == nil && group.key != .launchers) ? "Games" : group.label
|
||||
return (label, group.indices.map { ordered[$0] })
|
||||
}
|
||||
}
|
||||
|
||||
private var grid: some View {
|
||||
// Design D4: launcher entries get their own section above the titles, never interleaved.
|
||||
// Both headers appear only when both groups exist, so a library without launcher entries
|
||||
// renders exactly as it did before.
|
||||
let launchers = ordered.filter(\.isLauncher)
|
||||
let titles = ordered.filter { !$0.isLauncher }
|
||||
let both = !launchers.isEmpty && !titles.isEmpty
|
||||
let sections = self.sections
|
||||
let showsHeaders = sections.count > 1
|
||||
return ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if !launchers.isEmpty {
|
||||
if both { sectionHeader("Launchers") }
|
||||
tiles(launchers)
|
||||
}
|
||||
if !titles.isEmpty {
|
||||
if both { sectionHeader("Games") }
|
||||
tiles(titles)
|
||||
ForEach(Array(sections.enumerated()), id: \.offset) { _, section in
|
||||
if showsHeaders { sectionHeader(section.label) }
|
||||
tiles(section.games)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
@@ -287,7 +360,7 @@ struct LibraryView: View {
|
||||
.gamepadKeyNavigation(
|
||||
active: onLaunch != nil,
|
||||
onMove: { direction in
|
||||
guard let next = gridNav(launchers: launchers, titles: titles)
|
||||
guard let next = gridNav(sections: sections.map(\.games))
|
||||
.move(from: keyCursor, direction) else { return }
|
||||
keyCursor = next
|
||||
withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(next, anchor: .center) }
|
||||
@@ -303,9 +376,9 @@ struct LibraryView: View {
|
||||
#if os(iOS) || os(macOS)
|
||||
/// The keyboard cursor's model over the two grid sections. Rebuilt per press from the live
|
||||
/// sections so it can never point into a stale list.
|
||||
private func gridNav(launchers: [GameEntry], titles: [GameEntry]) -> LibraryGridNav {
|
||||
private func gridNav(sections: [[GameEntry]]) -> LibraryGridNav {
|
||||
LibraryGridNav(
|
||||
sections: [launchers, titles].filter { !$0.isEmpty }.map { $0.map(\.id) },
|
||||
sections: sections.filter { !$0.isEmpty }.map { $0.map(\.id) },
|
||||
columns: columnCount)
|
||||
}
|
||||
|
||||
@@ -427,9 +500,38 @@ struct LibraryView: View {
|
||||
.disabled(loading)
|
||||
}
|
||||
|
||||
/// Sort and group for the plain grid — the console's bar, as a menu. The sort is the shared
|
||||
/// key (Default · A–Z · Platform · Store); the grouping is this grid's own (sections stand in
|
||||
/// for the console's Collections place).
|
||||
private var sortMenu: some View {
|
||||
Menu {
|
||||
Picker("Sort", selection: $sortRaw) {
|
||||
ForEach(LibrarySortKey.all, id: \.stored) { key in
|
||||
Text(key.label).tag(key.stored)
|
||||
}
|
||||
}
|
||||
Picker("Group by", selection: $groupByRaw) {
|
||||
Text("None").tag("")
|
||||
Text("Platform").tag("platform")
|
||||
Text("Store").tag("store")
|
||||
}
|
||||
} label: {
|
||||
Label("Sort & group", systemImage: "line.3.horizontal.decrease.circle")
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
loading = true
|
||||
errorText = nil
|
||||
// Dev hook, the twin of the desktop console's `PUNKTFUNK_FAKE_LIBRARY`: a file holding
|
||||
// the host's `/api/v1/library` JSON (or the shared collate vectors file, whose `library`
|
||||
// array is the same shape) stands in for the host, so the grid, the sort bar and the
|
||||
// collections can be exercised on a Mac with no host at all. No wake, no cache, no
|
||||
// `/status`, and no art — the posters are placeholders.
|
||||
if let fake = ProcessInfo.processInfo.environment["PUNKTFUNK_FAKE_LIBRARY"], !fake.isEmpty {
|
||||
loadFake(path: fake)
|
||||
return
|
||||
}
|
||||
let current = store.hosts.first { $0.id == host.id } ?? host
|
||||
// mTLS uses this client's persistent identity (the host paired it over QUIC). No identity
|
||||
// yet → the user hasn't connected/paired, which is also when there's nothing to browse.
|
||||
@@ -543,6 +645,29 @@ struct LibraryView: View {
|
||||
loading = false
|
||||
}
|
||||
|
||||
/// The `PUNKTFUNK_FAKE_LIBRARY` path: a plain `[GameEntry]` array, or a `{ "library": [...] }`
|
||||
/// wrapper (the shared vectors file). A bad file reads as an error state, not a crash.
|
||||
private func loadFake(path: String) {
|
||||
defer { loading = false }
|
||||
struct Wrapped: Decodable { let library: [GameEntry] }
|
||||
servedFromCacheAt = nil
|
||||
running = [:]
|
||||
guard let data = FileManager.default.contents(atPath: path) else {
|
||||
games = []
|
||||
errorText = "PUNKTFUNK_FAKE_LIBRARY: can't read \(path)"
|
||||
return
|
||||
}
|
||||
let decoder = JSONDecoder()
|
||||
if let list = try? decoder.decode([GameEntry].self, from: data) {
|
||||
games = list.launchersFirst
|
||||
} else if let wrapped = try? decoder.decode(Wrapped.self, from: data) {
|
||||
games = wrapped.library.launchersFirst
|
||||
} else {
|
||||
games = []
|
||||
errorText = "PUNKTFUNK_FAKE_LIBRARY: \(path) is not a library JSON"
|
||||
}
|
||||
}
|
||||
|
||||
/// Every launch from this shelf goes through here, so the player's position is recorded on
|
||||
/// exactly one path however they picked the title — a tap, the keyboard, or the coverflow.
|
||||
/// `nil` in browse-only mode, which is what keeps the tiles untappable there.
|
||||
@@ -554,23 +679,67 @@ struct LibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog in display order: anything already running first, so getting back into it is the
|
||||
/// first thing on the screen rather than something to scroll for.
|
||||
///
|
||||
/// Applied on top of `launchersFirst` rather than instead of it — a launcher that is up still
|
||||
/// belongs with the launchers.
|
||||
/// `host` → `host · profile` (a pinned card's shelf) → `host · profile · collection` (drilled
|
||||
/// into one group), joined with `·` — the desktop's title shape.
|
||||
private var shelfTitle: String {
|
||||
let base = target.title(in: profiles)
|
||||
guard let collectionLabel else { return base }
|
||||
return "\(base) \u{b7} \(collectionLabel)"
|
||||
}
|
||||
|
||||
/// The catalog in display order — `LibraryOrder.display`, the desktop's `order()`: launcher
|
||||
/// entries lead, and anything already running leads WITHIN its band, so getting back into it
|
||||
/// is the first thing on the screen rather than something to scroll for. (Its predecessor put
|
||||
/// every running entry first, over `launchersFirst`, so a running game jumped ahead of the
|
||||
/// launcher prefix and the coverflow's heading read GAMES · LAUNCHERS · GAMES along the strip.)
|
||||
private var ordered: [GameEntry] {
|
||||
guard !running.isEmpty else { return games }
|
||||
return games.filter { running[$0.id] != nil } + games.filter { running[$0.id] == nil }
|
||||
return LibraryOrder.display(games, running: Set(running.keys))
|
||||
}
|
||||
|
||||
/// Whether the titles on screen are remembered rather than observed, and what the host is
|
||||
/// doing about it — the three-state staleness both presentations show. Never an error: a
|
||||
/// cached library is a working library.
|
||||
private var staleness: LibraryStaleness {
|
||||
guard servedFromCacheAt != nil else { return .none }
|
||||
return loading ? .waking : .offline
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog's provenance, as the shelf states it. Three states rather than a flag so "waking
|
||||
/// the host…" can never be shown while nothing is happening — the same enum the desktop console
|
||||
/// keeps (`Stale::{No, Waking, Offline}`), with its exact wording.
|
||||
enum LibraryStaleness: Equatable {
|
||||
case none
|
||||
/// Served from disk; a fetch (and a wake) is in flight.
|
||||
case waking
|
||||
/// Served from disk; the host did not answer.
|
||||
case offline
|
||||
|
||||
/// The note the shelf shows, or nil when the titles are live.
|
||||
var text: String? {
|
||||
switch self {
|
||||
case .none: return nil
|
||||
case .waking: return "Last known library — waking the host…"
|
||||
case .offline: return "Last known library — the host didn't answer"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
self == .waking ? "arrow.clockwise" : "wifi.slash"
|
||||
}
|
||||
}
|
||||
|
||||
#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.
|
||||
/// Zero-size controller listener for the library's pre-coverflow states — B backs out, A retries
|
||||
/// a failed fetch. 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 A/B) is up.
|
||||
private struct LibraryBackCatcher: View {
|
||||
let active: Bool
|
||||
/// nil while there is nothing to retry — the press then does nothing, exactly like the
|
||||
/// legend, which shows no A cell in that state.
|
||||
var onConfirm: (() -> Void)?
|
||||
let onBack: () -> Void
|
||||
@State private var input = GamepadMenuInput(manager: .shared)
|
||||
|
||||
@@ -579,8 +748,11 @@ private struct LibraryBackCatcher: View {
|
||||
.frame(width: 0, height: 0)
|
||||
.onAppear {
|
||||
input.onBack = onBack
|
||||
input.onConfirm = onConfirm
|
||||
if active { input.start() }
|
||||
}
|
||||
// The retry closure comes and goes with the error; keep the poller's copy current.
|
||||
.onChange(of: onConfirm != nil) { _, _ in input.onConfirm = onConfirm }
|
||||
.onChange(of: active) { _, nowActive in
|
||||
if nowActive { input.start() } else { input.stop() }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Reusable library widgets, shared by the touch grid (LibraryView's `GameCard`) and the gamepad
|
||||
// coverflow (LibraryCoverflowView's cover cell).
|
||||
|
||||
import ImageIO
|
||||
import PunktfunkKit
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
@@ -96,6 +97,36 @@ private extension Image {
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode cover art at the size it will be DRAWN, not the size the CDN shipped.
|
||||
///
|
||||
/// A Steam capsule is 600×900 (some custom art 1000×1500); decoded, that is 2–6 MB per poster
|
||||
/// and stays resident for as long as its tile does. A coverflow holds a dozen; a grid on an iPad
|
||||
/// Pro or an Apple TV holds forty, and Apple TV's memory ceiling is the lowest of the three.
|
||||
/// This is the desktop console's own lesson (its grid was a slideshow until posters were decoded
|
||||
/// at twice their cell size): `CGImageSourceCreateThumbnailAtIndex` decodes straight to a
|
||||
/// bounded bitmap and never materialises the full-size one. `maxPixels` is the longer edge, in
|
||||
/// PIXELS (the caller multiplies its point size by the screen scale, ×2 for headroom under the
|
||||
/// focus pop). nil ⇒ decode as-is (the touch grid's tiles are small and few enough).
|
||||
private func decodePoster(_ data: Data, maxPixels: Int?) -> PlatformImage? {
|
||||
guard let maxPixels, maxPixels > 0 else { return PlatformImage(data: data) }
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxPixels,
|
||||
]
|
||||
guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
||||
// A format ImageIO can't thumbnail (rare) still gets the full decode rather than a hole.
|
||||
return PlatformImage(data: data)
|
||||
}
|
||||
#if canImport(UIKit)
|
||||
return UIImage(cgImage: cg)
|
||||
#elseif canImport(AppKit)
|
||||
return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height))
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own
|
||||
/// art proxy, not just public CDNs — see `LibraryArtLoader`), advancing past any that fail to
|
||||
/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame
|
||||
@@ -111,12 +142,16 @@ struct PosterImage: View {
|
||||
/// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships
|
||||
/// no cover art by design, so for those the mark IS the poster — see `placeholder`.
|
||||
var icon: String?
|
||||
/// The size this poster is drawn at, in POINTS — the decode is bounded to twice its longer
|
||||
/// edge in pixels (see `decodePoster`). nil decodes the art as shipped.
|
||||
var drawnSize: CGSize?
|
||||
/// Fires once this poster has settled — art loaded, or every candidate exhausted and the
|
||||
/// placeholder is what it will be. The gamepad coverflow waits on a few of these before
|
||||
/// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles.
|
||||
var onLoaded: (() -> Void)?
|
||||
@State private var index = 0
|
||||
@State private var image: PlatformImage?
|
||||
@Environment(\.displayScale) private var displayScale
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
@@ -159,8 +194,11 @@ struct PosterImage: View {
|
||||
onLoaded?()
|
||||
return
|
||||
}
|
||||
// Twice the drawn edge: headroom for the focus pop and a Retina-crisp cover, without
|
||||
// ever holding the CDN's 600×900 (or larger) bitmap for the life of the tile.
|
||||
let maxPixels = drawnSize.map { Int(max($0.width, $0.height) * displayScale * 2) }
|
||||
guard let loader, let data = try? await loader.data(for: candidates[index]),
|
||||
let loaded = PlatformImage(data: data)
|
||||
let loaded = decodePoster(data, maxPixels: maxPixels)
|
||||
else {
|
||||
index += 1 // advance to the next candidate (or past the end → placeholder)
|
||||
return
|
||||
|
||||
@@ -40,6 +40,22 @@ enum ShotScenes {
|
||||
ShotScene(name: "11-library", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotLibrary())
|
||||
},
|
||||
// The grid arrangement, and the view/sort bar FOCUSED — the desktop shipped a
|
||||
// mis-sized bar wash precisely because no shot ever showed the bar with focus.
|
||||
ShotScene(name: "11b-library-grid", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotLibrary(arrangement: .grid))
|
||||
},
|
||||
ShotScene(name: "11c-library-bar", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotLibrary(arrangement: .shelf, barFocused: true))
|
||||
},
|
||||
// The Collections tiles (group by platform), as "start in collections" opens them.
|
||||
ShotScene(name: "11d-collections", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotLibrary(collections: true))
|
||||
},
|
||||
// A title's Options menu (X) over the shelf.
|
||||
ShotScene(name: "11e-library-options", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotLibrary(options: true))
|
||||
},
|
||||
]
|
||||
#if os(iOS) || os(macOS)
|
||||
// The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots
|
||||
@@ -54,6 +70,11 @@ enum ShotScenes {
|
||||
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotGamepadAddHost())
|
||||
},
|
||||
// The keyboard tray up, with the edited row seated above the keys (set
|
||||
// PUNKTFUNK_SHOT_EDITING=address to type into a field other than the name).
|
||||
ShotScene(name: "08b-gamepad-addhost-typing", orientation: .landscape, colorScheme: .dark) {
|
||||
AnyView(ShotGamepadAddHost())
|
||||
},
|
||||
ShotScene(name: "09-connecting", orientation: .natural, colorScheme: .dark) {
|
||||
AnyView(ShotConnect(kind: .connecting))
|
||||
},
|
||||
@@ -214,11 +235,11 @@ enum ShotMock {
|
||||
let json = """
|
||||
[
|
||||
{"id": "custom:aurora", "store": "custom", "title": "Aurora Drift",
|
||||
"art": {"portrait": "shot://art/aurora"}},
|
||||
"platform": "PS3", "art": {"portrait": "shot://art/aurora"}},
|
||||
{"id": "steam:starfall", "store": "steam", "title": "Starfall Vale",
|
||||
"art": {"portrait": "shot://art/starfall"}},
|
||||
{"id": "heroic:neon", "store": "heroic", "title": "Neon Circuit",
|
||||
"art": {"portrait": "shot://art/neon"}},
|
||||
"platform": "PC", "art": {"portrait": "shot://art/neon"}},
|
||||
{"id": "gog:ember", "store": "gog", "title": "Ember Peaks",
|
||||
"art": {"portrait": "shot://art/ember"}},
|
||||
{"id": "steam:launcher", "store": "steam", "title": "Steam", "art": {},
|
||||
@@ -267,14 +288,45 @@ private struct ShotHome: View {
|
||||
|
||||
// MARK: - Library
|
||||
|
||||
/// The library coverflow with the mock shelf — the store listing's PICK & PLAY frame. The real
|
||||
/// `LibraryCoverflowView`, no network: `ShotPosterArt` answers the mock entries' art immediately,
|
||||
/// The library with the mock shelf — the store listing's PICK & PLAY frame. The real
|
||||
/// `LibraryConsoleView` (coverflow or grid), no network: `ShotPosterArt` answers the mock entries' art immediately,
|
||||
/// so the cards swing in already carrying posters (the entrance waits on art settling).
|
||||
private struct ShotLibrary: View {
|
||||
var arrangement: LibraryArrangement?
|
||||
var barFocused = false
|
||||
var collections = false
|
||||
var options = false
|
||||
|
||||
/// Dev knobs for driving the console library on a Mac from the shot harness: with
|
||||
/// `PUNKTFUNK_FAKE_LIBRARY` set the scene shows that catalog (a real multi-row grid, the
|
||||
/// shared collate vectors file works) instead of the five-title mock; with
|
||||
/// `PUNKTFUNK_SHOT_INTERACTIVE=1` the screen owns the controller/keyboard, so arrow keys walk
|
||||
/// the grid exactly as the pad would.
|
||||
private var games: [GameEntry] {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let path = env["PUNKTFUNK_FAKE_LIBRARY"], !path.isEmpty,
|
||||
let data = FileManager.default.contents(atPath: path)
|
||||
else { return ShotMock.games }
|
||||
struct Wrapped: Decodable { let library: [GameEntry] }
|
||||
let decoder = JSONDecoder()
|
||||
if let list = try? decoder.decode([GameEntry].self, from: data) { return list.launchersFirst }
|
||||
if let wrapped = try? decoder.decode(Wrapped.self, from: data) { return wrapped.library.launchersFirst }
|
||||
return ShotMock.games
|
||||
}
|
||||
|
||||
private var interactive: Bool {
|
||||
ProcessInfo.processInfo.environment["PUNKTFUNK_SHOT_INTERACTIVE"] == "1"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LibraryCoverflowView(
|
||||
games: ShotMock.games, artLoader: ShotPosterArt.source,
|
||||
onLaunch: { _ in }, onDismiss: {}, controllerActive: false)
|
||||
LibraryConsoleView(
|
||||
games: games, artLoader: ShotPosterArt.source,
|
||||
onLaunch: { _ in }, onDismiss: {},
|
||||
// The mock has a clipboard action so the Options menu has its row to show.
|
||||
onCopyLink: { _ in }, hostName: "Battlestation",
|
||||
controllerActive: interactive,
|
||||
arrangementOverride: arrangement, barFocusedInitially: barFocused,
|
||||
startInCollectionsOverride: collections, optionsInitially: options)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,17 +35,8 @@ import CoreHaptics
|
||||
|
||||
/// The settings screen's sections. Order IS the strip order and the L1/R1 cycle order; the names
|
||||
/// match `pf-console-ui`'s `TABS` and the Android client's `GpTab`.
|
||||
enum GpSettingsTab: String, CaseIterable, Hashable {
|
||||
case stream = "Stream"
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
case controller = "Controller"
|
||||
case interface = "Interface"
|
||||
case profiles = "Profiles"
|
||||
/// Trailing, like Profiles: both are built from something other than the settings store, and
|
||||
/// About is where the strip ends because it is the one section that changes nothing.
|
||||
case about = "About"
|
||||
}
|
||||
// `GpSettingsTab` — the strip's sections — lives in PunktfunkShared (`ConsoleContract.swift`), where
|
||||
// `ConsoleVectorsTests` can pin its names against the shared vectors.
|
||||
|
||||
struct GamepadSettingsView: View {
|
||||
/// Resolved from `paletteID` below, NOT from `\.gamepadInk` — this screen publishes that value
|
||||
@@ -94,6 +85,10 @@ struct GamepadSettingsView: View {
|
||||
= StatsVerbosity.current.rawValue
|
||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||
/// The library's arrangement (shelf/grid) — one key, two surfaces: the library's own view/sort
|
||||
/// bar writes it too, so the field and this row can never disagree.
|
||||
@AppStorage(DefaultsKey.libraryView) private var libraryViewRaw = LibraryArrangement.shelf.stored
|
||||
@AppStorage(DefaultsKey.libraryCollections) private var libraryCollections = false
|
||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||
/// When the switch above takes over — the row is only built while it is on.
|
||||
@AppStorage(DefaultsKey.gamepadUIMode) private var gamepadUIMode =
|
||||
@@ -930,6 +925,23 @@ struct GamepadSettingsView: View {
|
||||
id: "library", tab: .interface, icon: "square.grid.2x2", label: "Game library",
|
||||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
|
||||
value: $libraryEnabled),
|
||||
// The two console-parity library rows (the desktop's `library_view` and
|
||||
// `library_collections`). Inert, not hidden, while the library is off: the rows keep
|
||||
// their place so the tab doesn't reflow under a toggle.
|
||||
choiceRow(
|
||||
id: "libraryView", tab: .interface, icon: "rectangle.grid.3x2",
|
||||
label: "Library view",
|
||||
detail: "Shelf is the coverflow; Grid shows more titles at once.",
|
||||
options: LibraryArrangement.all.map { (label: $0.label, tag: $0.stored) },
|
||||
current: LibraryArrangement(stored: libraryViewRaw).stored,
|
||||
enabled: libraryEnabled
|
||||
) { libraryViewRaw = $0 },
|
||||
toggleRow(
|
||||
id: "libraryCollections", tab: .interface, icon: "square.stack.3d.up",
|
||||
label: "Start in collections",
|
||||
detail: "Opens a library on its platform groups first; one-platform libraries "
|
||||
+ "still open on the shelf.",
|
||||
value: $libraryCollections, enabled: libraryEnabled),
|
||||
toggleRow(
|
||||
id: "gamepadUI", tab: .interface, icon: "hand.tap",
|
||||
label: "Controller-optimized UI",
|
||||
|
||||
@@ -448,6 +448,20 @@ extension SettingsView {
|
||||
+ "directly.") {
|
||||
Toggle("Show game library", isOn: $libraryEnabled)
|
||||
}
|
||||
if libraryEnabled {
|
||||
described("How the controller-optimized library arranges titles: Shelf is the "
|
||||
+ "coverflow, Grid shows more at once.") {
|
||||
Picker("Library view", selection: $libraryViewRaw) {
|
||||
ForEach(LibraryArrangement.all, id: \.stored) { arrangement in
|
||||
Text(arrangement.label).tag(arrangement.stored)
|
||||
}
|
||||
}
|
||||
}
|
||||
described("Opens a library on its platform groups first; a library with one "
|
||||
+ "platform still opens on the shelf.") {
|
||||
Toggle("Start in collections", isOn: $libraryCollections)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,10 @@ struct SettingsView: View {
|
||||
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
||||
@AppStorage(DefaultsKey.enable444) var enable444 = false
|
||||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
|
||||
/// The gamepad library's arrangement and its collections-first switch — device preferences,
|
||||
/// stored as the cross-client `library_view` / `library_collections` values.
|
||||
@AppStorage(DefaultsKey.libraryView) var libraryViewRaw = LibraryArrangement.shelf.stored
|
||||
@AppStorage(DefaultsKey.libraryCollections) var libraryCollections = false
|
||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||
@AppStorage(DefaultsKey.echoCancel) var echoCancel = true
|
||||
|
||||
@@ -53,6 +53,8 @@ struct GamepadPairView: View {
|
||||
@State private var focusID: String?
|
||||
/// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller.
|
||||
@State private var editing: String?
|
||||
/// The edited row's flight between its place in the list and its seat above the keyboard.
|
||||
@Namespace private var fieldFlight
|
||||
|
||||
var body: some View {
|
||||
GamepadMenuList(
|
||||
@@ -68,6 +70,20 @@ struct GamepadPairView: View {
|
||||
rowView(row, focused: focused)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
// While the tray edits this row, the row IS the one seated above the keyboard
|
||||
// (see `bottomTray`); its slot here stays empty and keeps the list's layout.
|
||||
.opacity(editing == row.id ? 0 : 1)
|
||||
// The flight's origin/destination: an invisible frame-provider that exists only
|
||||
// while the row is HERE. When editing starts it unmounts and the seated row is
|
||||
// inserted with the same id, so SwiftUI animates the seated row in FROM this
|
||||
// frame; when editing ends it returns and the seated row's removal flies back to
|
||||
// it. Exactly one matched view per id at any time — two live ones with the
|
||||
// source flag swapped sent the invisible list row flying instead.
|
||||
.overlay {
|
||||
if editing != row.id {
|
||||
Color.clear.matchedGeometryEffect(id: row.id, in: fieldFlight)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
@@ -107,12 +123,16 @@ struct GamepadPairView: View {
|
||||
// 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)
|
||||
// Not while the keyboard tray is up: Esc is the tray's Done then (see
|
||||
// GamepadKeyboard), and a shortcut here would fire first and close the whole screen.
|
||||
if editing == nil {
|
||||
Button("Cancel") { performClose() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 0, height: 0)
|
||||
.opacity(0)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,25 +164,37 @@ struct GamepadPairView: View {
|
||||
@ViewBuilder private var bottomTray: some View {
|
||||
if let editing {
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
text: editingBinding(editing),
|
||||
allowed: allowedCharacters(editing),
|
||||
onDone: { closeKeyboard() })
|
||||
// Fresh keyboard per field (see GamepadAddHostView) — the tray's input wiring
|
||||
// captured the previous binding on appear.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||
action: { backspace(editing) }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { closeKeyboard() }),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// The edited row sits directly above the keys, flown in from the list (see
|
||||
// GamepadAddHostView's twin) — what the keyboard covers no longer matters.
|
||||
if let row = rows.first(where: { $0.id == editing }) {
|
||||
rowView(row, focused: true)
|
||||
.frame(maxWidth: metrics.rowMaxWidth)
|
||||
.padding(.horizontal, 24)
|
||||
.matchedGeometryEffect(id: row.id, in: fieldFlight)
|
||||
.frame(maxWidth: .infinity)
|
||||
.transition(.opacity)
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
GamepadKeyboard(
|
||||
text: editingBinding(editing),
|
||||
allowed: allowedCharacters(editing),
|
||||
onDone: { closeKeyboard() })
|
||||
// Fresh keyboard per field (see GamepadAddHostView) — the tray's input
|
||||
// wiring captured the previous binding on appear.
|
||||
.id(editing)
|
||||
GamepadHintBar(hints: [
|
||||
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete",
|
||||
action: { backspace(editing) }),
|
||||
.init(
|
||||
glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done",
|
||||
action: { closeKeyboard() }),
|
||||
])
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
statusLine
|
||||
|
||||
@@ -50,6 +50,12 @@ public struct GameEntry: Codable, Hashable, Identifiable, Sendable {
|
||||
/// The token for this entry's brand mark (`"steam"`, `"heroic"`) — never art, never a URL.
|
||||
/// `nil` on every older host and on every ordinary title. See `launcherIconImage`.
|
||||
public var icon: String?
|
||||
/// The platform the host filed this title under ("PC", "PS3", "SNES" — free-form, the host's
|
||||
/// `GameMeta.platform`, populated by the rom-manager plugin and left `nil` by the store
|
||||
/// scanners). What the library's collections group by; a `nil` buckets under the STORE, never
|
||||
/// under "Unknown" (see `LibraryCollation.bucket`). Also on the detail band as
|
||||
/// `STORE · PLATFORM`.
|
||||
public var platform: String?
|
||||
|
||||
public var isCustom: Bool { store == "custom" }
|
||||
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
// Sorting and grouping the library — the Swift port of `pf-console-ui`'s `collate.rs`, which is
|
||||
// the portable SPEC every console shell implements: how titles fold for A–Z, what a platform-less
|
||||
// Steam title buckets under, why launchers always lead, and when a library is worth browsing as
|
||||
// collections at all. The rules are pinned by a shared vectors file rather than by prose:
|
||||
// `clients/shared/library-collate-vectors.json` holds one mixed library and the exact groups each
|
||||
// (sort, group_by) must produce, and `LibraryCollationTests` reads it — the desktop crate reads the
|
||||
// same file, so the two copies cannot drift in silence. Desktop is the source of truth; a rule
|
||||
// change lands there first, regenerates the file, and comes here second.
|
||||
//
|
||||
// Everything returns INDICES into the caller's array, never copies. The art cache, the running map
|
||||
// and the cursor all key off the model's ordering, and a collation that handed back cloned games
|
||||
// would fork the identity of every title on the shelf.
|
||||
//
|
||||
// Lives in PunktfunkKit rather than beside a view for the reason `LibraryGridNav` gives: pure
|
||||
// values in, pure values out, and PunktfunkKit is the target the tests can reach.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// How titles are ordered within a group. The raw value is the persisted `punktfunk.librarySort`
|
||||
/// string — a FILE FORMAT shared with the desktop's `library_sort` (`host|title|platform|store`);
|
||||
/// renaming one silently resets every user's chosen sort on their next launch.
|
||||
public enum LibrarySortKey: String, CaseIterable, Hashable, Sendable {
|
||||
/// The host's own order, untouched. The default, and byte-identical to the shelf as it has
|
||||
/// always been — a user who never opens the sort pills must see no change at all.
|
||||
case hostOrder = "host"
|
||||
/// A–Z, case- and diacritic-relaxed, with the leading article folded away.
|
||||
case title = "title"
|
||||
/// Platform label A–Z, then title within it.
|
||||
case platform = "platform"
|
||||
/// Store label A–Z, then title.
|
||||
case store = "store"
|
||||
|
||||
/// Parse the persisted value. Lenient by design: an unknown string is a newer client's key,
|
||||
/// and the right answer to one is today's shelf rather than an error — the rule `uiPalette`
|
||||
/// follows too.
|
||||
public init(stored: String?) {
|
||||
self = LibrarySortKey(rawValue: stored ?? "") ?? .hostOrder
|
||||
}
|
||||
|
||||
/// The persisted name.
|
||||
public var stored: String { rawValue }
|
||||
|
||||
/// The pill's label.
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .hostOrder: return "Default"
|
||||
case .title: return "A–Z"
|
||||
case .platform: return "Platform"
|
||||
case .store: return "Store"
|
||||
}
|
||||
}
|
||||
|
||||
/// The pills, in the order they are offered.
|
||||
public static let all: [LibrarySortKey] = [.hostOrder, .title, .platform, .store]
|
||||
}
|
||||
|
||||
/// The two arrangements of the gamepad library. Persisted as `punktfunk.libraryView`
|
||||
/// (`shelf|grid`, the desktop's `library_view`); unknown → shelf.
|
||||
public enum LibraryArrangement: String, CaseIterable, Hashable, Sendable {
|
||||
case shelf = "shelf"
|
||||
case grid = "grid"
|
||||
|
||||
public init(stored: String?) {
|
||||
self = LibraryArrangement(rawValue: stored ?? "") ?? .shelf
|
||||
}
|
||||
|
||||
public var stored: String { rawValue }
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .shelf: return "Shelf"
|
||||
case .grid: return "Grid"
|
||||
}
|
||||
}
|
||||
|
||||
public static let all: [LibraryArrangement] = [.shelf, .grid]
|
||||
}
|
||||
|
||||
/// What to bucket by. `nil` at the call sites = one group holding everything (the plain shelf).
|
||||
public enum LibraryGroupBy: Hashable, Sendable {
|
||||
case platform
|
||||
case store
|
||||
}
|
||||
|
||||
/// What a group IS, kept as data rather than a formatted string so a filtered library can be
|
||||
/// compared against it without re-parsing a label.
|
||||
public enum LibraryGroupKey: Hashable, Sendable {
|
||||
/// The launcher entries (design D4's leading group).
|
||||
case launchers
|
||||
case platform(String)
|
||||
case store(String)
|
||||
|
||||
/// The heading this group wears.
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .launchers: return "Launchers"
|
||||
case .platform(let name), .store(let name): return name
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind caption a collection tile wears (`LAUNCHERS` / `PLATFORM` / `STORE`).
|
||||
public var kindCaption: String {
|
||||
switch self {
|
||||
case .launchers: return "LAUNCHERS"
|
||||
case .platform: return "PLATFORM"
|
||||
case .store: return "STORE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One collated bucket: what it is, what to call it, and which games are in it.
|
||||
public struct LibraryGroup: Hashable, Sendable {
|
||||
public let key: LibraryGroupKey
|
||||
public let label: String
|
||||
/// Indices into the array passed to `LibraryCollation.collate`, in display order.
|
||||
public let indices: [Int]
|
||||
|
||||
public init(key: LibraryGroupKey, label: String, indices: [Int]) {
|
||||
self.key = key
|
||||
self.label = label
|
||||
self.indices = indices
|
||||
}
|
||||
}
|
||||
|
||||
public enum LibraryCollation {
|
||||
/// Fold a title down to something sortable: lowercase, diacritics relaxed to their base
|
||||
/// letter, punctuation dropped, and a leading article removed.
|
||||
///
|
||||
/// The article fold is what a user actually means by A–Z. "The Witcher 3" belongs under W;
|
||||
/// left alone, every "The …" in a library piles up under T and the sort is useless exactly
|
||||
/// where it is most needed. English articles only — the host's titles are whatever the store
|
||||
/// called them, and inventing rules for languages we cannot detect would file things under
|
||||
/// letters nobody expects.
|
||||
public static func sortTitle(_ title: String) -> String {
|
||||
var relaxed = String.UnicodeScalarView()
|
||||
for scalar in title.lowercased().unicodeScalars {
|
||||
let folded = fold(scalar)
|
||||
let props = folded.properties
|
||||
// Rust's `is_alphanumeric() || is_whitespace()`: Alphabetic, or a numeric type,
|
||||
// or White_Space — punctuation and symbols go.
|
||||
if props.isAlphabetic || props.numericType != nil || props.isWhitespace {
|
||||
relaxed.append(folded)
|
||||
}
|
||||
}
|
||||
let trimmed = String(relaxed).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
for article in ["the ", "a ", "an "] where trimmed.hasPrefix(article) {
|
||||
// Guard against a title that IS an article ("The", "A Way Out" keeps "way out", but a
|
||||
// bare "The" must not sort as an empty string and float to the front).
|
||||
let rest = String(trimmed.dropFirst(article.count))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !rest.isEmpty { return rest }
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/// The desktop's fold table, scalar for scalar.
|
||||
private static func fold(_ scalar: Unicode.Scalar) -> Unicode.Scalar {
|
||||
switch scalar {
|
||||
case "á", "à", "â", "ä", "ã", "å": return "a"
|
||||
case "é", "è", "ê", "ë": return "e"
|
||||
case "í", "ì", "î", "ï": return "i"
|
||||
case "ó", "ò", "ô", "ö", "õ": return "o"
|
||||
case "ú", "ù", "û", "ü": return "u"
|
||||
case "ç": return "c"
|
||||
case "ñ": return "n"
|
||||
default: return scalar
|
||||
}
|
||||
}
|
||||
|
||||
/// The bucket for one game under `by`.
|
||||
///
|
||||
/// The interesting case is a game with no platform. It does NOT go to "Unknown": a Steam
|
||||
/// library is entirely platform-less, and one giant "Unknown" heap would be a worse view than
|
||||
/// no grouping at all. A store-front game buckets under its STORE instead ("Steam"), which is
|
||||
/// both true and useful, and only an entry with neither lands in "Other".
|
||||
static func bucket(_ game: GameEntry, by: LibraryGroupBy) -> LibraryGroupKey {
|
||||
switch by {
|
||||
case .platform:
|
||||
let platform = game.platform?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !platform.isEmpty { return .platform(platform) }
|
||||
let store = game.storeLabel
|
||||
return store == "Game" ? .platform("Other") : .store(store)
|
||||
case .store:
|
||||
return .store(game.storeLabel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust's `str::cmp` — bytewise over UTF-8, so `"GOG" < "Game" < "Steam"`. Every ordering
|
||||
/// in this file goes through it: a locale-aware `<` would file the same library differently
|
||||
/// on two devices, and differently from the desktop.
|
||||
static func bytewise(_ a: String, _ b: String) -> Bool {
|
||||
a.utf8.lexicographicallyPrecedes(b.utf8)
|
||||
}
|
||||
|
||||
/// Collate `games` into display groups.
|
||||
///
|
||||
/// Launchers always form the leading group, which is how design D4's "launcher entries come
|
||||
/// first" invariant survives grouping BY CONSTRUCTION rather than by every caller remembering
|
||||
/// it. Sorting applies WITHIN groups, never across them.
|
||||
public static func collate(
|
||||
_ games: [GameEntry], sort: LibrarySortKey, groupBy: LibraryGroupBy?
|
||||
) -> [LibraryGroup] {
|
||||
var launchers: [Int] = []
|
||||
// Insertion-ordered rather than a dictionary, so groups appear in the order the library
|
||||
// first mentions them and two runs over the same library agree.
|
||||
var buckets: [(key: LibraryGroupKey, indices: [Int])] = []
|
||||
for (i, game) in games.enumerated() {
|
||||
if game.isLauncher {
|
||||
launchers.append(i)
|
||||
continue
|
||||
}
|
||||
// Ungrouped: one bucket holding the whole shelf. Its label is never drawn (the shelf
|
||||
// has no heading when there is only one group), so it names itself honestly rather
|
||||
// than inventing a title.
|
||||
let key = groupBy.map { bucket(game, by: $0) } ?? .platform("All")
|
||||
if let at = buckets.firstIndex(where: { $0.key == key }) {
|
||||
buckets[at].indices.append(i)
|
||||
} else {
|
||||
buckets.append((key, [i]))
|
||||
}
|
||||
}
|
||||
|
||||
// Fold every title once; the comparator below runs O(n log n) times per group.
|
||||
let titleKeys = games.map { sortTitle($0.title) }
|
||||
// Every comparator falls back to the index, so equal keys keep the host's order rather
|
||||
// than an arbitrary one — and the result is the same whether or not `sorted` is stable.
|
||||
func precedes(_ a: Int, _ b: Int) -> Bool {
|
||||
let byTitleThenIndex: () -> Bool = {
|
||||
if titleKeys[a] != titleKeys[b] { return bytewise(titleKeys[a], titleKeys[b]) }
|
||||
return a < b
|
||||
}
|
||||
switch sort {
|
||||
// Untouched: the host's order IS the order.
|
||||
case .hostOrder:
|
||||
return a < b
|
||||
case .title:
|
||||
return byTitleThenIndex()
|
||||
case .platform:
|
||||
let pa = games[a].platform ?? "", pb = games[b].platform ?? ""
|
||||
if pa != pb { return bytewise(pa, pb) }
|
||||
return byTitleThenIndex()
|
||||
case .store:
|
||||
let sa = games[a].storeLabel, sb = games[b].storeLabel
|
||||
if sa != sb { return bytewise(sa, sb) }
|
||||
return byTitleThenIndex()
|
||||
}
|
||||
}
|
||||
|
||||
var out: [LibraryGroup] = []
|
||||
if !launchers.isEmpty {
|
||||
// Launchers keep the host's order whatever the sort says: there are two or three of
|
||||
// them, they are a fixed set, and shuffling them by title makes muscle memory useless
|
||||
// for no gain.
|
||||
out.append(LibraryGroup(key: .launchers, label: LibraryGroupKey.launchers.label, indices: launchers))
|
||||
}
|
||||
// Groups themselves go A–Z by label, launchers excepted (pinned first); ties keep insertion
|
||||
// order, exactly like the desktop's stable sort.
|
||||
let ordered = buckets.enumerated().sorted { a, b in
|
||||
if a.element.key.label != b.element.key.label {
|
||||
return bytewise(a.element.key.label, b.element.key.label)
|
||||
}
|
||||
return a.offset < b.offset
|
||||
}
|
||||
for (_, bucket) in ordered {
|
||||
out.append(LibraryGroup(
|
||||
key: bucket.key, label: bucket.key.label, indices: bucket.indices.sorted(by: precedes)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// The flat index list for a group filter — `nil` = the whole library, in collated order
|
||||
/// (launchers included, first).
|
||||
public static func filtered(
|
||||
_ games: [GameEntry], sort: LibrarySortKey, filter: LibraryGroupKey?
|
||||
) -> [Int] {
|
||||
let by: LibraryGroupBy?
|
||||
switch filter {
|
||||
case .platform: by = .platform
|
||||
case .store: by = .store
|
||||
case .launchers, .none: by = nil
|
||||
}
|
||||
let groups = collate(games, sort: sort, groupBy: by)
|
||||
guard let want = filter else { return groups.flatMap(\.indices) }
|
||||
// A filter naming a group that no longer exists yields NOTHING, never everything.
|
||||
return groups.first { $0.key == want }?.indices ?? []
|
||||
}
|
||||
|
||||
/// Is there anything worth browsing? A library with one platform and one store has nothing to
|
||||
/// drill INTO, and a screen that opens onto a single tile is worse than no screen — so the
|
||||
/// button that reaches it is hidden rather than made to disappoint. ⚠ A Steam-only library
|
||||
/// with no platform metadata is ONE group and is not worth browsing — by design.
|
||||
public static func worthBrowsing(_ games: [GameEntry]) -> Bool {
|
||||
collate(games, sort: .hostOrder, groupBy: .platform)
|
||||
.filter { $0.key != .launchers }
|
||||
.count >= 2
|
||||
}
|
||||
}
|
||||
|
||||
/// The shelf's display order — the one rule every writer of the catalog goes through, the
|
||||
/// desktop's `order()`.
|
||||
///
|
||||
/// Two rules, in this order:
|
||||
/// 1. **Launcher entries lead** (design D4). Applied FIRST, because a grid's layout is built on
|
||||
/// it: the launcher rows sit above the game rows as a prefix. Let a running game jump ahead of
|
||||
/// a launcher and the heading reads GAMES · LAUNCHERS · GAMES along the strip.
|
||||
/// 2. **Running titles lead within their group.** Getting back into what is already up should
|
||||
/// be the first thing on the shelf rather than something to scroll for — and a launcher that
|
||||
/// is up still belongs with the launchers, which is what makes the two rules compose instead
|
||||
/// of fighting.
|
||||
///
|
||||
/// A stable partition into four bands; the host's own title order survives inside each.
|
||||
public enum LibraryOrder {
|
||||
public static func display(_ games: [GameEntry], running: Set<String>) -> [GameEntry] {
|
||||
games.enumerated().sorted { a, b in
|
||||
let ka = (a.element.isLauncher ? 0 : 1, running.contains(a.element.id) ? 0 : 1)
|
||||
let kb = (b.element.isLauncher ? 0 : 1, running.contains(b.element.id) ? 0 : 1)
|
||||
if ka != kb { return ka < kb }
|
||||
return a.offset < b.offset
|
||||
}.map(\.element)
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,22 @@ public final class GamepadMenuInput {
|
||||
let stick = gamepad.leftThumbstick
|
||||
let x = stick.xAxis.value
|
||||
let y = stick.yAxis.value
|
||||
let dpad = gamepad.dpad
|
||||
// HYSTERESIS: an engaged direction stays engaged until ITS OWN input releases, even if the
|
||||
// other axis is momentarily larger. Without this a single flick to the right passed through
|
||||
// samples where |y| > |x| on the way out of the dead zone and read as UP, then RIGHT — two
|
||||
// moves for one gesture. Invisible on the carousels (their vertical axis is inert or a
|
||||
// menu), a "random jump" on any 2-D field such as the library grid.
|
||||
if let current = currentDirection {
|
||||
let held: Bool
|
||||
switch current {
|
||||
case .left: held = (x < -deadzone && abs(x) >= abs(y) * 0.5) || dpad.left.isPressed
|
||||
case .right: held = (x > deadzone && abs(x) >= abs(y) * 0.5) || dpad.right.isPressed
|
||||
case .up: held = (y > deadzone && abs(y) >= abs(x) * 0.5) || dpad.up.isPressed
|
||||
case .down: held = (y < -deadzone && abs(y) >= abs(x) * 0.5) || dpad.down.isPressed
|
||||
}
|
||||
if held { return current }
|
||||
}
|
||||
// Horizontal wins an exact |x| == |y| diagonal tie (>=), matching the SDL core and Android
|
||||
// nav so a perfect 45° push resolves to the same direction on every client.
|
||||
if abs(x) >= abs(y), abs(x) > deadzone {
|
||||
@@ -147,7 +163,6 @@ public final class GamepadMenuInput {
|
||||
} else if abs(y) > deadzone {
|
||||
return y > 0 ? .up : .down
|
||||
}
|
||||
let dpad = gamepad.dpad
|
||||
if dpad.left.isPressed { return .left }
|
||||
if dpad.right.isPressed { return .right }
|
||||
if dpad.up.isPressed { return .up }
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Cursor arithmetic for the gamepad library's GRID arrangement — the port of `pf-console-ui`'s
|
||||
// `GridShape` / `grid_step` / `grid_col_hint` (`library.rs`), which is the portable rule set:
|
||||
//
|
||||
// ONE shape both the renderer and the cursor read. The overhaul's "focus engine disagreed with
|
||||
// its own layout" defect came from having two descriptions of the same field, which agreed only
|
||||
// when `launchers % cols == 0`. So the shape carries the launcher SPLIT — the launcher rows sit
|
||||
// above the game rows as a prefix with the games restarting at column 0 — and every question
|
||||
// (which cell is index i, how long is row r, where does row r start) is asked of it.
|
||||
//
|
||||
// ONE rule for moves, because an accretion of special cases is how this broke: horizontal moves
|
||||
// walk the row and refuse at THAT ROW's true ends; vertical moves and pages change row only,
|
||||
// carrying the remembered column and clamping it into the target row's length. Left/right
|
||||
// refusing rather than wrapping is the shelf's rule and the one a thumb already knows — a held
|
||||
// Right that wrapped would scan the whole library, and there are shoulders for that.
|
||||
//
|
||||
// This is deliberately NOT `LibraryGridNav`, the touch grid's hardware-keyboard cursor: that one
|
||||
// runs left/right flat across the section boundary, because a keyboard has no bump or haptic
|
||||
// vocabulary to say "end of row". Pure values in, pure values out: no SwiftUI.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The grid's layout, as the cursor and the renderer both read it.
|
||||
public struct LibraryGridShape: Hashable, Sendable {
|
||||
/// Cells per row. A RENDER fact — it depends on the window — so callers build the shape
|
||||
/// from what was actually laid out rather than deriving it twice from two widths.
|
||||
public let cols: Int
|
||||
/// How many cells there are: the FILTERED count, the one the cursor indexes.
|
||||
public let len: Int
|
||||
/// Where the games section starts, or 0 when the field is one continuous run.
|
||||
public let split: Int
|
||||
|
||||
/// `launchers` is the leading launcher run. The section only exists when BOTH halves do —
|
||||
/// an all-launcher or launcher-less field is a plain grid, and giving it a heading band and a
|
||||
/// gap it has no second group for would be a rule showing off.
|
||||
public init(len: Int, cols: Int, launchers: Int) {
|
||||
self.len = max(0, len)
|
||||
self.cols = max(0, cols)
|
||||
self.split = (launchers > 0 && launchers < len) ? launchers : 0
|
||||
}
|
||||
|
||||
private var safeCols: Int { max(1, cols) }
|
||||
|
||||
/// The first row of the games section (meaningless when there is no split).
|
||||
public var splitRow: Int { (split + safeCols - 1) / safeCols }
|
||||
|
||||
/// Which cell an index is drawn in.
|
||||
public func cell(of i: Int) -> (row: Int, col: Int) {
|
||||
if split > 0, i >= split {
|
||||
let j = i - split
|
||||
return (splitRow + j / safeCols, j % safeCols)
|
||||
}
|
||||
return (i / safeCols, i % safeCols)
|
||||
}
|
||||
|
||||
public var rows: Int {
|
||||
if split > 0 {
|
||||
return splitRow + (len - split + safeCols - 1) / safeCols
|
||||
}
|
||||
return (len + safeCols - 1) / safeCols
|
||||
}
|
||||
|
||||
/// The index of a row's first cell.
|
||||
public func rowStart(_ row: Int) -> Int {
|
||||
if split > 0, row >= splitRow {
|
||||
return split + (row - splitRow) * safeCols
|
||||
}
|
||||
return row * safeCols
|
||||
}
|
||||
|
||||
/// How many cells a row actually holds — the launcher section's last row stops where the
|
||||
/// games section begins, and the field's last row stops at the end of the library.
|
||||
public func rowLen(_ row: Int) -> Int {
|
||||
let start = rowStart(row)
|
||||
let end = (split > 0 && row + 1 == splitRow) ? split : len
|
||||
return min(max(0, end - start), safeCols)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the cursor is asked to go.
|
||||
public enum LibraryGridDirection: Hashable, Sendable {
|
||||
case left, right, up, down, pageBack, pageForward
|
||||
}
|
||||
|
||||
/// The answer: a new index, or a refusal (the caller bumps).
|
||||
public enum LibraryGridStep: Hashable, Sendable {
|
||||
case moved(Int)
|
||||
case boundary
|
||||
}
|
||||
|
||||
public enum LibraryGridCursor {
|
||||
/// L1/R1 page this many rows.
|
||||
public static let pageRows = 3
|
||||
|
||||
/// The move — see the file header for the rule. A cursor outside the field is a stale one
|
||||
/// (the library shortened under us); reading it as the nearest real cell makes the next
|
||||
/// press heal it instead of compounding it.
|
||||
public static func step(
|
||||
_ cursor: Int, shape: LibraryGridShape, colHint: Int, direction: LibraryGridDirection
|
||||
) -> LibraryGridStep {
|
||||
guard shape.len > 0, shape.cols > 0 else { return .boundary }
|
||||
let (row, col) = shape.cell(of: min(max(cursor, 0), shape.len - 1))
|
||||
func moved(_ i: Int) -> LibraryGridStep { i == cursor ? .boundary : .moved(i) }
|
||||
switch direction {
|
||||
case .left:
|
||||
return col == 0 ? .boundary : moved(shape.rowStart(row) + col - 1)
|
||||
case .right:
|
||||
return col + 1 >= shape.rowLen(row) ? .boundary : moved(shape.rowStart(row) + col + 1)
|
||||
case .up, .down, .pageBack, .pageForward:
|
||||
let (delta, paging): (Int, Bool)
|
||||
switch direction {
|
||||
case .up: (delta, paging) = (-1, false)
|
||||
case .down: (delta, paging) = (1, false)
|
||||
case .pageBack: (delta, paging) = (-pageRows, true)
|
||||
default: (delta, paging) = (pageRows, true)
|
||||
}
|
||||
let target = min(max(row + delta, 0), shape.rows - 1)
|
||||
if target == row {
|
||||
// A STEP at the edge refuses. A PAGE is a "take me there", so it lands on the
|
||||
// end of the row it is already on — the reading the shoulders have always had.
|
||||
guard paging else { return .boundary }
|
||||
let c = delta > 0 ? shape.rowLen(row) - 1 : 0
|
||||
return moved(shape.rowStart(row) + c)
|
||||
}
|
||||
let c = min(colHint, shape.rowLen(target) - 1)
|
||||
return moved(shape.rowStart(target) + c)
|
||||
}
|
||||
}
|
||||
|
||||
/// The remembered column after a move. A horizontal step CHOOSES a column; a vertical one
|
||||
/// only borrows it — which is what makes crossing a two-wide launcher row and coming back
|
||||
/// return to the column the crossing started from.
|
||||
public static func colHint(
|
||||
shape: LibraryGridShape, previous: Int, direction: LibraryGridDirection, landed: Int
|
||||
) -> Int {
|
||||
switch direction {
|
||||
case .left, .right: return shape.cell(of: max(landed, 0)).col
|
||||
default: return previous
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Where the controller IS inside one library shelf — the desktop console's screen stack for the
|
||||
// library (shelf → Collections → filtered shelf), kept as a small value type INSIDE the library
|
||||
// layer rather than as deeper `GamepadShell` screens. The shell's screen enum is derived from
|
||||
// presentation triggers with depth ≤ 1 by construction, macOS shows the library as a sheet and
|
||||
// tvOS as a cover, and the desktop's own property is that drilling never touches the model — the
|
||||
// filter is index-level, the art and the fetch don't change. So the library layer holds a stack
|
||||
// of PLACES and pushes/pops within itself; the shell's B reaches the layer's `onDismiss` only from
|
||||
// its root. Pure values in, pure values out — the flows are tested here, not on glass.
|
||||
//
|
||||
// The desktop's four flows, verbatim:
|
||||
// shelf → Y → collections → A → filtered shelf → B → collections → B → shelf → B → home
|
||||
// (start in collections) collections → A → filtered → B → collections → B → home
|
||||
// (start in collections) collections → Y "All titles" → unfiltered shelf (DRILLED) → B → collections
|
||||
// Y is refused with a pulse on any drilled shelf — the filtered drill-in and the "All titles" way out.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// One place inside the library layer.
|
||||
public enum LibraryPlace: Hashable, Sendable {
|
||||
/// The shelf (coverflow or grid), optionally filtered to one collated group.
|
||||
case shelf(filter: LibraryGroupKey?)
|
||||
/// The group-by-platform tiles.
|
||||
case collections
|
||||
|
||||
public var isCollections: Bool {
|
||||
if case .collections = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// The shelf's filter, if this is a shelf.
|
||||
public var filter: LibraryGroupKey? {
|
||||
if case .shelf(let f) = self { return f }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The library layer's stack of places. Never empty.
|
||||
public struct LibraryPlaceStack: Hashable, Sendable {
|
||||
public private(set) var places: [LibraryPlace]
|
||||
|
||||
public init(root: LibraryPlace) {
|
||||
places = [root]
|
||||
}
|
||||
|
||||
public var top: LibraryPlace { places[places.count - 1] }
|
||||
public var isRoot: Bool { places.count == 1 }
|
||||
|
||||
/// A shelf reached by drilling — the filtered drill-in from Collections, or the "All titles"
|
||||
/// way out of a Collections root. Y (Collections) is refused there, and its hint hidden: the
|
||||
/// way back is B.
|
||||
public var drilled: Bool {
|
||||
!isRoot && !top.isCollections
|
||||
}
|
||||
|
||||
/// Whether Collections may be pushed from here: only from an unfiltered ROOT shelf. (From a
|
||||
/// Collections root, the way to the plain shelf is Y "All titles", which is a different push.)
|
||||
public var canOpenCollections: Bool {
|
||||
isRoot && top == .shelf(filter: nil)
|
||||
}
|
||||
|
||||
/// Whether the "All titles" way out is offered: only on a Collections ROOT — a Collections
|
||||
/// place reached from a shelf already has that shelf one B away.
|
||||
public var offersAllTitles: Bool {
|
||||
isRoot && top.isCollections
|
||||
}
|
||||
|
||||
public mutating func push(_ place: LibraryPlace) {
|
||||
places.append(place)
|
||||
}
|
||||
|
||||
/// Pop the top place; `false` at the root (the caller dismisses the layer instead).
|
||||
@discardableResult
|
||||
public mutating func pop() -> Bool {
|
||||
guard places.count > 1 else { return false }
|
||||
places.removeLast()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The "start in collections" hand-over — decided ONCE per shelf, from the setting and the
|
||||
/// library as it stands. The desktop's `collections_upgrade`, minus its epoch: an Apple shelf's
|
||||
/// view owns its own fetch, so a stale list from another host cannot reach it.
|
||||
public enum CollectionsHandover {
|
||||
public enum Verdict: Equatable, Sendable {
|
||||
/// Nothing to decide on yet (the list is loading, empty or errored) — ask again later,
|
||||
/// the decision is NOT consumed.
|
||||
case wait
|
||||
/// Decided: the shelf it is (setting off, drilled, or not worth browsing).
|
||||
case shelf
|
||||
/// Decided: open on the Collections tiles.
|
||||
case collections
|
||||
}
|
||||
|
||||
/// - `settingOn`: `punktfunk.libraryCollections`.
|
||||
/// - `alreadyDecided`: this shelf decided once already — a later rescan or refresh must not
|
||||
/// yank a browsing user away.
|
||||
/// - `drilled`: the place stack is already past its root.
|
||||
/// - `ready`: the catalog is Ready — a CACHED catalog counts (the hand-over may fire on it,
|
||||
/// before the host answers; that is what made the cache worth having). Loading / empty /
|
||||
/// error decide nothing.
|
||||
/// - `worthBrowsing`: `LibraryCollation.worthBrowsing(games)`.
|
||||
public static func decide(
|
||||
settingOn: Bool, alreadyDecided: Bool, drilled: Bool, ready: Bool, worthBrowsing: Bool
|
||||
) -> Verdict {
|
||||
if alreadyDecided || !settingOn || drilled { return .shelf }
|
||||
guard ready else { return .wait }
|
||||
return worthBrowsing ? .collections : .shelf
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// The console UI's cross-client contract, as NUMBERS — the parts of `clients/shared/
|
||||
// console-vectors.json` this client implements that are not the palette table: the screen
|
||||
// push/pop choreography (`motion_spring`, version 2) and the settings tab list (`tabs`).
|
||||
//
|
||||
// In PunktfunkShared, not beside the views that use them, for the reason `GamepadPalette` gives
|
||||
// in its header: `PunktfunkClient` is an executable target with no test target of its own, so
|
||||
// anything that must be PINNED against the vectors file has to live where `ConsoleVectorsTests`
|
||||
// can reach it. `GamepadShellMotion` (the app) turns these into SwiftUI animations; the settings
|
||||
// screen turns the tab list into pills. Foundation only — the widget extension links this module.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// The console screen transition — the desktop console's spring push/pop (`shell.rs`), pinned by
|
||||
/// the vectors' `motion_spring` block. Springs are integrator-dependent, so the contract pins
|
||||
/// PARAMETERS, not sampled positions: two implementations that both honour response/damping agree
|
||||
/// to the eye and disagree in the third decimal.
|
||||
public enum ConsoleMotion {
|
||||
/// SwiftUI's `spring(response:dampingFraction:)` vocabulary, which is also the desktop's
|
||||
/// (`k = (2π/response)²`, `c = 2ζ√k`).
|
||||
public static let response: Double = 0.42
|
||||
public static let damping: Double = 0.88
|
||||
/// The incoming screen slides up out of a fade by this much (design units = points).
|
||||
public static let pushSlideDp: Double = 36
|
||||
/// …growing from this scale, while the screen beneath recedes to `exitScale`.
|
||||
public static let enterScale: Double = 0.985
|
||||
public static let exitScale: Double = 0.96
|
||||
/// A pop re-reveals the screen beneath from this alpha (the desktop's `NAV_REVEAL_ALPHA`;
|
||||
/// SwiftUI's opacity transition animates from 0 — a known, accepted deviation, see
|
||||
/// `AnyTransition.gamepadScreen`).
|
||||
public static let revealAlpha: Double = 0.4
|
||||
/// Back pressed mid-push turns the entering screen around; input other than Back stays
|
||||
/// dropped until the spring has passed 0.85 of its travel.
|
||||
public static let interruptible = true
|
||||
/// When a fresh push starts accepting input other than Back: the time this spring takes to
|
||||
/// pass 0.85 of its travel (≈ 0.2 s for response 0.42, damping 0.88 — the analytic step
|
||||
/// response, `1 − e^{−ζω₀t}(cos ω_d t + ζ/√(1−ζ²)·sin ω_d t)`, crosses 0.85 at t ≈ 0.20).
|
||||
public static let inputOpensAfter: TimeInterval = 0.20
|
||||
/// Under Reduce Motion the transition is a plain crossfade on this spring — no slide, no
|
||||
/// scale (the desktop's `REDUCED_NAV`).
|
||||
public static let reducedResponse: Double = 0.22
|
||||
public static let reducedDamping: Double = 1.0
|
||||
}
|
||||
|
||||
/// The gamepad settings screen's sections, in strip order. The names are the cross-client tab
|
||||
/// vocabulary (`tabs` in the vectors) — a setting is found under the same word on every client.
|
||||
/// `about` is this client's own trailing section: it is built from something other than the
|
||||
/// settings store and changes nothing, so it ends the strip and is not in the shared list.
|
||||
public enum GpSettingsTab: String, CaseIterable, Hashable, Sendable {
|
||||
case stream = "Stream"
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
case controller = "Controller"
|
||||
case interface = "Interface"
|
||||
case profiles = "Profiles"
|
||||
/// Trailing, like Profiles: both are built from something other than the settings store, and
|
||||
/// About is where the strip ends because it is the one section that changes nothing.
|
||||
case about = "About"
|
||||
|
||||
/// The tabs this client shares with the vectors' list — everything but its own About.
|
||||
public static var shared: [GpSettingsTab] { allCases.filter { $0 != .about } }
|
||||
}
|
||||
@@ -192,8 +192,30 @@ public enum DefaultsKey {
|
||||
/// "pointer" (the cursor jumps to the finger), or "touch" (real multi-touch passthrough).
|
||||
/// Read live per gesture by `StreamLayerUIView`.
|
||||
public static let touchMode = "punktfunk.touchMode"
|
||||
/// Experimental: show the host's game library (browsed over the management API). Off by default.
|
||||
/// Show the host's game library (browsed over the management API). On by default — every
|
||||
/// reader defaults it to `true`.
|
||||
public static let libraryEnabled = "punktfunk.libraryEnabled"
|
||||
/// How the library's titles are ordered within a group — a `LibrarySortKey` stored value
|
||||
/// (`"host"` = the host's own order, the default; `"title"` A–Z; `"platform"`; `"store"`).
|
||||
/// The cross-client `library_sort` key: the desktop console persists the same ids, and an
|
||||
/// unknown value reads as host order. Presentation only — a device preference, never part of
|
||||
/// a stream profile. Written by the library's sort/view bar and by the Collections screen.
|
||||
public static let librarySort = "punktfunk.librarySort"
|
||||
/// Which arrangement the gamepad library opens in — a `LibraryArrangement` stored value
|
||||
/// (`"shelf"` = the coverflow, the default; `"grid"`). The cross-client `library_view` key;
|
||||
/// unknown reads as shelf. Presentation only. One key, two surfaces: the library's bar and the
|
||||
/// Interface settings row both write it.
|
||||
public static let libraryView = "punktfunk.libraryView"
|
||||
/// Open a browsable library straight onto its Collections (group-by-platform tiles) instead of
|
||||
/// the shelf — the cross-client `library_collections` key. Off by default; a library that is
|
||||
/// not worth browsing (one platform, one store) opens on the shelf regardless. Presentation
|
||||
/// only.
|
||||
public static let libraryCollections = "punktfunk.libraryCollections"
|
||||
/// The TOUCH library grid's grouping — `""` (none, the default), `"platform"` or `"store"`:
|
||||
/// one section per collated group. Touch-only: on the console the grouping is a PLACE
|
||||
/// (Collections), not a mode of the shelf, so there is no cross-client key for it. The sort it
|
||||
/// composes with is the shared `librarySort`. Presentation only.
|
||||
public static let libraryGroupBy = "punktfunk.libraryGroupBy"
|
||||
/// macOS: take the window fullscreen while streaming and restore it on the host list. On by default.
|
||||
public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming"
|
||||
/// LEGACY (pre-tiered overlay): the old boolean stats-overlay toggle. Kept ONLY as the
|
||||
|
||||
@@ -16,11 +16,10 @@ import simd
|
||||
/// produces, which is what actually reaches the gradient. `GamepadPaletteTests` already asserts
|
||||
/// the invariants (hue spread, gamut, lightness honesty); this asserts the values.
|
||||
///
|
||||
/// ⚠️ The tab names and the shell motion constants are in the vectors file too, but this client
|
||||
/// cannot yet check them: `GpSettingsTab` and `GamepadShellMotion` live in `PunktfunkClient`,
|
||||
/// an executable target with no test target of its own. Moving them into `PunktfunkShared` — where
|
||||
/// `GamepadPalette` already sits, and for exactly this reason (see its header) — is what would
|
||||
/// close that gap.
|
||||
/// The tab names and the shell motion are pinned here too, since `GpSettingsTab` and
|
||||
/// `ConsoleMotion` moved into `PunktfunkShared` (`ConsoleContract.swift`) — where `GamepadPalette`
|
||||
/// already sat, and for exactly this reason. This client implements the version-2 `motion_spring`
|
||||
/// block; the deprecated v1 `motion` block is Android's until it migrates.
|
||||
final class ConsoleVectorsTests: XCTestCase {
|
||||
/// Read from the repo, not from a bundle resource: a copy would be a second file, and a
|
||||
/// second file drifts. Four `deletingLastPathComponent()` calls walk
|
||||
@@ -38,6 +37,36 @@ final class ConsoleVectorsTests: XCTestCase {
|
||||
let cellRamp: [Double]
|
||||
let meshInterior: [[Double]]
|
||||
let palettes: [Palette]
|
||||
let tabs: [Tab]
|
||||
let motionSpring: MotionSpring
|
||||
|
||||
// swiftlint:disable:next nesting
|
||||
struct Tab: Decodable {
|
||||
let name: String
|
||||
let desktopOnly: Bool?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case desktopOnly = "desktop_only"
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next nesting
|
||||
struct MotionSpring: Decodable {
|
||||
let response: Double
|
||||
let damping: Double
|
||||
let pushSlideDp: Double
|
||||
let enterScale: Double
|
||||
let exitScale: Double
|
||||
let revealAlpha: Double
|
||||
let interruptible: Bool
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case response, damping, interruptible
|
||||
case pushSlideDp = "push_slide_dp"
|
||||
case enterScale = "enter_scale"
|
||||
case exitScale = "exit_scale"
|
||||
case revealAlpha = "reveal_alpha"
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable:next nesting
|
||||
struct Palette: Decodable {
|
||||
@@ -55,10 +84,37 @@ final class ConsoleVectorsTests: XCTestCase {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case cellRamp = "cell_ramp"
|
||||
case meshInterior = "mesh_interior"
|
||||
case palettes
|
||||
case palettes, tabs
|
||||
case motionSpring = "motion_spring"
|
||||
}
|
||||
}
|
||||
|
||||
/// The section names, against the shared vectors — a setting is found under the same word on
|
||||
/// every client. The desktop's `Input` tab is `desktop_only` (touch mode, mouse, invert-scroll
|
||||
/// and shortcuts have nothing to set on a phone or a TV); this client's trailing `About` is its
|
||||
/// own and not in the shared list (`GpSettingsTab.shared` drops it).
|
||||
func testTabNamesMatchTheSharedVectors() throws {
|
||||
let file = try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: Self.vectorFileURL))
|
||||
let want = file.tabs.filter { $0.desktopOnly != true }.map(\.name)
|
||||
XCTAssertEqual(GpSettingsTab.shared.map(\.rawValue), want, "console settings tabs")
|
||||
XCTAssertEqual(GpSettingsTab.allCases.last, .about, "About ends the strip")
|
||||
}
|
||||
|
||||
/// The screen transition — the version-2 `motion_spring` block. Parameters, not samples:
|
||||
/// springs are integrator-dependent, and two implementations honouring response/damping agree
|
||||
/// to the eye. The geometry (slide, scales, reveal alpha) is unchanged from v1.
|
||||
func testMotionMatchesTheSharedVectors() throws {
|
||||
let file = try JSONDecoder().decode(VectorFile.self, from: Data(contentsOf: Self.vectorFileURL))
|
||||
let m = file.motionSpring
|
||||
assertClose(ConsoleMotion.response, m.response, "response")
|
||||
assertClose(ConsoleMotion.damping, m.damping, "damping")
|
||||
assertClose(ConsoleMotion.pushSlideDp, m.pushSlideDp, "push_slide_dp")
|
||||
assertClose(ConsoleMotion.enterScale, m.enterScale, "enter_scale")
|
||||
assertClose(ConsoleMotion.exitScale, m.exitScale, "exit_scale")
|
||||
assertClose(ConsoleMotion.revealAlpha, m.revealAlpha, "reveal_alpha")
|
||||
XCTAssertEqual(ConsoleMotion.interruptible, m.interruptible, "interruptible")
|
||||
}
|
||||
|
||||
private func assertClose(
|
||||
_ got: Double, _ want: Double, _ what: String, tolerance: Double = 1e-6,
|
||||
file: StaticString = #filePath, line: UInt = #line
|
||||
|
||||
@@ -27,6 +27,7 @@ final class LibraryClientTests: XCTestCase {
|
||||
"id": "custom:abc123",
|
||||
"store": "custom",
|
||||
"title": "Dolphin",
|
||||
"platform": "GameCube",
|
||||
"art": { "header": "https://example.com/dolphin.jpg" }
|
||||
}
|
||||
]
|
||||
@@ -45,6 +46,10 @@ final class LibraryClientTests: XCTestCase {
|
||||
XCTAssertTrue(custom.isCustom)
|
||||
XCTAssertNil(custom.launch)
|
||||
XCTAssertNil(custom.art.portrait)
|
||||
// `platform` is the host's flattened `GameMeta.platform`: present on a rom-manager entry,
|
||||
// absent (nil) on a store-front title — the field the library's collections group by.
|
||||
XCTAssertEqual(custom.platform, "GameCube")
|
||||
XCTAssertNil(steam.platform)
|
||||
}
|
||||
|
||||
func testPosterCandidatesPreferPortraitThenHeader() {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// The library's sort/group rules, against the desktop's `collate.rs` — by name (each case here
|
||||
/// is one of that module's tests) and by the shared vectors file
|
||||
/// (`clients/shared/library-collate-vectors.json`, which the desktop test reads too). If the
|
||||
/// vectors test goes red after a desktop rule change, the rule moved and this port owes the
|
||||
/// same change; if it goes red after an edit here, the port drifted.
|
||||
final class LibraryCollationTests: XCTestCase {
|
||||
/// Four `deletingLastPathComponent()` calls walk `Tests/PunktfunkKitTests/` → `Tests/` →
|
||||
/// `apple/` → `clients/`, the same way `ConsoleVectorsTests` finds its file.
|
||||
private static var vectorFileURL: URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("shared/library-collate-vectors.json")
|
||||
}
|
||||
|
||||
private func entry(
|
||||
_ id: String, _ title: String, store: String = "steam", platform: String? = nil,
|
||||
launcher: Bool = false
|
||||
) -> GameEntry {
|
||||
var json: [String: Any] = ["id": id, "store": store, "title": title, "art": [:]]
|
||||
if let platform { json["platform"] = platform }
|
||||
if launcher { json["role"] = "launcher" }
|
||||
let data = try! JSONSerialization.data(withJSONObject: json)
|
||||
return try! JSONDecoder().decode(GameEntry.self, from: data)
|
||||
}
|
||||
|
||||
private func ids(_ games: [GameEntry], _ indices: [Int]) -> [String] {
|
||||
indices.map { games[$0].id }
|
||||
}
|
||||
|
||||
// MARK: - The desktop's tests, by name
|
||||
|
||||
func testArticleFoldFilesTitlesWhereAReaderLooksForThem() {
|
||||
XCTAssertEqual(LibraryCollation.sortTitle("The Witcher 3"), "witcher 3")
|
||||
XCTAssertEqual(LibraryCollation.sortTitle("A Way Out"), "way out")
|
||||
XCTAssertEqual(LibraryCollation.sortTitle("An Untitled Story"), "untitled story")
|
||||
// Not an article, just a word starting with one.
|
||||
XCTAssertEqual(LibraryCollation.sortTitle("Theme Hospital"), "theme hospital")
|
||||
XCTAssertEqual(LibraryCollation.sortTitle("Anno 1800"), "anno 1800")
|
||||
// Diacritics relax; punctuation goes.
|
||||
XCTAssertEqual(LibraryCollation.sortTitle("Pokémon: Red!"), "pokemon red")
|
||||
// A title that is ONLY an article keeps something to sort on.
|
||||
XCTAssertEqual(LibraryCollation.sortTitle("The"), "the")
|
||||
}
|
||||
|
||||
func testPlatformLessStoreGamesBucketUnderTheirStoreNotUnknown() {
|
||||
let games = [
|
||||
entry("a", "Dota 2"),
|
||||
entry("b", "Half-Life"),
|
||||
entry("c", "Shadow of the Colossus", store: "custom", platform: "PS2"),
|
||||
// Neither a platform nor a store we have a label for.
|
||||
entry("d", "Mystery", store: "weird"),
|
||||
// A whitespace-only platform is no platform.
|
||||
entry("e", "Blank", store: "gog", platform: " "),
|
||||
]
|
||||
let groups = LibraryCollation.collate(games, sort: .hostOrder, groupBy: .platform)
|
||||
XCTAssertEqual(groups.map(\.label), ["GOG", "Other", "PS2", "Steam"])
|
||||
XCTAssertEqual(groups.map(\.key), [.store("GOG"), .platform("Other"), .platform("PS2"), .store("Steam")])
|
||||
XCTAssertEqual(ids(games, groups[3].indices), ["a", "b"])
|
||||
XCTAssertEqual(ids(games, groups[1].indices), ["d"])
|
||||
XCTAssertFalse(groups.contains { $0.label == "Unknown" })
|
||||
}
|
||||
|
||||
func testLaunchersAlwaysLeadWhateverTheSort() {
|
||||
let games = [
|
||||
entry("z", "Zebra"),
|
||||
entry("steam", "Steam", launcher: true),
|
||||
entry("a", "Aardvark", store: "gog"),
|
||||
entry("heroic", "Heroic", store: "heroic", launcher: true),
|
||||
]
|
||||
for sort in LibrarySortKey.all {
|
||||
for by in [nil, LibraryGroupBy.platform, .store] {
|
||||
let groups = LibraryCollation.collate(games, sort: sort, groupBy: by)
|
||||
XCTAssertEqual(groups.first?.key, .launchers, "\(sort) / \(String(describing: by))")
|
||||
// Host order inside the launcher group, whatever the sort.
|
||||
XCTAssertEqual(ids(games, groups[0].indices), ["steam", "heroic"], "\(sort)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testHostOrderIsByteIdenticalToNoSortingAtAll() {
|
||||
let games = [
|
||||
entry("c", "Charlie"), entry("a", "Alpha"), entry("b", "Bravo", store: "gog"),
|
||||
]
|
||||
let groups = LibraryCollation.collate(games, sort: .hostOrder, groupBy: nil)
|
||||
XCTAssertEqual(groups.count, 1)
|
||||
XCTAssertEqual(groups[0].label, "All")
|
||||
XCTAssertEqual(ids(games, groups[0].indices), ["c", "a", "b"])
|
||||
XCTAssertEqual(
|
||||
LibraryCollation.filtered(games, sort: .hostOrder, filter: nil), [0, 1, 2])
|
||||
}
|
||||
|
||||
func testEqualKeysKeepTheHostsOrder() {
|
||||
let games = [
|
||||
entry("second", "Same Name", store: "custom"),
|
||||
entry("first", "Same Name", store: "custom"),
|
||||
entry("third", "same name!", store: "custom"),
|
||||
]
|
||||
for sort in [LibrarySortKey.title, .platform, .store] {
|
||||
let groups = LibraryCollation.collate(games, sort: sort, groupBy: nil)
|
||||
XCTAssertEqual(ids(games, groups[0].indices), ["second", "first", "third"], "\(sort)")
|
||||
}
|
||||
}
|
||||
|
||||
func testFilteringReturnsOnlyThatGroupsGames() {
|
||||
let games = [
|
||||
entry("l", "Steam", launcher: true),
|
||||
entry("p1", "Portal", platform: "PC"),
|
||||
entry("g1", "God of War", store: "custom", platform: "PS3"),
|
||||
entry("g2", "Ico", store: "custom", platform: "PS3"),
|
||||
entry("s1", "Half-Life"),
|
||||
]
|
||||
XCTAssertEqual(
|
||||
ids(games, LibraryCollation.filtered(games, sort: .hostOrder, filter: .platform("PS3"))),
|
||||
["g1", "g2"])
|
||||
XCTAssertEqual(
|
||||
ids(games, LibraryCollation.filtered(games, sort: .title, filter: .store("Steam"))),
|
||||
["s1", "p1"])
|
||||
XCTAssertEqual(
|
||||
ids(games, LibraryCollation.filtered(games, sort: .hostOrder, filter: .launchers)), ["l"])
|
||||
// A group that isn't there yields nothing — never everything.
|
||||
XCTAssertEqual(LibraryCollation.filtered(games, sort: .hostOrder, filter: .platform("SNES")), [])
|
||||
}
|
||||
|
||||
func testEmptyAndSingleGroupLibrariesAreNotWorthBrowsing() {
|
||||
XCTAssertFalse(LibraryCollation.worthBrowsing([]))
|
||||
XCTAssertFalse(LibraryCollation.worthBrowsing([entry("l", "Steam", launcher: true)]))
|
||||
XCTAssertFalse(LibraryCollation.worthBrowsing([entry("a", "A"), entry("b", "B")]))
|
||||
XCTAssertFalse(LibraryCollation.worthBrowsing([
|
||||
entry("l", "Steam", launcher: true), entry("a", "A"), entry("b", "B"),
|
||||
]))
|
||||
XCTAssertTrue(LibraryCollation.worthBrowsing([entry("a", "A"), entry("b", "B", store: "gog")]))
|
||||
XCTAssertTrue(LibraryCollation.worthBrowsing([
|
||||
entry("a", "A", store: "custom", platform: "PS3"), entry("b", "B"),
|
||||
]))
|
||||
}
|
||||
|
||||
func testSortKeysParseFromTheirStoredNamesAndUnknownFallsBack() {
|
||||
XCTAssertEqual(LibrarySortKey(stored: "title"), .title)
|
||||
XCTAssertEqual(LibrarySortKey(stored: "platform"), .platform)
|
||||
XCTAssertEqual(LibrarySortKey(stored: "store"), .store)
|
||||
for key in LibrarySortKey.all {
|
||||
XCTAssertEqual(LibrarySortKey(stored: key.stored), key, "\(key.label) round-trips")
|
||||
}
|
||||
XCTAssertEqual(LibrarySortKey(stored: "something-newer"), .hostOrder)
|
||||
XCTAssertEqual(LibrarySortKey(stored: ""), .hostOrder)
|
||||
XCTAssertEqual(LibrarySortKey(stored: nil), .hostOrder)
|
||||
XCTAssertEqual(LibrarySortKey.all.map(\.stored), ["host", "title", "platform", "store"])
|
||||
|
||||
XCTAssertEqual(LibraryArrangement(stored: "grid"), .grid)
|
||||
XCTAssertEqual(LibraryArrangement(stored: "shelf"), .shelf)
|
||||
XCTAssertEqual(LibraryArrangement(stored: "carousel"), .shelf)
|
||||
XCTAssertEqual(LibraryArrangement(stored: nil), .shelf)
|
||||
}
|
||||
|
||||
/// The desktop model's `running_titles_lead_without_breaking_the_launcher_prefix`: launchers
|
||||
/// lead absolutely, running titles lead within their band, host order inside each band.
|
||||
func testRunningTitlesLeadWithoutBreakingTheLauncherPrefix() {
|
||||
let games = [
|
||||
entry("steam", "Steam", launcher: true),
|
||||
entry("heroic", "Heroic", store: "heroic", launcher: true),
|
||||
entry("a", "Alpha"),
|
||||
entry("b", "Bravo"),
|
||||
entry("c", "Charlie"),
|
||||
]
|
||||
let order = LibraryOrder.display(games, running: ["c", "heroic"]).map(\.id)
|
||||
XCTAssertEqual(order, ["heroic", "steam", "c", "a", "b"])
|
||||
// Nothing running: byte-identical.
|
||||
XCTAssertEqual(LibraryOrder.display(games, running: []).map(\.id), games.map(\.id))
|
||||
// A running GAME never jumps ahead of a launcher — the bug this replaced.
|
||||
XCTAssertEqual(
|
||||
LibraryOrder.display(games, running: ["b"]).map(\.id), ["steam", "heroic", "b", "a", "c"])
|
||||
}
|
||||
|
||||
// MARK: - The shared vectors file
|
||||
|
||||
private struct VectorFile: Decodable {
|
||||
struct TitleCase: Decodable { let `in`: String; let out: String }
|
||||
struct SortKeyCase: Decodable { let stored: String; let key: String }
|
||||
struct GroupExpect: Decodable {
|
||||
let kind: String
|
||||
let name: String?
|
||||
let label: String
|
||||
let ids: [String]
|
||||
}
|
||||
struct Case: Decodable {
|
||||
let name: String
|
||||
let sort: String
|
||||
let groupBy: String?
|
||||
let expect: [GroupExpect]
|
||||
enum CodingKeys: String, CodingKey { case name, sort, groupBy = "group_by", expect }
|
||||
}
|
||||
struct FilterKey: Decodable { let kind: String; let name: String? }
|
||||
struct FilteredCase: Decodable {
|
||||
let name: String
|
||||
let sort: String
|
||||
let filter: FilterKey?
|
||||
let expect: [String]
|
||||
}
|
||||
struct BrowsableCase: Decodable { let name: String; let ids: [String]?; let expect: Bool }
|
||||
|
||||
let version: Int
|
||||
let library: [GameEntry]
|
||||
let sortTitle: [TitleCase]
|
||||
let storeLabels: [String: String]
|
||||
let sortKeys: [SortKeyCase]
|
||||
let cases: [Case]
|
||||
let filtered: [FilteredCase]
|
||||
let worthBrowsing: [BrowsableCase]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case version, library, cases, filtered
|
||||
case sortTitle = "sort_title"
|
||||
case storeLabels = "store_labels"
|
||||
case sortKeys = "sort_keys"
|
||||
case worthBrowsing = "worth_browsing"
|
||||
}
|
||||
}
|
||||
|
||||
private func key(kind: String, name: String?) -> LibraryGroupKey {
|
||||
switch kind {
|
||||
case "launchers": return .launchers
|
||||
case "platform": return .platform(name!)
|
||||
case "store": return .store(name!)
|
||||
default: XCTFail("unknown group kind \(kind)"); return .launchers
|
||||
}
|
||||
}
|
||||
|
||||
private func groupBy(_ raw: String?) -> LibraryGroupBy? {
|
||||
switch raw {
|
||||
case nil: return nil
|
||||
case "platform": return .platform
|
||||
case "store": return .store
|
||||
default: XCTFail("unknown group_by \(raw!)"); return nil
|
||||
}
|
||||
}
|
||||
|
||||
func testVectorsMatchTheSharedFile() throws {
|
||||
let data = try Data(contentsOf: Self.vectorFileURL)
|
||||
let file = try JSONDecoder().decode(VectorFile.self, from: data)
|
||||
XCTAssertEqual(file.version, 1, "bump the reader when the file's version moves")
|
||||
let games = file.library
|
||||
// The library array is the host wire shape, so the real model decodes it — including
|
||||
// `platform`, which is the field this whole feature hangs on.
|
||||
XCTAssertEqual(games.count, 14)
|
||||
XCTAssertEqual(games.first { $0.id == "custom:gow3" }?.platform, "PS3")
|
||||
|
||||
for c in file.sortTitle {
|
||||
XCTAssertEqual(LibraryCollation.sortTitle(c.in), c.out, "sortTitle(\(c.in))")
|
||||
}
|
||||
for (store, label) in file.storeLabels {
|
||||
XCTAssertEqual(entry("x", "X", store: store).storeLabel, label, "storeLabel(\(store))")
|
||||
}
|
||||
for c in file.sortKeys {
|
||||
XCTAssertEqual(LibrarySortKey(stored: c.stored).stored, c.key, "parse(\(c.stored))")
|
||||
}
|
||||
for c in file.cases {
|
||||
let got = LibraryCollation.collate(games, sort: LibrarySortKey(stored: c.sort), groupBy: groupBy(c.groupBy))
|
||||
XCTAssertEqual(got.count, c.expect.count, "\(c.name): group count")
|
||||
for (g, w) in zip(got, c.expect) {
|
||||
XCTAssertEqual(g.key, key(kind: w.kind, name: w.name), "\(c.name): group key")
|
||||
XCTAssertEqual(g.label, w.label, "\(c.name): label")
|
||||
XCTAssertEqual(ids(games, g.indices), w.ids, "\(c.name): \(g.label) ids")
|
||||
}
|
||||
}
|
||||
for c in file.filtered {
|
||||
let filter = c.filter.map { key(kind: $0.kind, name: $0.name) }
|
||||
XCTAssertEqual(
|
||||
ids(games, LibraryCollation.filtered(games, sort: LibrarySortKey(stored: c.sort), filter: filter)),
|
||||
c.expect, c.name)
|
||||
}
|
||||
for c in file.worthBrowsing {
|
||||
let subset: [GameEntry]
|
||||
if let want = c.ids {
|
||||
subset = want.map { id in games.first { $0.id == id }! }
|
||||
} else {
|
||||
subset = games
|
||||
}
|
||||
XCTAssertEqual(LibraryCollation.worthBrowsing(subset), c.expect, c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// The gamepad grid's shape and cursor, against the desktop's `library.rs` grid tests — the same
|
||||
/// nine cases by name, over the same shapes, so a rule that moves there is a red test here.
|
||||
final class LibraryGridCursorTests: XCTestCase {
|
||||
/// (len, cols, launchers) — the desktop's `SHAPES` fixture.
|
||||
private let shapes: [(Int, Int, Int)] = [
|
||||
(11, 4, 0), (40, 5, 0), (30, 7, 2), (20, 4, 6), (4, 4, 2), (9, 3, 3), (7, 3, 7),
|
||||
(1, 3, 1), (13, 1, 2),
|
||||
]
|
||||
|
||||
private func shape(_ t: (Int, Int, Int)) -> LibraryGridShape {
|
||||
LibraryGridShape(len: t.0, cols: t.1, launchers: t.2)
|
||||
}
|
||||
|
||||
private func step(
|
||||
_ cursor: Int, _ s: LibraryGridShape, _ hint: Int, _ dir: LibraryGridDirection
|
||||
) -> LibraryGridStep {
|
||||
LibraryGridCursor.step(cursor, shape: s, colHint: hint, direction: dir)
|
||||
}
|
||||
|
||||
/// The invariant the two old layout models broke: a cell's coordinates and its row's
|
||||
/// extent come from one shape, and every index is in exactly one row.
|
||||
func testGridRowsTileTheFieldExactlyOnce() {
|
||||
for t in shapes {
|
||||
let s = shape(t)
|
||||
var next = 0
|
||||
for row in 0..<s.rows {
|
||||
let n = s.rowLen(row)
|
||||
XCTAssertTrue((1...t.1).contains(n), "\(t) row \(row) holds \(n) cells")
|
||||
for col in 0..<n {
|
||||
let i = s.rowStart(row) + col
|
||||
XCTAssertEqual(i, next, "\(t) row \(row) does not follow the one above")
|
||||
XCTAssertEqual(s.cell(of: i).row, row, "\(t) disagrees about index \(i)")
|
||||
XCTAssertEqual(s.cell(of: i).col, col, "\(t) disagrees about index \(i)")
|
||||
next += 1
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(next, t.0, "\(t) left cells in no row at all")
|
||||
}
|
||||
}
|
||||
|
||||
/// A row END refuses (like the shelf), and it is the row's TRUE end — not `cols`, which
|
||||
/// is a different number in every partial row and in the whole games section.
|
||||
func testGridHorizontalMovesWalkTheRowAndRefuseItsTrueEnds() {
|
||||
for t in shapes {
|
||||
let s = shape(t)
|
||||
for i in 0..<t.0 {
|
||||
let (row, col) = s.cell(of: i)
|
||||
for hint in 0..<t.1 {
|
||||
XCTAssertEqual(
|
||||
step(i, s, hint, .left), col == 0 ? .boundary : .moved(i - 1),
|
||||
"\(t) Left from \(i)")
|
||||
XCTAssertEqual(
|
||||
step(i, s, hint, .right),
|
||||
col + 1 == s.rowLen(row) ? .boundary : .moved(i + 1),
|
||||
"\(t) Right from \(i)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical moves change the ROW and nothing else, arriving in the remembered column
|
||||
/// clamped into whatever that row can hold.
|
||||
func testGridVerticalMovesChangeRowAndCarryTheColumn() {
|
||||
let vertical: [(LibraryGridDirection, Int)] = [
|
||||
(.up, -1), (.down, 1), (.pageBack, -LibraryGridCursor.pageRows),
|
||||
(.pageForward, LibraryGridCursor.pageRows),
|
||||
]
|
||||
for t in shapes {
|
||||
let s = shape(t)
|
||||
for i in 0..<t.0 {
|
||||
let row = s.cell(of: i).row
|
||||
for hint in 0..<t.1 {
|
||||
for (dir, d) in vertical {
|
||||
let wantRow = min(max(row + d, 0), s.rows - 1)
|
||||
let what = "\(t) \(dir) from \(i) with hint \(hint)"
|
||||
switch step(i, s, hint, dir) {
|
||||
case .moved(let to):
|
||||
let (r, c) = s.cell(of: to)
|
||||
XCTAssertEqual(r, wantRow, "\(what) landed in row \(r)")
|
||||
if r != row {
|
||||
XCTAssertEqual(c, min(hint, s.rowLen(r) - 1), "\(what) column")
|
||||
}
|
||||
case .boundary:
|
||||
XCTAssertEqual(wantRow, row, "\(what) refused")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Both sections stay reachable from anywhere in the other, in a bounded number of presses.
|
||||
func testEveryRowIsReachableByStepping() {
|
||||
for t in shapes {
|
||||
let s = shape(t)
|
||||
for i in 0..<t.0 {
|
||||
for (dir, end) in [(LibraryGridDirection.up, 0), (.down, s.rows - 1)] {
|
||||
var cursor = i
|
||||
for _ in 0...s.rows {
|
||||
if case .moved(let to) = step(cursor, s, 0, dir) { cursor = to } else { break }
|
||||
}
|
||||
XCTAssertEqual(s.cell(of: cursor).row, end, "\(t) \(dir) from \(i) stalled")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Deck, exactly: 1280×800 gives seven columns, and a host with the usual two launchers
|
||||
/// puts them alone on row 0 with the games restarting at column 0 under them.
|
||||
func testTheLauncherRowSitsSquarelyAboveTheGames() {
|
||||
let s = LibraryGridShape(len: 30, cols: 7, launchers: 2)
|
||||
XCTAssertEqual(s.rows, 5)
|
||||
XCTAssertEqual(s.rowLen(0), 2)
|
||||
XCTAssertEqual(s.rowLen(1), 7)
|
||||
XCTAssertEqual(step(0, s, 0, .down), .moved(2))
|
||||
XCTAssertEqual(step(1, s, 1, .down), .moved(3))
|
||||
XCTAssertEqual(step(2, s, 0, .up), .moved(0))
|
||||
XCTAssertEqual(step(3, s, 1, .up), .moved(1))
|
||||
XCTAssertEqual(step(6, s, 4, .up), .moved(1))
|
||||
XCTAssertEqual(step(6, s, 4, .right), .moved(7))
|
||||
XCTAssertEqual(step(7, s, 5, .left), .moved(6))
|
||||
XCTAssertEqual(step(8, s, 6, .right), .boundary)
|
||||
XCTAssertEqual(step(2, s, 0, .left), .boundary)
|
||||
}
|
||||
|
||||
/// The remembered column is what makes a crossing reversible.
|
||||
func testACrossingReturnsToTheColumnItStartedFrom() {
|
||||
let s = LibraryGridShape(len: 30, cols: 7, launchers: 2)
|
||||
func walk(_ start: Int, _ dirs: [LibraryGridDirection]) -> Int {
|
||||
var cursor = start
|
||||
var hint = s.cell(of: max(start, 0)).col
|
||||
for dir in dirs {
|
||||
if case .moved(let to) = step(cursor, s, hint, dir) {
|
||||
hint = LibraryGridCursor.colHint(shape: s, previous: hint, direction: dir, landed: to)
|
||||
cursor = to
|
||||
}
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
XCTAssertEqual(walk(0, [.down, .right, .right, .right, .right]), 6)
|
||||
XCTAssertEqual(walk(0, [.down, .right, .right, .right, .right, .up]), 1)
|
||||
XCTAssertEqual(walk(0, [.down, .right, .right, .right, .right, .up, .down]), 6)
|
||||
XCTAssertEqual(walk(0, [.down, .right, .right, .right, .right, .up, .down, .up, .down]), 6)
|
||||
}
|
||||
|
||||
func testGridPagesByRowsAndLandsOnTheEnds() {
|
||||
let s = LibraryGridShape(len: 40, cols: 5, launchers: 0)
|
||||
XCTAssertEqual(step(0, s, 0, .pageForward), .moved(15))
|
||||
XCTAssertEqual(step(35, s, 0, .pageForward), .moved(39))
|
||||
XCTAssertEqual(step(39, s, 4, .pageForward), .boundary)
|
||||
XCTAssertEqual(step(3, s, 3, .pageBack), .moved(0))
|
||||
XCTAssertEqual(step(0, s, 0, .pageBack), .boundary)
|
||||
}
|
||||
|
||||
/// The launcher-less grid, unchanged: rows of 4, 4, 3.
|
||||
func testGridRowsRefuseAtTheirEndsButTheTailRowClamps() {
|
||||
let s = LibraryGridShape(len: 11, cols: 4, launchers: 0)
|
||||
XCTAssertEqual(step(1, s, 1, .right), .moved(2))
|
||||
XCTAssertEqual(step(2, s, 2, .left), .moved(1))
|
||||
XCTAssertEqual(step(3, s, 3, .right), .boundary)
|
||||
XCTAssertEqual(step(4, s, 0, .left), .boundary)
|
||||
XCTAssertEqual(step(1, s, 1, .down), .moved(5))
|
||||
XCTAssertEqual(step(7, s, 3, .down), .moved(10))
|
||||
XCTAssertEqual(step(10, s, 3, .down), .boundary)
|
||||
XCTAssertEqual(step(2, s, 2, .up), .boundary)
|
||||
XCTAssertEqual(step(6, s, 2, .up), .moved(2))
|
||||
}
|
||||
|
||||
func testGridStepIsSafeOnADegenerateGrid() {
|
||||
let empty = LibraryGridShape(len: 0, cols: 4, launchers: 0)
|
||||
XCTAssertEqual(step(0, empty, 0, .right), .boundary)
|
||||
let colless = LibraryGridShape(len: 5, cols: 0, launchers: 0)
|
||||
XCTAssertEqual(step(0, colless, 0, .right), .boundary)
|
||||
let thin = LibraryGridShape(len: 5, cols: 1, launchers: 0)
|
||||
XCTAssertEqual(step(1, thin, 0, .right), .boundary)
|
||||
XCTAssertEqual(step(1, thin, 0, .down), .moved(2))
|
||||
let s = LibraryGridShape(len: 6, cols: 3, launchers: 2)
|
||||
XCTAssertEqual(step(99, s, 0, .up), .moved(2))
|
||||
XCTAssertEqual(step(-4, s, 0, .right), .moved(1))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
/// The library layer's place stack and the start-in-collections hand-over — the desktop's
|
||||
/// screen-stack flows for the library, as values.
|
||||
final class LibraryPlacesTests: XCTestCase {
|
||||
/// shelf → Y → collections → A → filtered shelf → B → collections → B → shelf → B → dismiss.
|
||||
func testYPushesCollectionsAPushesAFilteredShelfAndBReturnsTheWayItCame() {
|
||||
var stack = LibraryPlaceStack(root: .shelf(filter: nil))
|
||||
XCTAssertTrue(stack.canOpenCollections)
|
||||
XCTAssertFalse(stack.drilled)
|
||||
XCTAssertFalse(stack.offersAllTitles)
|
||||
|
||||
stack.push(.collections)
|
||||
XCTAssertEqual(stack.top, .collections)
|
||||
XCTAssertFalse(stack.offersAllTitles, "a Collections place under a shelf has that shelf one B away")
|
||||
XCTAssertFalse(stack.drilled)
|
||||
|
||||
stack.push(.shelf(filter: .platform("PS3")))
|
||||
XCTAssertTrue(stack.drilled)
|
||||
XCTAssertFalse(stack.canOpenCollections, "Y is refused on a drilled shelf")
|
||||
XCTAssertEqual(stack.top.filter, .platform("PS3"))
|
||||
|
||||
XCTAssertTrue(stack.pop())
|
||||
XCTAssertEqual(stack.top, .collections)
|
||||
XCTAssertTrue(stack.pop())
|
||||
XCTAssertEqual(stack.top, .shelf(filter: nil))
|
||||
XCTAssertTrue(stack.isRoot)
|
||||
XCTAssertFalse(stack.pop(), "at the root, B is the layer's dismiss")
|
||||
XCTAssertEqual(stack.places.count, 1)
|
||||
}
|
||||
|
||||
/// The way to "All titles" exists only where there is no shelf underneath.
|
||||
func testTheWayToAllTitlesExistsOnlyWhereThereIsNoShelf() {
|
||||
var stack = LibraryPlaceStack(root: .collections)
|
||||
XCTAssertTrue(stack.offersAllTitles)
|
||||
XCTAssertFalse(stack.canOpenCollections)
|
||||
|
||||
stack.push(.shelf(filter: nil))
|
||||
XCTAssertTrue(stack.drilled, "the All-titles shelf is a drill-in — Y is refused there too")
|
||||
XCTAssertFalse(stack.canOpenCollections)
|
||||
XCTAssertFalse(stack.offersAllTitles)
|
||||
stack.pop()
|
||||
XCTAssertTrue(stack.offersAllTitles)
|
||||
}
|
||||
|
||||
func testTheHandoverIsDecidedOnceFromTheSettingAndTheLibrary() {
|
||||
typealias H = CollectionsHandover
|
||||
// Off: the shelf, whatever else.
|
||||
XCTAssertEqual(H.decide(settingOn: false, alreadyDecided: false, drilled: false, ready: true, worthBrowsing: true), .shelf)
|
||||
// Decided already: never again for this shelf.
|
||||
XCTAssertEqual(H.decide(settingOn: true, alreadyDecided: true, drilled: false, ready: true, worthBrowsing: true), .shelf)
|
||||
// Drilled: refused outright.
|
||||
XCTAssertEqual(H.decide(settingOn: true, alreadyDecided: false, drilled: true, ready: true, worthBrowsing: true), .shelf)
|
||||
// Not ready (loading / empty / error): decide nothing, decision NOT consumed.
|
||||
XCTAssertEqual(H.decide(settingOn: true, alreadyDecided: false, drilled: false, ready: false, worthBrowsing: true), .wait)
|
||||
// Ready — a cached catalog counts — and worth browsing: open on the tiles.
|
||||
XCTAssertEqual(H.decide(settingOn: true, alreadyDecided: false, drilled: false, ready: true, worthBrowsing: true), .collections)
|
||||
// Ready but one group: the shelf stays.
|
||||
XCTAssertEqual(H.decide(settingOn: true, alreadyDecided: false, drilled: false, ready: true, worthBrowsing: false), .shelf)
|
||||
}
|
||||
}
|
||||
@@ -1953,7 +1953,7 @@
|
||||
}
|
||||
],
|
||||
"motion": {
|
||||
"$deprecated": "SUPERSEDED by `motion_spring` (version 2). The desktop console's transition is a damped spring now, not a 0.26 s ease-out-cubic, so it no longer implements this block and its test reads `motion_spring` instead. Kept in place, and still correct, for the clients that have not migrated: the Android client's ConsoleVectorsTest pins it, and the Apple client's GamepadShell mirrors these constants (untested there - its ConsoleVectorsTests covers the palette table only). DELETE THIS BLOCK when the last client moves; until then the drift is visible rather than silent, because each client's test names which block it implements.",
|
||||
"$deprecated": "SUPERSEDED by `motion_spring` (version 2). The desktop console's transition is a damped spring now, not a 0.26 s ease-out-cubic, so it no longer implements this block and its test reads `motion_spring` instead. Kept in place, and still correct, for the client that has not migrated: the Android client's ConsoleVectorsTest pins it. (The Apple client moved to `motion_spring` on 2026-08-18 - `ConsoleMotion` in PunktfunkShared, pinned by its ConsoleVectorsTests.) DELETE THIS BLOCK when Android moves; until then the drift is visible rather than silent, because each client's test names which block it implements.",
|
||||
"$why": "The console screen transition, from pf-console-ui's shell: TRANSITION_S and the paint geometry in shell/render.rs. Every client re-implements this by hand in its own animation system, which is why it is pinned here.",
|
||||
"transition_s": 0.26,
|
||||
"push_slide_dp": 36.0,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"$comment": "Cross-client parity vectors for LIBRARY COLLATION — the sort/group rules every console shell must implement identically (desktop pf-console-ui `collate.rs` is the source of truth; Apple `LibraryCollation.swift` and Android port it). One mixed library, then the exact groups each (sort, group_by) must produce, the flat lists a group filter yields, which libraries are 'worth browsing', the title fold, the store labels, and the persisted sort ids. `library` entries use the host's /api/v1/library wire shape so a client can decode them with its real model. Read by: crates/pf-console-ui/src/collate.rs (`vectors_match_the_shared_file`), clients/apple/Tests/PunktfunkKitTests/LibraryCollationTests.swift. Group labels sort BYTEWISE (so 'GOG' < 'Game' < 'Steam'); an ungrouped collation is one group keyed platform/'All' (never drawn); `filtered` with no filter flattens EVERY group, launchers included.",
|
||||
"version": 1,
|
||||
"library": [
|
||||
{"id": "steam:launcher", "store": "steam", "title": "Steam", "role": "launcher", "icon": "steam", "art": {}},
|
||||
{"id": "steam:292030", "store": "steam", "title": "The Witcher 3: Wild Hunt", "art": {}},
|
||||
{"id": "steam:1222700", "store": "steam", "title": "A Way Out", "art": {}},
|
||||
{"id": "custom:zelda-oot", "store": "custom", "title": "Zelda: Ocarina of Time", "platform": "N64", "art": {}},
|
||||
{"id": "custom:sm64", "store": "custom", "title": "Super Mario 64", "platform": "N64", "art": {}},
|
||||
{"id": "heroic:launcher", "store": "heroic", "title": "Heroic", "role": "launcher", "icon": "heroic", "art": {}},
|
||||
{"id": "custom:gow3", "store": "custom", "title": "God of War", "platform": "PS3", "art": {}},
|
||||
{"id": "custom:eclair", "store": "custom", "title": "Éclair", "platform": "PS3", "art": {}},
|
||||
{"id": "gog:witcher", "store": "gog", "title": "The Witcher", "art": {}},
|
||||
{"id": "weird:odyssey", "store": "weird", "title": "An Odyssey", "art": {}},
|
||||
{"id": "custom:the", "store": "custom", "title": "The", "platform": "PS3", "art": {}},
|
||||
{"id": "steam:620", "store": "steam", "title": "Portal 2", "platform": "PC", "art": {}},
|
||||
{"id": "steam:lower", "store": "steam", "title": "a lowercase start", "platform": "", "art": {}},
|
||||
{"id": "custom:gow-dup", "store": "custom", "title": "God of War", "platform": "PS3", "art": {}}
|
||||
],
|
||||
"sort_title": [
|
||||
{"in": "The Witcher 3: Wild Hunt", "out": "witcher 3 wild hunt"},
|
||||
{"in": "A Way Out", "out": "way out"},
|
||||
{"in": "An Untitled Story", "out": "untitled story"},
|
||||
{"in": "Theme Hospital", "out": "theme hospital"},
|
||||
{"in": "Anno 1800", "out": "anno 1800"},
|
||||
{"in": "Pokémon: Red!", "out": "pokemon red"},
|
||||
{"in": "Éclair", "out": "eclair"},
|
||||
{"in": "Spider-Man 2", "out": "spiderman 2"},
|
||||
{"in": "The", "out": "the"},
|
||||
{"in": " A ", "out": "a"},
|
||||
{"in": "Ōkami", "out": "ōkami"},
|
||||
{"in": "Zelda: Ocarina of Time", "out": "zelda ocarina of time"}
|
||||
],
|
||||
"store_labels": {
|
||||
"steam": "Steam", "custom": "Custom", "heroic": "Heroic", "lutris": "Lutris",
|
||||
"epic": "Epic", "gog": "GOG", "xbox": "Xbox", "weird": "Game", "": "Game"
|
||||
},
|
||||
"sort_keys": [
|
||||
{"stored": "host", "key": "host"},
|
||||
{"stored": "title", "key": "title"},
|
||||
{"stored": "platform", "key": "platform"},
|
||||
{"stored": "store", "key": "store"},
|
||||
{"stored": "", "key": "host"},
|
||||
{"stored": "bogus", "key": "host"},
|
||||
{"stored": "Title", "key": "host"}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "host order, ungrouped, is byte-identical to the shelf",
|
||||
"sort": "host", "group_by": null,
|
||||
"expect": [
|
||||
{"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]},
|
||||
{"kind": "platform", "name": "All", "label": "All", "ids": ["steam:292030", "steam:1222700", "custom:zelda-oot", "custom:sm64", "custom:gow3", "custom:eclair", "gog:witcher", "weird:odyssey", "custom:the", "steam:620", "steam:lower", "custom:gow-dup"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "A–Z folds articles and diacritics; equal keys keep host order; launchers keep host order",
|
||||
"sort": "title", "group_by": null,
|
||||
"expect": [
|
||||
{"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]},
|
||||
{"kind": "platform", "name": "All", "label": "All", "ids": ["custom:eclair", "custom:gow3", "custom:gow-dup", "steam:lower", "weird:odyssey", "steam:620", "custom:sm64", "custom:the", "steam:1222700", "gog:witcher", "steam:292030", "custom:zelda-oot"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "group by platform: platform-less store games bucket under their STORE, a generic-store title under Other, groups A–Z by label",
|
||||
"sort": "host", "group_by": "platform",
|
||||
"expect": [
|
||||
{"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]},
|
||||
{"kind": "store", "name": "GOG", "label": "GOG", "ids": ["gog:witcher"]},
|
||||
{"kind": "platform", "name": "N64", "label": "N64", "ids": ["custom:zelda-oot", "custom:sm64"]},
|
||||
{"kind": "platform", "name": "Other", "label": "Other", "ids": ["weird:odyssey"]},
|
||||
{"kind": "platform", "name": "PC", "label": "PC", "ids": ["steam:620"]},
|
||||
{"kind": "platform", "name": "PS3", "label": "PS3", "ids": ["custom:gow3", "custom:eclair", "custom:the", "custom:gow-dup"]},
|
||||
{"kind": "store", "name": "Steam", "label": "Steam", "ids": ["steam:292030", "steam:1222700", "steam:lower"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "group by platform, sorted A–Z within each group",
|
||||
"sort": "title", "group_by": "platform",
|
||||
"expect": [
|
||||
{"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]},
|
||||
{"kind": "store", "name": "GOG", "label": "GOG", "ids": ["gog:witcher"]},
|
||||
{"kind": "platform", "name": "N64", "label": "N64", "ids": ["custom:sm64", "custom:zelda-oot"]},
|
||||
{"kind": "platform", "name": "Other", "label": "Other", "ids": ["weird:odyssey"]},
|
||||
{"kind": "platform", "name": "PC", "label": "PC", "ids": ["steam:620"]},
|
||||
{"kind": "platform", "name": "PS3", "label": "PS3", "ids": ["custom:eclair", "custom:gow3", "custom:gow-dup", "custom:the"]},
|
||||
{"kind": "store", "name": "Steam", "label": "Steam", "ids": ["steam:lower", "steam:1222700", "steam:292030"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sort by store, ungrouped: store label bytewise (GOG before Game), then title, then host order",
|
||||
"sort": "store", "group_by": null,
|
||||
"expect": [
|
||||
{"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]},
|
||||
{"kind": "platform", "name": "All", "label": "All", "ids": ["custom:eclair", "custom:gow3", "custom:gow-dup", "custom:sm64", "custom:the", "custom:zelda-oot", "gog:witcher", "weird:odyssey", "steam:lower", "steam:620", "steam:1222700", "steam:292030"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "group by store, sorted by platform within: raw platform string first (empty leads), then title",
|
||||
"sort": "platform", "group_by": "store",
|
||||
"expect": [
|
||||
{"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]},
|
||||
{"kind": "store", "name": "Custom", "label": "Custom", "ids": ["custom:sm64", "custom:zelda-oot", "custom:eclair", "custom:gow3", "custom:gow-dup", "custom:the"]},
|
||||
{"kind": "store", "name": "GOG", "label": "GOG", "ids": ["gog:witcher"]},
|
||||
{"kind": "store", "name": "Game", "label": "Game", "ids": ["weird:odyssey"]},
|
||||
{"kind": "store", "name": "Steam", "label": "Steam", "ids": ["steam:lower", "steam:1222700", "steam:292030", "steam:620"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sort by platform, ungrouped",
|
||||
"sort": "platform", "group_by": null,
|
||||
"expect": [
|
||||
{"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]},
|
||||
{"kind": "platform", "name": "All", "label": "All", "ids": ["steam:lower", "weird:odyssey", "steam:1222700", "gog:witcher", "steam:292030", "custom:sm64", "custom:zelda-oot", "steam:620", "custom:eclair", "custom:gow3", "custom:gow-dup", "custom:the"]}
|
||||
]
|
||||
}
|
||||
],
|
||||
"filtered": [
|
||||
{"name": "a platform filter yields that group's games only", "sort": "host", "filter": {"kind": "platform", "name": "PS3"}, "expect": ["custom:gow3", "custom:eclair", "custom:the", "custom:gow-dup"]},
|
||||
{"name": "a store filter groups by store and sorts within", "sort": "title", "filter": {"kind": "store", "name": "Steam"}, "expect": ["steam:lower", "steam:620", "steam:1222700", "steam:292030"]},
|
||||
{"name": "a filter naming a group that does not exist yields nothing, never everything", "sort": "host", "filter": {"kind": "platform", "name": "SNES"}, "expect": []},
|
||||
{"name": "a filter of the wrong KIND for the group is not the group", "sort": "host", "filter": {"kind": "platform", "name": "Steam"}, "expect": []},
|
||||
{"name": "the launchers filter is the launcher group", "sort": "title", "filter": {"kind": "launchers"}, "expect": ["steam:launcher", "heroic:launcher"]},
|
||||
{"name": "no filter flattens every group, launchers first", "sort": "host", "filter": null, "expect": ["steam:launcher", "heroic:launcher", "steam:292030", "steam:1222700", "custom:zelda-oot", "custom:sm64", "custom:gow3", "custom:eclair", "gog:witcher", "weird:odyssey", "custom:the", "steam:620", "steam:lower", "custom:gow-dup"]},
|
||||
{"name": "no filter, A–Z", "sort": "title", "filter": null, "expect": ["steam:launcher", "heroic:launcher", "custom:eclair", "custom:gow3", "custom:gow-dup", "steam:lower", "weird:odyssey", "steam:620", "custom:sm64", "custom:the", "steam:1222700", "gog:witcher", "steam:292030", "custom:zelda-oot"]}
|
||||
],
|
||||
"worth_browsing": [
|
||||
{"name": "the whole library", "ids": null, "expect": true},
|
||||
{"name": "a Steam-only library with no platform metadata is ONE group", "ids": ["steam:launcher", "steam:292030", "steam:1222700"], "expect": false},
|
||||
{"name": "two stores are two groups", "ids": ["steam:292030", "gog:witcher"], "expect": true},
|
||||
{"name": "empty", "ids": [], "expect": false},
|
||||
{"name": "launchers alone", "ids": ["steam:launcher", "heroic:launcher"], "expect": false},
|
||||
{"name": "one platform", "ids": ["custom:zelda-oot", "custom:sm64"], "expect": false},
|
||||
{"name": "one platform plus its launcher", "ids": ["heroic:launcher", "custom:gow3", "custom:eclair"], "expect": false},
|
||||
{"name": "a platform and a platform-less store title", "ids": ["custom:gow3", "steam:292030"], "expect": true}
|
||||
]
|
||||
}
|
||||
@@ -56,8 +56,10 @@ sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
|
||||
sdl3 = { version = "0.18", features = ["hidapi", "ash", "build-from-source"] }
|
||||
|
||||
# The shared console parity vectors (`clients/shared/console-vectors.json`) are read by three
|
||||
# tests here — the palette table, the tab names and the transition motion. Dev-only: nothing in the
|
||||
# shipping crate parses JSON. `pf-client-core` reads its own deeplink vectors the same way.
|
||||
# tests here — the palette table, the tab names and the transition motion — and the library
|
||||
# collation vectors (`clients/shared/library-collate-vectors.json`) by a fourth (`collate.rs`).
|
||||
# Dev-only: nothing in the shipping crate parses JSON. `pf-client-core` reads its own deeplink
|
||||
# vectors the same way.
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
//! library collapsing into one "Unknown" heap, a fold that files "The Witcher" under T).
|
||||
//! Both are cheap to test here and expensive to notice on a TV.
|
||||
//!
|
||||
//! The spec is also MACHINE-READABLE: `clients/shared/library-collate-vectors.json` pins the
|
||||
//! groups these rules produce over one mixed library (`vectors_match_the_shared_file`), and the
|
||||
//! ports read the same file — change a rule here, regenerate the file in the same commit.
|
||||
//!
|
||||
//! Everything returns INDICES into the caller's slice. The screens' art cache, fetch pump
|
||||
//! and cursor arithmetic all key off the shared model's ordering, so a collation that
|
||||
//! handed back cloned games would fork the identity of every title in the shelf.
|
||||
@@ -439,4 +443,142 @@ mod tests {
|
||||
assert_eq!(SortKey::parse(""), SortKey::HostOrder);
|
||||
assert_eq!(SortKey::default(), SortKey::HostOrder);
|
||||
}
|
||||
|
||||
/// The cross-client parity file. `clients/shared/library-collate-vectors.json` pins, from
|
||||
/// this module's rules, the exact groups every (sort, group_by) must produce over one
|
||||
/// mixed library, the flat lists a filter yields, which libraries are worth browsing,
|
||||
/// the title fold, the store labels and the persisted sort ids — so the Apple and Android
|
||||
/// ports have a machine contract to read instead of prose to transcribe. This crate is
|
||||
/// the source of truth: a rule change here regenerates the file, never the other way.
|
||||
#[test]
|
||||
fn vectors_match_the_shared_file() {
|
||||
let raw = include_str!("../../../clients/shared/library-collate-vectors.json");
|
||||
let file: serde_json::Value =
|
||||
serde_json::from_str(raw).expect("library-collate-vectors.json must parse");
|
||||
assert_eq!(
|
||||
file["version"], 1,
|
||||
"bump the reader when the file's version moves"
|
||||
);
|
||||
|
||||
let games: Vec<LibraryGame> = file["library"]
|
||||
.as_array()
|
||||
.expect("library")
|
||||
.iter()
|
||||
.map(|e| LibraryGame {
|
||||
id: e["id"].as_str().expect("id").to_string(),
|
||||
title: e["title"].as_str().expect("title").to_string(),
|
||||
store: e["store"].as_str().expect("store").to_string(),
|
||||
launcher: e["role"].as_str() == Some("launcher"),
|
||||
icon: e["icon"].as_str().unwrap_or("").to_string(),
|
||||
platform: e["platform"].as_str().map(str::to_string),
|
||||
running: false,
|
||||
})
|
||||
.collect();
|
||||
let ids =
|
||||
|idx: &[usize]| -> Vec<&str> { idx.iter().map(|&i| games[i].id.as_str()).collect() };
|
||||
let str_list = |v: &serde_json::Value| -> Vec<String> {
|
||||
v.as_array()
|
||||
.expect("array")
|
||||
.iter()
|
||||
.map(|s| s.as_str().expect("string").to_string())
|
||||
.collect()
|
||||
};
|
||||
let key_of = |v: &serde_json::Value| -> GroupKey {
|
||||
let name = || v["name"].as_str().expect("name").to_string();
|
||||
match v["kind"].as_str().expect("kind") {
|
||||
"launchers" => GroupKey::Launchers,
|
||||
"platform" => GroupKey::Platform(name()),
|
||||
"store" => GroupKey::Store(name()),
|
||||
other => panic!("unknown group kind {other}"),
|
||||
}
|
||||
};
|
||||
let group_by = |v: &serde_json::Value| -> Option<GroupBy> {
|
||||
match v.as_str() {
|
||||
None => None,
|
||||
Some("platform") => Some(GroupBy::Platform),
|
||||
Some("store") => Some(GroupBy::Store),
|
||||
Some(other) => panic!("unknown group_by {other}"),
|
||||
}
|
||||
};
|
||||
|
||||
for case in file["sort_title"].as_array().expect("sort_title") {
|
||||
let input = case["in"].as_str().expect("in");
|
||||
assert_eq!(
|
||||
sort_title(input),
|
||||
case["out"].as_str().expect("out"),
|
||||
"sort_title({input:?})"
|
||||
);
|
||||
}
|
||||
for (store, label) in file["store_labels"].as_object().expect("store_labels") {
|
||||
assert_eq!(
|
||||
store_label(store),
|
||||
label.as_str().expect("label"),
|
||||
"store_label({store:?})"
|
||||
);
|
||||
}
|
||||
for case in file["sort_keys"].as_array().expect("sort_keys") {
|
||||
let stored = case["stored"].as_str().expect("stored");
|
||||
assert_eq!(
|
||||
SortKey::parse(stored).id(),
|
||||
case["key"].as_str().expect("key"),
|
||||
"SortKey::parse({stored:?})"
|
||||
);
|
||||
}
|
||||
|
||||
for case in file["cases"].as_array().expect("cases") {
|
||||
let name = case["name"].as_str().expect("name");
|
||||
let sort = SortKey::parse(case["sort"].as_str().expect("sort"));
|
||||
let got = collate(&games, sort, group_by(&case["group_by"]));
|
||||
let want = case["expect"].as_array().expect("expect");
|
||||
assert_eq!(got.len(), want.len(), "{name}: group count");
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
assert_eq!(g.key, key_of(w), "{name}: group key");
|
||||
assert_eq!(
|
||||
g.label,
|
||||
w["label"].as_str().expect("label"),
|
||||
"{name}: label"
|
||||
);
|
||||
assert_eq!(
|
||||
ids(&g.games),
|
||||
str_list(&w["ids"]),
|
||||
"{name}: {} ids",
|
||||
g.label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for case in file["filtered"].as_array().expect("filtered") {
|
||||
let name = case["name"].as_str().expect("name");
|
||||
let sort = SortKey::parse(case["sort"].as_str().expect("sort"));
|
||||
let filter = (!case["filter"].is_null()).then(|| key_of(&case["filter"]));
|
||||
assert_eq!(
|
||||
ids(&filtered(&games, sort, filter.as_ref())),
|
||||
str_list(&case["expect"]),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
|
||||
for case in file["worth_browsing"].as_array().expect("worth_browsing") {
|
||||
let name = case["name"].as_str().expect("name");
|
||||
let subset: Vec<LibraryGame> = match case["ids"].as_array() {
|
||||
None => games.clone(),
|
||||
Some(want) => want
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let id = id.as_str().expect("id");
|
||||
games
|
||||
.iter()
|
||||
.find(|g| g.id == id)
|
||||
.unwrap_or_else(|| panic!("{name}: unknown id {id}"))
|
||||
.clone()
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
assert_eq!(
|
||||
worth_browsing(&subset),
|
||||
case["expect"].as_bool().expect("expect"),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user