The Apple client catches up with the console-UI overhaul: grid, sort, Collections, the v2 motion contract #293

Merged
enricobuehler merged 12 commits from worktree-apple-console-parity-sweep into main 2026-08-18 11:31:25 +00:00
35 changed files with 3943 additions and 266 deletions
@@ -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 ·
// AZ · 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.753, columns = what fits, clamped 28, and the columns
// then stretch to fill the field's width. A Deck-shaped window gets 7, an iPad in landscape 67,
// 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.753; columns = what
/// fits, clamped 28.
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 · AZ · 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 26 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 AZ, 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"
/// AZ, case- and diacritic-relaxed, with the leading article folded away.
case title = "title"
/// Platform label AZ, then title within it.
case platform = "platform"
/// Store label AZ, 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 "AZ"
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 AZ. "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 AZ 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"` AZ; `"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)
}
}
+1 -1
View File
@@ -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,
+137
View File
@@ -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": "AZ 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 AZ 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 AZ 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, AZ", "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}
]
}
+4 -2
View File
@@ -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"
+142
View File
@@ -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}"
);
}
}
}