diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 9e039b68..cf89d7f9 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -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( diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 128980f6..07615ec7 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -40,6 +40,10 @@ struct GamepadCarousel: 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: 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: 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: 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: 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 } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 8c1d7ed6..c988820d 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift index 66d67ece..9bc0ad69 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift index 3ab10a9c..06f9e048 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift index 48b520d1..f5c0e5be 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift @@ -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 { diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift new file mode 100644 index 00000000..689c09eb --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift @@ -0,0 +1,123 @@ +// The gamepad library's live view/sort bar — the desktop console's `screens/library.rs` bar, in +// SwiftUI: one band across the top of the field with two anchored pill groups, `SORT` (Default · +// A–Z · Platform · Store) leading and `VIEW` (Shelf · Grid) trailing. Both controls apply by +// WRITING THE SETTING (`LibraryConsoleView` owns the keys), so the field re-sorts / re-arranges +// live under the strip and the Interface settings row can never disagree with it. +// +// Focus is the WHOLE bar: an accent wash over the pill row — no border, no halo, no glass. The +// C7 note on the desktop's wash being taller than the row it highlighted is the trap here: wash +// the pill row's extent, not the band's. On Apple the bar is a TRAY the container slides down over +// the field on ▲ (a fixed band ate a third of a landscape phone), so it is only ever drawn focused; +// while down ◀▶ step the sort (clamped, no wrap), L1/R1 pick the arrangement outright, and +// ▼ / A / B hand the controller back — that routing lives in the container, this view only draws. +// Pills are tappable/clickable where there is a pointer, and focusable Buttons on tvOS. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) || os(tvOS) + +struct LibraryBarView: View { + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + private var ink: GamepadInk { .stored(paletteID) } + @Environment(\.gamepadMetrics) private var metrics + let sort: LibrarySortKey + let arrangement: LibraryArrangement + /// The bar owns the controller (▲ from the field). Draws the wash. + let focused: Bool + /// The Collections screen shows the SORT group alone (its arrangement is the tiles). + var showsView = true + var compact = false + var onSort: (LibrarySortKey) -> Void + var onArrangement: (LibraryArrangement) -> Void + @Namespace private var sortHighlight + @Namespace private var viewHighlight + + /// The band's height, matching the settings tab strip's rhythm. + static let bandHeight: CGFloat = 46 + /// The air under the band before the field. + static let gap: CGFloat = 12 + + var body: some View { + // One row where it fits (every landscape screen); a portrait phone gets the two groups + // stacked rather than a row spilling off the edge. + ViewThatFits(in: .horizontal) { + HStack(spacing: 0) { + sortGroup + Spacer(minLength: 12) + if showsView { viewGroup } + } + VStack(alignment: .leading, spacing: 6) { + sortGroup + if showsView { viewGroup } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 5) + // The focus wash: the pill row's extent, corner 14, accent at 14 % — reads as "this row + // has the controller" without pretending to be a panel. + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(ink.accent(0.14)) + .opacity(focused ? 1 : 0) + } + .animation(.smooth(duration: 0.18), value: focused) + .padding(.horizontal, 12) + .frame(minHeight: Self.bandHeight) + } + + private var sortGroup: some View { + group(caption: "SORT") { + ForEach(LibrarySortKey.all, id: \.self) { key in + pill(key.label, selected: key == sort, namespace: sortHighlight) { onSort(key) } + } + } + } + + private var viewGroup: some View { + group(caption: "VIEW") { + ForEach(LibraryArrangement.all, id: \.self) { view in + pill(view.label, selected: view == arrangement, namespace: viewHighlight) { + onArrangement(view) + } + } + } + } + + /// A tracked small-caps caption followed by its pills. + private func group(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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift new file mode 100644 index 00000000..daffbc02 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift @@ -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.. 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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift new file mode 100644 index 00000000..89557e48 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index a421887b..3186f2a3 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift new file mode 100644 index 00000000..ef2bf70e --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift @@ -0,0 +1,537 @@ +// The gamepad library's GRID arrangement — the desktop console's grid (`screens/library.rs`), +// in SwiftUI: 2:3 poster cells in rows that scroll vertically behind a viewport (a move scrolls +// only as far as it must to keep the focused cell in view; a restored cursor is seated a third of +// the way down), the launcher rows sitting squarely above the game rows with a heading between. Every cell is equally readable (no coverflow recede); focus is a ×1.06 pop, an +// accent ring OUTSIDE the cover, and the shadow. The `Resume` badge keeps the coverflow's corner — +// the same badge in two different corners on two arrangements of the same shelf would read as +// two different badges. +// +// The cursor is `LibraryGridCursor` over ONE `LibraryGridShape` that this view builds from what +// it actually laid out (the column count is a render fact, published before navigation is +// allowed — a move that arrives before the first layout is declined rather than guessed). ◀▶ +// walk the row and refuse at its true ends with a bump; ▲▼ change row carrying the remembered +// column; L1/R1 page three rows and land on the ends; ▲ from the TOP row hands the controller to +// the bar. The detail band, legend and backdrop are `LibraryConsoleView`'s. +// +// Sizing follows the desktop's `k`: cells are 150×225 design units, gap 16, margin 48, scaled by +// `min(width, height) / 800` clamped to 0.75…3, columns = what fits, clamped 2…8, and the columns +// then stretch to fill the field's width. A Deck-shaped window gets 7, an iPad in landscape 6–7, +// a phone in landscape 7, an Apple TV 8. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) || os(tvOS) +import GameController + +struct LibraryGridView: View { + @AppStorage(DefaultsKey.uiPalette) private var paletteID = "violet" + private var ink: GamepadInk { .stored(paletteID) } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// The shelf in DISPLAY order — collated by the container (launchers lead, then the sort). + let games: [GameEntry] + let artLoader: (any LibraryArtSource)? + /// The focused title, published for the container's detail band and legend. + @Binding var focusID: String? + var onLaunch: ((String) -> Void)? + var running: [String: RunningGame] = [:] + /// The title to open on — see `LibraryCoverflowView.initialSelection`. + var initialSelection: String? + var onBack: (() -> Void)? + var onSecondary: (() -> Void)? + var onTertiary: (() -> Void)? + /// ▲ from the top row: hand the controller to the bar. + var onUp: (() -> Void)? + var controllerActive = true + + @State private var input = GamepadMenuInput(manager: .shared) + @State private var haptics = MenuHaptics(manager: .shared) + #if os(tvOS) + /// tvOS: the focus engine is the navigation authority — `focusID` chases it. + @FocusState private var tvFocus: String? + #endif + /// The column the user last CHOSE (a horizontal move), carried across vertical ones. + @State private var colHint = 0 + /// Columns as laid out — 0 until the first layout pass, during which navigation declines. + @State private var cols = 0 + /// Boundary recoil, deflected along the axis pushed. + @State private var bump = CGSize.zero + @State private var boundaryTick = 0 + @State private var activateTick = 0 + /// The field's entrance, one timeline (see GamepadCarousel's `entranceProgress`). + @State private var entranceProgress: Double = 0 + @State private var entranceArmed = false + @State private var entranceAnchor = 0 + @State private var artSettled = 0 + @State private var artWaitOver = false + /// The first scroll on mount is seated (no animation), later ones are sprung. + @State private var seated = false + /// The field's offset — ONE sprung scalar, this view's own (see `body`). + @State private var scrollY: CGFloat = 0 + /// Where the last scroll started, so rows stay rendered across the whole travel. + @State private var prevScrollY: CGFloat = 0 + /// A touch drag's starting offset (iOS). + @State private var dragStart: CGFloat? + + /// Launchers lead by construction (`LibraryOrder` / `LibraryCollation`), so the launcher run + /// is a prefix. + private var launcherCount: Int { games.prefix { $0.isLauncher }.count } + private var shape: LibraryGridShape { + LibraryGridShape(len: games.count, cols: cols, launchers: launcherCount) + } + private var cursor: Int { + guard let id = focusID, let i = games.firstIndex(where: { $0.id == id }) else { return 0 } + return i + } + private var contentReady: Bool { + artWaitOver || artSettled >= min(6, games.count) + } + + var body: some View { + GeometryReader { geo in + let g = GridGeometry(size: geo.size, len: games.count, launchers: launcherCount) + // The field: every row at its exact place, the whole thing shifted by `scrollY`, and + // clipped to the viewport. NOT a ScrollView — this view owns the offset. A + // ScrollView-driven grid needed `scrollTo` against a lazy layout, and on glass one + // step down read as TWO impulses (the scroll settled, then moved again as the lazy + // rows laid out under it). All of this geometry is fixed and known, so the offset is + // computed and sprung here, exactly as the desktop console does it — and rows are + // culled by the same arithmetic instead of by a lazy container's guess. + ZStack(alignment: .topLeading) { + heading(g.shape.split > 0 ? "LAUNCHERS" : nil, height: g.headingH, k: g.k) + .padding(.horizontal, g.margin) + if g.shape.split > 0 { + heading("GAMES", height: g.headingH, k: g.k) + .padding(.horizontal, g.margin) + .offset(y: g.gamesHeadingTop) + } + ForEach(0.. some View { + let start = g.shape.rowStart(r) + let n = g.shape.rowLen(r) + return HStack(alignment: .top, spacing: g.gap) { + ForEach(start..<(start + n), id: \.self) { index in + let game = games[index] + #if os(tvOS) + // A focusable Button per cell: the focus engine does the navigating (remote + // swipes and pad dpad alike — a Siri Remote is no extended gamepad, so the poll + // above never sees it), select activates. The bare style keeps the cell's own + // look; the ring + pop below is the focus treatment, since `focusID` chases focus. + Button { activate() } label: { + cell(game, index: index, width: g.cellW, height: g.cellH, k: g.k, shape: g.shape) + } + .buttonStyle(ConsoleBareButtonStyle()) + .focused($tvFocus, equals: game.id) + #else + cell(game, index: index, width: g.cellW, height: g.cellH, k: g.k, shape: g.shape) + #endif + } + } + // Rendered rows are keyed on their index so a row that scrolls out and back in remounts + // fresh (its posters re-request from the loader's cache), like the desktop's cull. + .id("row-\(r)") + } + + /// A heading band: leading, tracked, `fg(0.45)` — or the same band empty (the top inset). + /// The caption sits mid-band: the air on either side is what a focused cell's ×1.06 pop and + /// its ring rise into, from the row above as much as the row below, so a heading never + /// collides with either. + private func heading(_ text: String?, height: CGFloat, k: CGFloat) -> some View { + HStack { + if let text { + // Captions scale with the field but never below legibility. + Text(text) + .font(.geist(max(9, 11 * k), .semibold, relativeTo: .caption2)) + .tracking(1.4) + .foregroundStyle(ink.fg(0.45)) + } + } + .frame(height: height, alignment: .leading) + } + + private func cell( + _ game: GameEntry, index: Int, width: CGFloat, height: CGFloat, k: CGFloat, + shape: LibraryGridShape + ) -> some View { + let focused = game.id == focusID + let corner = 12 * k + return PosterImage( + candidates: game.art.posterCandidates, title: game.title, loader: artLoader, + icon: game.iconToken, + drawnSize: CGSize(width: width, height: height), + onLoaded: { artSettled += 1 }) + .frame(width: width, height: height) + .clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous)) + .overlay(alignment: .topTrailing) { + if running[game.id] != nil { RunningBadge(solid: true) } + } + .overlay { + RoundedRectangle(cornerRadius: corner, style: .continuous) + .strokeBorder(ink.fg(0.12), lineWidth: 1) + } + // The focus ring goes OUTSIDE the cover, so it reads as a ring around it rather than + // a border painted onto it. + .overlay { + RoundedRectangle(cornerRadius: corner + 3, style: .continuous) + .inset(by: -3) + .strokeBorder(ink.accent(0.9), lineWidth: 2) + .opacity(focused ? 1 : 0) + } + .shadow(color: ink.shadow(focused ? 0.5 : 0.22), radius: focused ? 14 : 8, y: focused ? 10 : 5) + .scaleEffect(focused ? 1.06 : 1) + // `springs::FOCUS` — the pop, with a whisker of overshoot. + .animation( + reduceMotion ? .easeOut(duration: 0.12) : .spring(response: 0.30, dampingFraction: 0.80), + value: focused) + .modifier(entrance(index: index, shape: shape)) + .zIndex(focused ? 1 : 0) + #if !os(tvOS) + // Pointer/touch: a press on the focused cell activates, any other only brings it to + // front — the carousel's rule. + .contentShape(RoundedRectangle(cornerRadius: corner, style: .continuous)) + .onTapGesture { + if focused { activate() } else { focus(index) } + } + #endif + } + + // MARK: - Entrance + + private func armEntrance() { + guard !entranceArmed, contentReady, !games.isEmpty else { return } + entranceArmed = true + entranceAnchor = cursor + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + withAnimation( + reduceMotion ? .easeOut(duration: 0.28) : .linear(duration: CardEntrance.total) + ) { + entranceProgress = 1 + } + } + } + + /// The cell's share of the field's entrance, fanning on `|Δrow| + |Δcol|` from the focused + /// cell (the desktop's grid rule) — the same stagger and cap the strip uses. + private func entrance(index: Int, shape: LibraryGridShape) -> GridCellEntrance { + let a = shape.cell(of: min(entranceAnchor, max(shape.len - 1, 0))) + let c = shape.cell(of: index) + let distance = abs(a.row - c.row) + abs(a.col - c.col) + let delay = min(CardEntrance.maxDelay, Double(distance) * 0.12) + return GridCellEntrance( + progress: entranceProgress, start: delay / CardEntrance.total, reduceMotion: reduceMotion) + } + + // MARK: - Input + + private func seed() { + guard !games.isEmpty else { return } + if let id = focusID, games.contains(where: { $0.id == id }) { + colHint = shape.cell(of: cursor).col + return + } + if let id = initialSelection, games.contains(where: { $0.id == id }) { + focusID = id + } else { + focusID = games[0].id + } + colHint = shape.cell(of: cursor).col + } + + private func wire() { + input.onMove = { move($0) } + input.onConfirm = { activate() } + input.onSecondary = onSecondary + input.onTertiary = onTertiary + input.onBack = onBack + input.onShoulder = { right in page(forward: right) } + } + + private func move(_ direction: GamepadMenuInput.Direction) { + let dir: LibraryGridDirection + switch direction { + case .left: dir = .left + case .right: dir = .right + case .up: dir = .up + case .down: dir = .down + } + step(dir) + } + + private func page(forward: Bool) { + step(forward ? .pageForward : .pageBack) + } + + private func step(_ dir: LibraryGridDirection) { + // No layout yet: decline rather than guess (the desktop's `grid_cols_last`). + guard cols > 0, !games.isEmpty else { return } + let s = shape + switch LibraryGridCursor.step(cursor, shape: s, colHint: colHint, direction: dir) { + case .moved(let to): + colHint = LibraryGridCursor.colHint(shape: s, previous: colHint, direction: dir, landed: to) + haptics.move() + focusID = games[to].id + case .boundary: + // ▲ off the top row is the way to the bar, not a wall. + if dir == .up, s.cell(of: cursor).row == 0, let onUp { + onUp() + return + } + boundaryBump(dir) + } + } + + private func focus(_ index: Int) { + guard games.indices.contains(index) else { return } + colHint = shape.cell(of: index).col + haptics.move() + focusID = games[index].id + } + + private func activate() { + guard let id = focusID, games.contains(where: { $0.id == id }) else { return } + activateTick &+= 1 + haptics.confirm() + onLaunch?(id) + } + + /// The recoil is deflected along the axis pushed — horizontal shifts x, vertical shifts the + /// scroll (here, the field). Travel dropped under Reduce Motion, haptic kept. + private func boundaryBump(_ dir: LibraryGridDirection) { + boundaryTick &+= 1 + haptics.boundary() + guard !reduceMotion else { return } + let recoil: CGSize + switch dir { + case .left: recoil = CGSize(width: 16, height: 0) + case .right: recoil = CGSize(width: -16, height: 0) + case .up, .pageBack: recoil = CGSize(width: 0, height: 16) + case .down, .pageForward: recoil = CGSize(width: 0, height: -16) + } + withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bump = recoil } + withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bump = .zero } + } +} + +/// The grid's geometry, computed once per layout pass from the viewport — every number the field +/// and the cursor share. Cells are 150×225 design units, gap 16, margin 48, heading band 30, a +/// 10-unit label gap under each row, all × `k = min(w, h)/800` clamped 0.75…3; columns = what +/// fits, clamped 2…8. +private struct GridGeometry { + let k: CGFloat + let cellW: CGFloat, cellH: CGFloat, gap: CGFloat, margin: CGFloat + let headingH: CGFloat, labelGap: CGFloat + /// The air a focused cell's ring and ×1.06 pop need past its frame. + let halo: CGFloat + let cols: Int + let shape: LibraryGridShape + let viewH: CGFloat + + init(size: CGSize, len: Int, launchers: Int) { + // The desktop's k, floored at 0.75 (its smallest field is a Deck's 800): on a phone the + // covers stay poster-sized rather than thumbnails, and the field scrolls. + k = min(max(min(size.width, size.height) / 800, 0.75), 3) + gap = 16 * k; margin = 48 * k + // The heading band never drops below what a caption plus a focused cell's pop and ring + // need on either side of it (the caption sits mid-band). + headingH = max(32, 30 * k); labelGap = 10 * k; halo = max(8, 10 * k) + let avail = size.width - 2 * margin + let base = 150 * k + cols = min(max(Int((avail + gap) / (base + gap)), 2), 8) + // The columns FILL the field: whatever width the last cell would have left over is + // shared out, so the grid spans the safe area on every screen instead of stopping short + // of the right edge (on a phone the slack was a fifth of the width). Cells keep 2:3. + cellW = max(base, (avail - CGFloat(cols - 1) * gap) / CGFloat(cols)) + cellH = cellW * 1.5 + shape = LibraryGridShape(len: len, cols: cols, launchers: launchers) + viewH = size.height + } + + /// Row pitch: the cell, the gap, and the label air under it. + var pitch: CGFloat { cellH + gap + labelGap } + /// The GAMES heading's top: after the launcher rows. + var gamesHeadingTop: CGFloat { headingH + CGFloat(shape.splitRow) * pitch } + + /// A row's top edge. Row 0 sits under the (unconditional) top heading band; the game rows + /// under the launcher rows sit under a second band. + func rowTop(_ r: Int) -> CGFloat { + if shape.split > 0, r >= shape.splitRow { + return gamesHeadingTop + headingH + CGFloat(r - shape.splitRow) * pitch + } + return headingH + CGFloat(r) * pitch + } + + /// The whole field, plus a trailing band so the last row keeps its pop and ring at max scroll. + var contentH: CGFloat { + guard shape.rows > 0 else { return headingH * 2 } + return rowTop(shape.rows - 1) + cellH + labelGap + headingH + } + var maxScroll: CGFloat { max(0, contentH - viewH) } + + /// Whether a row is drawn: it overlaps the span the field is travelling across (from the + /// previous offset to the current one), with one row of look-ahead on either side. + func isNear(row r: Int, scroll: CGFloat, previous: CGFloat) -> Bool { + let lo = min(scroll, previous) - pitch + let hi = max(scroll, previous) + viewH + pitch + let top = rowTop(r) + return top < hi && top + cellH > lo + } + + /// The offset that keeps `row` (ring and pop included) inside the viewport, moving no + /// further than it must from `current`. + func reveal(row r: Int, from current: CGFloat) -> CGFloat { + let top = rowTop(r) - halo + let bottom = rowTop(r) + cellH + halo + var y = current + if top < y { y = top } + if bottom > y + viewH { y = bottom - viewH } + return min(max(y, 0), maxScroll) + } + + /// Where a restored cursor is SEATED on mount: a third of the way down the viewport (the + /// desktop's `view_h·0.34`), clamped; row 0 is simply the top. + func seat(row r: Int) -> CGFloat { + guard r > 0 else { return 0 } + return min(max(rowTop(r) - viewH * 0.34, 0), maxScroll) + } +} + +/// How a grid cell arrives when its field does: small, low and invisible, then it grows and rises +/// into place on a spring soft enough to overshoot — the coverflow's `CardEntrance` without the +/// turn (the desktop's grid entrance has rise, scale and fade; the turn is the shelf's alone). +struct GridCellEntrance: ViewModifier, Animatable { + var progress: Double + let start: Double + let reduceMotion: Bool + + var animatableData: Double { + get { progress } + set { progress = newValue } + } + + func body(content: Content) -> some View { + let span = CardEntrance.perCard / CardEntrance.total + let raw = min(max((progress - start) / span, 0), 1) + let travel = Self.easeOutBack(raw) + let fade = Self.easeOut(min(raw / 0.34, 1)) + let away = reduceMotion ? 0 : 1 - travel + return content + .opacity(reduceMotion ? raw : fade) + .scaleEffect(1 - 0.26 * away) + .offset(y: 34 * away) + } + + private static func easeOutBack(_ t: Double) -> Double { + let c1 = 1.2, c3 = c1 + 1 + let u = t - 1 + return 1 + c3 * u * u * u + c1 * u * u + } + + private static func easeOut(_ t: Double) -> Double { + 1 - pow(1 - t, 3) + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryTitleOptionsView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryTitleOptionsView.swift new file mode 100644 index 00000000..c580044b --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryTitleOptionsView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 2e44cc30..3652d000 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -68,6 +68,16 @@ struct LibraryView: View { /// and while the connect takeover is up. Presentations that cover the launcher keep the /// default (their being up IS the launcher's gate). var controllerActive = true + /// The collection the gamepad shelf is drilled into (its label), or nil — reported so a host + /// screen (GamepadLibraryScreen's pinned title) can read `host · profile · collection`. + var onCollectionChanged: ((String?) -> Void)? + /// The same, for this view's own navigation title (the sheet/cover presentations). + @State private var collectionLabel: String? + /// The touch grid's sort (the shared `library_sort` key, the same one the console's bar + /// writes) and its grouping (touch-only — sections are the touch analogue of the console's + /// Collections place). + @AppStorage(DefaultsKey.librarySort) private var sortRaw = "" + @AppStorage(DefaultsKey.libraryGroupBy) private var groupByRaw = "" @Environment(\.dismiss) private var dismiss /// Resolves a pinned shelf's profile NAME for the title (the target carries only its id). @ObservedObject private var profiles = ProfileStore.shared @@ -118,15 +128,23 @@ struct LibraryView: View { var body: some View { content - .navigationTitle("\(target.title(in: profiles)) — Library") + .navigationTitle("\(shelfTitle) — Library") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { #if os(macOS) - ToolbarItemGroup { reloadButton } + ToolbarItemGroup { + if !gamepadUIActive { sortMenu } + reloadButton + } #else ToolbarItem(placement: .primaryAction) { reloadButton } + // The console presentation carries its own sort/view bar; the plain grid gets a + // menu in the bar it already has. + if !gamepadUIActive { + ToolbarItem(placement: .primaryAction) { sortMenu } + } #endif // A gamepad-only user can't swipe-to-dismiss the sheet this view is presented in // (ContentView's `.sheet(item: $libraryTarget)`) — give it a focusable, dpad-reachable @@ -152,7 +170,12 @@ struct LibraryView: View { // (the gamepad screens carry no close chrome). .background { if gamepadUIActive && games.isEmpty { - LibraryBackCatcher(active: controllerActive) { (onClose ?? { dismiss() })() } + LibraryBackCatcher( + active: controllerActive, + // A = the on-screen Retry button, only while there is an error to retry + // (a press during the load itself would start a second fetch). + onConfirm: errorText != nil && !loading ? { Task { await load() } } : nil, + onBack: { (onClose ?? { dismiss() })() }) } } #endif @@ -172,10 +195,10 @@ struct LibraryView: View { /// and never as an error: a cached library is a working library, and a host that is still /// waking is the case this whole path exists to serve. @ViewBuilder private var staleNote: some View { - if servedFromCacheAt != nil { + if let text = staleness.text { HStack(spacing: 6) { - Image(systemName: loading ? "arrow.clockwise" : "wifi.slash") - Text(loading ? "Waking the host…" : "Showing this host's last known library") + Image(systemName: staleness.symbol) + Text(text) } .font(.geist(12, relativeTo: .caption)) .foregroundStyle(.secondary) @@ -196,14 +219,24 @@ struct LibraryView: View { consoleField(emptyState) } else { if gamepadUIActive { - LibraryCoverflowView( + LibraryConsoleView( games: ordered, artLoader: artLoader, onLaunch: launchAndRemember, running: running, + staleness: staleness, + // The title last opened from this shelf — the coverflow assembles around it, + // the way the plain grid scrolls back to it, so the round trip browse → play → + // quit → browse lands where the player left rather than at the first cover. + initialSelection: LibraryScrollMemory.last(forHost: host.id.uuidString), onDismiss: { (onClose ?? { dismiss() })() }, // Nil where there is nothing to copy into (tvOS), which is what drops the - // hint from the legend rather than leaving a button that does nothing. + // Options row and its hint rather than leaving a menu with nothing in it. onCopyLink: LinkClipboard.isAvailable ? { copyLink($0) } : nil, - controllerActive: controllerActive) + hostName: host.displayName, + controllerActive: controllerActive, + onCollectionChanged: { label in + collectionLabel = label + onCollectionChanged?(label) + }) } else { // Above the grid rather than over it: the coverflow owns its whole surface and has // its own legend row, so the note rides the plain-grid presentation only. @@ -223,33 +256,73 @@ struct LibraryView: View { /// the titles land and the coverflow takes over. /// /// Only in gamepad mode: the plain grid's states belong on the system background, as before. + /// + /// In gamepad mode these states also carry the legend the coverflow carries — `A Retry` on an + /// error, `B Back` always — because a controller-only user on an error screen otherwise had + /// no visible way out (the desktop console shows the same two hints there). @ViewBuilder private func consoleField(_ view: some View) -> some View { #if os(iOS) || os(macOS) || os(tvOS) - view.background { - if gamepadUIActive, !hostedInShell { GamepadScreenBackground() } - } + view + .background { + if gamepadUIActive, !hostedInShell { GamepadScreenBackground() } + } + .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { + if gamepadUIActive { + GamepadHintBar(hints: stateHints) + .padding(.leading, 22) + .padding(.vertical, 10) + } + } #else view #endif } + #if os(iOS) || os(macOS) || os(tvOS) + /// The legend under the loading / error / empty states: A retries a failed fetch (the same + /// action as the on-screen Retry button), B backs out. Read at press time, like every hint. + private var stateHints: [GamepadHint] { + var hints: [GamepadHint] = [] + if errorText != nil, !loading { + hints.append(.init( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Retry", + action: { Task { await load() } })) + } + hints.append(.init( + glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Back", + action: { (onClose ?? { dismiss() })() })) + return hints + } + #endif + + /// The grid's sections: the catalog collated by the shared rules — launchers lead (design + /// D4), then one section per group under the chosen grouping (none = one section of games), + /// each in the chosen sort. Headers only when there is more than one section, so an + /// ungrouped, launcher-less library renders exactly as it always did. + private var sections: [(label: String, games: [GameEntry])] { + let groupBy: LibraryGroupBy? + switch groupByRaw { + case "platform": groupBy = .platform + case "store": groupBy = .store + default: groupBy = nil + } + return LibraryCollation.collate(ordered, sort: LibrarySortKey(stored: sortRaw), groupBy: groupBy) + .map { group in + // The ungrouped bucket names itself "All"; on this grid it has always been "Games". + let label = (groupBy == nil && group.key != .launchers) ? "Games" : group.label + return (label, group.indices.map { ordered[$0] }) + } + } + private var grid: some View { - // Design D4: launcher entries get their own section above the titles, never interleaved. - // Both headers appear only when both groups exist, so a library without launcher entries - // renders exactly as it did before. - let launchers = ordered.filter(\.isLauncher) - let titles = ordered.filter { !$0.isLauncher } - let both = !launchers.isEmpty && !titles.isEmpty + let sections = self.sections + let showsHeaders = sections.count > 1 return ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 18) { - if !launchers.isEmpty { - if both { sectionHeader("Launchers") } - tiles(launchers) - } - if !titles.isEmpty { - if both { sectionHeader("Games") } - tiles(titles) + ForEach(Array(sections.enumerated()), id: \.offset) { _, section in + if showsHeaders { sectionHeader(section.label) } + tiles(section.games) } } .padding() @@ -287,7 +360,7 @@ struct LibraryView: View { .gamepadKeyNavigation( active: onLaunch != nil, onMove: { direction in - guard let next = gridNav(launchers: launchers, titles: titles) + guard let next = gridNav(sections: sections.map(\.games)) .move(from: keyCursor, direction) else { return } keyCursor = next withAnimation(.easeOut(duration: 0.18)) { proxy.scrollTo(next, anchor: .center) } @@ -303,9 +376,9 @@ struct LibraryView: View { #if os(iOS) || os(macOS) /// The keyboard cursor's model over the two grid sections. Rebuilt per press from the live /// sections so it can never point into a stale list. - private func gridNav(launchers: [GameEntry], titles: [GameEntry]) -> LibraryGridNav { + private func gridNav(sections: [[GameEntry]]) -> LibraryGridNav { LibraryGridNav( - sections: [launchers, titles].filter { !$0.isEmpty }.map { $0.map(\.id) }, + sections: sections.filter { !$0.isEmpty }.map { $0.map(\.id) }, columns: columnCount) } @@ -427,9 +500,38 @@ struct LibraryView: View { .disabled(loading) } + /// Sort and group for the plain grid — the console's bar, as a menu. The sort is the shared + /// key (Default · A–Z · Platform · Store); the grouping is this grid's own (sections stand in + /// for the console's Collections place). + private var sortMenu: some View { + Menu { + Picker("Sort", selection: $sortRaw) { + ForEach(LibrarySortKey.all, id: \.stored) { key in + Text(key.label).tag(key.stored) + } + } + Picker("Group by", selection: $groupByRaw) { + Text("None").tag("") + Text("Platform").tag("platform") + Text("Store").tag("store") + } + } label: { + Label("Sort & group", systemImage: "line.3.horizontal.decrease.circle") + } + } + private func load() async { loading = true errorText = nil + // Dev hook, the twin of the desktop console's `PUNKTFUNK_FAKE_LIBRARY`: a file holding + // the host's `/api/v1/library` JSON (or the shared collate vectors file, whose `library` + // array is the same shape) stands in for the host, so the grid, the sort bar and the + // collections can be exercised on a Mac with no host at all. No wake, no cache, no + // `/status`, and no art — the posters are placeholders. + if let fake = ProcessInfo.processInfo.environment["PUNKTFUNK_FAKE_LIBRARY"], !fake.isEmpty { + loadFake(path: fake) + return + } let current = store.hosts.first { $0.id == host.id } ?? host // mTLS uses this client's persistent identity (the host paired it over QUIC). No identity // yet → the user hasn't connected/paired, which is also when there's nothing to browse. @@ -543,6 +645,29 @@ struct LibraryView: View { loading = false } + /// The `PUNKTFUNK_FAKE_LIBRARY` path: a plain `[GameEntry]` array, or a `{ "library": [...] }` + /// wrapper (the shared vectors file). A bad file reads as an error state, not a crash. + private func loadFake(path: String) { + defer { loading = false } + struct Wrapped: Decodable { let library: [GameEntry] } + servedFromCacheAt = nil + running = [:] + guard let data = FileManager.default.contents(atPath: path) else { + games = [] + errorText = "PUNKTFUNK_FAKE_LIBRARY: can't read \(path)" + return + } + let decoder = JSONDecoder() + if let list = try? decoder.decode([GameEntry].self, from: data) { + games = list.launchersFirst + } else if let wrapped = try? decoder.decode(Wrapped.self, from: data) { + games = wrapped.library.launchersFirst + } else { + games = [] + errorText = "PUNKTFUNK_FAKE_LIBRARY: \(path) is not a library JSON" + } + } + /// Every launch from this shelf goes through here, so the player's position is recorded on /// exactly one path however they picked the title — a tap, the keyboard, or the coverflow. /// `nil` in browse-only mode, which is what keeps the tiles untappable there. @@ -554,23 +679,67 @@ struct LibraryView: View { } } - /// The catalog in display order: anything already running first, so getting back into it is the - /// first thing on the screen rather than something to scroll for. - /// - /// Applied on top of `launchersFirst` rather than instead of it — a launcher that is up still - /// belongs with the launchers. + /// `host` → `host · profile` (a pinned card's shelf) → `host · profile · collection` (drilled + /// into one group), joined with `·` — the desktop's title shape. + private var shelfTitle: String { + let base = target.title(in: profiles) + guard let collectionLabel else { return base } + return "\(base) \u{b7} \(collectionLabel)" + } + + /// The catalog in display order — `LibraryOrder.display`, the desktop's `order()`: launcher + /// entries lead, and anything already running leads WITHIN its band, so getting back into it + /// is the first thing on the screen rather than something to scroll for. (Its predecessor put + /// every running entry first, over `launchersFirst`, so a running game jumped ahead of the + /// launcher prefix and the coverflow's heading read GAMES · LAUNCHERS · GAMES along the strip.) private var ordered: [GameEntry] { guard !running.isEmpty else { return games } - return games.filter { running[$0.id] != nil } + games.filter { running[$0.id] == nil } + return LibraryOrder.display(games, running: Set(running.keys)) + } + + /// Whether the titles on screen are remembered rather than observed, and what the host is + /// doing about it — the three-state staleness both presentations show. Never an error: a + /// cached library is a working library. + private var staleness: LibraryStaleness { + guard servedFromCacheAt != nil else { return .none } + return loading ? .waking : .offline + } +} + +/// The catalog's provenance, as the shelf states it. Three states rather than a flag so "waking +/// the host…" can never be shown while nothing is happening — the same enum the desktop console +/// keeps (`Stale::{No, Waking, Offline}`), with its exact wording. +enum LibraryStaleness: Equatable { + case none + /// Served from disk; a fetch (and a wake) is in flight. + case waking + /// Served from disk; the host did not answer. + case offline + + /// The note the shelf shows, or nil when the titles are live. + var text: String? { + switch self { + case .none: return nil + case .waking: return "Last known library — waking the host…" + case .offline: return "Last known library — the host didn't answer" + } + } + + var symbol: String { + self == .waking ? "arrow.clockwise" : "wifi.slash" } } #if os(iOS) || os(macOS) -/// Zero-size controller listener for the library's pre-coverflow states — B backs out. The same -/// shape as ConnectOverlay's `ConnectControllerInput`; `GamepadMenuInput.needsSnapshot` swallows -/// the held press that opened the screen. Unmounts the moment the coverflow (and its own B) is up. +/// Zero-size controller listener for the library's pre-coverflow states — B backs out, A retries +/// a failed fetch. The same shape as ConnectOverlay's `ConnectControllerInput`; +/// `GamepadMenuInput.needsSnapshot` swallows the held press that opened the screen. Unmounts the +/// moment the coverflow (and its own A/B) is up. private struct LibraryBackCatcher: View { let active: Bool + /// nil while there is nothing to retry — the press then does nothing, exactly like the + /// legend, which shows no A cell in that state. + var onConfirm: (() -> Void)? let onBack: () -> Void @State private var input = GamepadMenuInput(manager: .shared) @@ -579,8 +748,11 @@ private struct LibraryBackCatcher: View { .frame(width: 0, height: 0) .onAppear { input.onBack = onBack + input.onConfirm = onConfirm if active { input.start() } } + // The retry closure comes and goes with the error; keep the poller's copy current. + .onChange(of: onConfirm != nil) { _, _ in input.onConfirm = onConfirm } .onChange(of: active) { _, nowActive in if nowActive { input.start() } else { input.stop() } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 75671bdb..49b82383 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -1,6 +1,7 @@ // Reusable library widgets, shared by the touch grid (LibraryView's `GameCard`) and the gamepad // coverflow (LibraryCoverflowView's cover cell). +import ImageIO import PunktfunkKit import SwiftUI #if canImport(UIKit) @@ -96,6 +97,36 @@ private extension Image { } } +/// Decode cover art at the size it will be DRAWN, not the size the CDN shipped. +/// +/// A Steam capsule is 600×900 (some custom art 1000×1500); decoded, that is 2–6 MB per poster +/// and stays resident for as long as its tile does. A coverflow holds a dozen; a grid on an iPad +/// Pro or an Apple TV holds forty, and Apple TV's memory ceiling is the lowest of the three. +/// This is the desktop console's own lesson (its grid was a slideshow until posters were decoded +/// at twice their cell size): `CGImageSourceCreateThumbnailAtIndex` decodes straight to a +/// bounded bitmap and never materialises the full-size one. `maxPixels` is the longer edge, in +/// PIXELS (the caller multiplies its point size by the screen scale, ×2 for headroom under the +/// focus pop). nil ⇒ decode as-is (the touch grid's tiles are small and few enough). +private func decodePoster(_ data: Data, maxPixels: Int?) -> PlatformImage? { + guard let maxPixels, maxPixels > 0 else { return PlatformImage(data: data) } + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixels, + ] + guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + // A format ImageIO can't thumbnail (rare) still gets the full decode rather than a hole. + return PlatformImage(data: data) + } + #if canImport(UIKit) + return UIImage(cgImage: cg) + #elseif canImport(AppKit) + return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)) + #endif +} + /// Sequentially tries cover-art URLs over `loader` (so a paired client can reach the host's own /// art proxy, not just public CDNs — see `LibraryArtLoader`), advancing past any that fail to /// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame @@ -111,12 +142,16 @@ struct PosterImage: View { /// The entry's brand-mark token (`GameEntry.iconToken`), when it has one. A launcher tile ships /// no cover art by design, so for those the mark IS the poster — see `placeholder`. var icon: String? + /// The size this poster is drawn at, in POINTS — the decode is bounded to twice its longer + /// edge in pixels (see `decodePoster`). nil decodes the art as shipped. + var drawnSize: CGSize? /// Fires once this poster has settled — art loaded, or every candidate exhausted and the /// placeholder is what it will be. The gamepad coverflow waits on a few of these before /// playing its entrance, so the cards swing in carrying artwork rather than grey rectangles. var onLoaded: (() -> Void)? @State private var index = 0 @State private var image: PlatformImage? + @Environment(\.displayScale) private var displayScale var body: some View { Group { @@ -159,8 +194,11 @@ struct PosterImage: View { onLoaded?() return } + // Twice the drawn edge: headroom for the focus pop and a Retina-crisp cover, without + // ever holding the CDN's 600×900 (or larger) bitmap for the life of the tile. + let maxPixels = drawnSize.map { Int(max($0.width, $0.height) * displayScale * 2) } guard let loader, let data = try? await loader.data(for: candidates[index]), - let loaded = PlatformImage(data: data) + let loaded = decodePoster(data, maxPixels: maxPixels) else { index += 1 // advance to the next candidate (or past the end → placeholder) return diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 9ac4ab4e..8852073e 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -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) } } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 57fc12c2..6ff1f94d 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -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", diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index ab8b19a7..0a7b13a7 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -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) + } + } } } } diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 0e64fce2..1e40e287 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift index 2dc974e2..ae9f16ae 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift index d2957a60..059118d5 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift @@ -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" } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryCollation.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryCollation.swift new file mode 100644 index 00000000..b75077a5 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryCollation.swift @@ -0,0 +1,321 @@ +// Sorting and grouping the library — the Swift port of `pf-console-ui`'s `collate.rs`, which is +// the portable SPEC every console shell implements: how titles fold for A–Z, what a platform-less +// Steam title buckets under, why launchers always lead, and when a library is worth browsing as +// collections at all. The rules are pinned by a shared vectors file rather than by prose: +// `clients/shared/library-collate-vectors.json` holds one mixed library and the exact groups each +// (sort, group_by) must produce, and `LibraryCollationTests` reads it — the desktop crate reads the +// same file, so the two copies cannot drift in silence. Desktop is the source of truth; a rule +// change lands there first, regenerates the file, and comes here second. +// +// Everything returns INDICES into the caller's array, never copies. The art cache, the running map +// and the cursor all key off the model's ordering, and a collation that handed back cloned games +// would fork the identity of every title on the shelf. +// +// Lives in PunktfunkKit rather than beside a view for the reason `LibraryGridNav` gives: pure +// values in, pure values out, and PunktfunkKit is the target the tests can reach. + +import Foundation + +/// How titles are ordered within a group. The raw value is the persisted `punktfunk.librarySort` +/// string — a FILE FORMAT shared with the desktop's `library_sort` (`host|title|platform|store`); +/// renaming one silently resets every user's chosen sort on their next launch. +public enum LibrarySortKey: String, CaseIterable, Hashable, Sendable { + /// The host's own order, untouched. The default, and byte-identical to the shelf as it has + /// always been — a user who never opens the sort pills must see no change at all. + case hostOrder = "host" + /// A–Z, case- and diacritic-relaxed, with the leading article folded away. + case title = "title" + /// Platform label A–Z, then title within it. + case platform = "platform" + /// Store label A–Z, then title. + case store = "store" + + /// Parse the persisted value. Lenient by design: an unknown string is a newer client's key, + /// and the right answer to one is today's shelf rather than an error — the rule `uiPalette` + /// follows too. + public init(stored: String?) { + self = LibrarySortKey(rawValue: stored ?? "") ?? .hostOrder + } + + /// The persisted name. + public var stored: String { rawValue } + + /// The pill's label. + public var label: String { + switch self { + case .hostOrder: return "Default" + case .title: return "A–Z" + case .platform: return "Platform" + case .store: return "Store" + } + } + + /// The pills, in the order they are offered. + public static let all: [LibrarySortKey] = [.hostOrder, .title, .platform, .store] +} + +/// The two arrangements of the gamepad library. Persisted as `punktfunk.libraryView` +/// (`shelf|grid`, the desktop's `library_view`); unknown → shelf. +public enum LibraryArrangement: String, CaseIterable, Hashable, Sendable { + case shelf = "shelf" + case grid = "grid" + + public init(stored: String?) { + self = LibraryArrangement(rawValue: stored ?? "") ?? .shelf + } + + public var stored: String { rawValue } + + public var label: String { + switch self { + case .shelf: return "Shelf" + case .grid: return "Grid" + } + } + + public static let all: [LibraryArrangement] = [.shelf, .grid] +} + +/// What to bucket by. `nil` at the call sites = one group holding everything (the plain shelf). +public enum LibraryGroupBy: Hashable, Sendable { + case platform + case store +} + +/// What a group IS, kept as data rather than a formatted string so a filtered library can be +/// compared against it without re-parsing a label. +public enum LibraryGroupKey: Hashable, Sendable { + /// The launcher entries (design D4's leading group). + case launchers + case platform(String) + case store(String) + + /// The heading this group wears. + public var label: String { + switch self { + case .launchers: return "Launchers" + case .platform(let name), .store(let name): return name + } + } + + /// The kind caption a collection tile wears (`LAUNCHERS` / `PLATFORM` / `STORE`). + public var kindCaption: String { + switch self { + case .launchers: return "LAUNCHERS" + case .platform: return "PLATFORM" + case .store: return "STORE" + } + } +} + +/// One collated bucket: what it is, what to call it, and which games are in it. +public struct LibraryGroup: Hashable, Sendable { + public let key: LibraryGroupKey + public let label: String + /// Indices into the array passed to `LibraryCollation.collate`, in display order. + public let indices: [Int] + + public init(key: LibraryGroupKey, label: String, indices: [Int]) { + self.key = key + self.label = label + self.indices = indices + } +} + +public enum LibraryCollation { + /// Fold a title down to something sortable: lowercase, diacritics relaxed to their base + /// letter, punctuation dropped, and a leading article removed. + /// + /// The article fold is what a user actually means by A–Z. "The Witcher 3" belongs under W; + /// left alone, every "The …" in a library piles up under T and the sort is useless exactly + /// where it is most needed. English articles only — the host's titles are whatever the store + /// called them, and inventing rules for languages we cannot detect would file things under + /// letters nobody expects. + public static func sortTitle(_ title: String) -> String { + var relaxed = String.UnicodeScalarView() + for scalar in title.lowercased().unicodeScalars { + let folded = fold(scalar) + let props = folded.properties + // Rust's `is_alphanumeric() || is_whitespace()`: Alphabetic, or a numeric type, + // or White_Space — punctuation and symbols go. + if props.isAlphabetic || props.numericType != nil || props.isWhitespace { + relaxed.append(folded) + } + } + let trimmed = String(relaxed).trimmingCharacters(in: .whitespacesAndNewlines) + for article in ["the ", "a ", "an "] where trimmed.hasPrefix(article) { + // Guard against a title that IS an article ("The", "A Way Out" keeps "way out", but a + // bare "The" must not sort as an empty string and float to the front). + let rest = String(trimmed.dropFirst(article.count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + if !rest.isEmpty { return rest } + } + return trimmed + } + + /// The desktop's fold table, scalar for scalar. + private static func fold(_ scalar: Unicode.Scalar) -> Unicode.Scalar { + switch scalar { + case "á", "à", "â", "ä", "ã", "å": return "a" + case "é", "è", "ê", "ë": return "e" + case "í", "ì", "î", "ï": return "i" + case "ó", "ò", "ô", "ö", "õ": return "o" + case "ú", "ù", "û", "ü": return "u" + case "ç": return "c" + case "ñ": return "n" + default: return scalar + } + } + + /// The bucket for one game under `by`. + /// + /// The interesting case is a game with no platform. It does NOT go to "Unknown": a Steam + /// library is entirely platform-less, and one giant "Unknown" heap would be a worse view than + /// no grouping at all. A store-front game buckets under its STORE instead ("Steam"), which is + /// both true and useful, and only an entry with neither lands in "Other". + static func bucket(_ game: GameEntry, by: LibraryGroupBy) -> LibraryGroupKey { + switch by { + case .platform: + let platform = game.platform?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !platform.isEmpty { return .platform(platform) } + let store = game.storeLabel + return store == "Game" ? .platform("Other") : .store(store) + case .store: + return .store(game.storeLabel) + } + } + + /// Rust's `str::cmp` — bytewise over UTF-8, so `"GOG" < "Game" < "Steam"`. Every ordering + /// in this file goes through it: a locale-aware `<` would file the same library differently + /// on two devices, and differently from the desktop. + static func bytewise(_ a: String, _ b: String) -> Bool { + a.utf8.lexicographicallyPrecedes(b.utf8) + } + + /// Collate `games` into display groups. + /// + /// Launchers always form the leading group, which is how design D4's "launcher entries come + /// first" invariant survives grouping BY CONSTRUCTION rather than by every caller remembering + /// it. Sorting applies WITHIN groups, never across them. + public static func collate( + _ games: [GameEntry], sort: LibrarySortKey, groupBy: LibraryGroupBy? + ) -> [LibraryGroup] { + var launchers: [Int] = [] + // Insertion-ordered rather than a dictionary, so groups appear in the order the library + // first mentions them and two runs over the same library agree. + var buckets: [(key: LibraryGroupKey, indices: [Int])] = [] + for (i, game) in games.enumerated() { + if game.isLauncher { + launchers.append(i) + continue + } + // Ungrouped: one bucket holding the whole shelf. Its label is never drawn (the shelf + // has no heading when there is only one group), so it names itself honestly rather + // than inventing a title. + let key = groupBy.map { bucket(game, by: $0) } ?? .platform("All") + if let at = buckets.firstIndex(where: { $0.key == key }) { + buckets[at].indices.append(i) + } else { + buckets.append((key, [i])) + } + } + + // Fold every title once; the comparator below runs O(n log n) times per group. + let titleKeys = games.map { sortTitle($0.title) } + // Every comparator falls back to the index, so equal keys keep the host's order rather + // than an arbitrary one — and the result is the same whether or not `sorted` is stable. + func precedes(_ a: Int, _ b: Int) -> Bool { + let byTitleThenIndex: () -> Bool = { + if titleKeys[a] != titleKeys[b] { return bytewise(titleKeys[a], titleKeys[b]) } + return a < b + } + switch sort { + // Untouched: the host's order IS the order. + case .hostOrder: + return a < b + case .title: + return byTitleThenIndex() + case .platform: + let pa = games[a].platform ?? "", pb = games[b].platform ?? "" + if pa != pb { return bytewise(pa, pb) } + return byTitleThenIndex() + case .store: + let sa = games[a].storeLabel, sb = games[b].storeLabel + if sa != sb { return bytewise(sa, sb) } + return byTitleThenIndex() + } + } + + var out: [LibraryGroup] = [] + if !launchers.isEmpty { + // Launchers keep the host's order whatever the sort says: there are two or three of + // them, they are a fixed set, and shuffling them by title makes muscle memory useless + // for no gain. + out.append(LibraryGroup(key: .launchers, label: LibraryGroupKey.launchers.label, indices: launchers)) + } + // Groups themselves go A–Z by label, launchers excepted (pinned first); ties keep insertion + // order, exactly like the desktop's stable sort. + let ordered = buckets.enumerated().sorted { a, b in + if a.element.key.label != b.element.key.label { + return bytewise(a.element.key.label, b.element.key.label) + } + return a.offset < b.offset + } + for (_, bucket) in ordered { + out.append(LibraryGroup( + key: bucket.key, label: bucket.key.label, indices: bucket.indices.sorted(by: precedes))) + } + return out + } + + /// The flat index list for a group filter — `nil` = the whole library, in collated order + /// (launchers included, first). + public static func filtered( + _ games: [GameEntry], sort: LibrarySortKey, filter: LibraryGroupKey? + ) -> [Int] { + let by: LibraryGroupBy? + switch filter { + case .platform: by = .platform + case .store: by = .store + case .launchers, .none: by = nil + } + let groups = collate(games, sort: sort, groupBy: by) + guard let want = filter else { return groups.flatMap(\.indices) } + // A filter naming a group that no longer exists yields NOTHING, never everything. + return groups.first { $0.key == want }?.indices ?? [] + } + + /// Is there anything worth browsing? A library with one platform and one store has nothing to + /// drill INTO, and a screen that opens onto a single tile is worse than no screen — so the + /// button that reaches it is hidden rather than made to disappoint. ⚠ A Steam-only library + /// with no platform metadata is ONE group and is not worth browsing — by design. + public static func worthBrowsing(_ games: [GameEntry]) -> Bool { + collate(games, sort: .hostOrder, groupBy: .platform) + .filter { $0.key != .launchers } + .count >= 2 + } +} + +/// The shelf's display order — the one rule every writer of the catalog goes through, the +/// desktop's `order()`. +/// +/// Two rules, in this order: +/// 1. **Launcher entries lead** (design D4). Applied FIRST, because a grid's layout is built on +/// it: the launcher rows sit above the game rows as a prefix. Let a running game jump ahead of +/// a launcher and the heading reads GAMES · LAUNCHERS · GAMES along the strip. +/// 2. **Running titles lead within their group.** Getting back into what is already up should +/// be the first thing on the shelf rather than something to scroll for — and a launcher that +/// is up still belongs with the launchers, which is what makes the two rules compose instead +/// of fighting. +/// +/// A stable partition into four bands; the host's own title order survives inside each. +public enum LibraryOrder { + public static func display(_ games: [GameEntry], running: Set) -> [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) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift index d162ebeb..534aef32 100644 --- a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift @@ -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 } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridCursor.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridCursor.swift new file mode 100644 index 00000000..f59d267c --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridCursor.swift @@ -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 + } + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryPlaces.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryPlaces.swift new file mode 100644 index 00000000..ac6f0e1b --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryPlaces.swift @@ -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 + } +} diff --git a/clients/apple/Sources/PunktfunkShared/ConsoleContract.swift b/clients/apple/Sources/PunktfunkShared/ConsoleContract.swift new file mode 100644 index 00000000..8f4cb9a5 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/ConsoleContract.swift @@ -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 } } +} diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 856286de..e6a55859 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -192,8 +192,30 @@ public enum DefaultsKey { /// "pointer" (the cursor jumps to the finger), or "touch" (real multi-touch passthrough). /// Read live per gesture by `StreamLayerUIView`. public static let touchMode = "punktfunk.touchMode" - /// Experimental: show the host's game library (browsed over the management API). Off by default. + /// Show the host's game library (browsed over the management API). On by default — every + /// reader defaults it to `true`. public static let libraryEnabled = "punktfunk.libraryEnabled" + /// How the library's titles are ordered within a group — a `LibrarySortKey` stored value + /// (`"host"` = the host's own order, the default; `"title"` A–Z; `"platform"`; `"store"`). + /// The cross-client `library_sort` key: the desktop console persists the same ids, and an + /// unknown value reads as host order. Presentation only — a device preference, never part of + /// a stream profile. Written by the library's sort/view bar and by the Collections screen. + public static let librarySort = "punktfunk.librarySort" + /// Which arrangement the gamepad library opens in — a `LibraryArrangement` stored value + /// (`"shelf"` = the coverflow, the default; `"grid"`). The cross-client `library_view` key; + /// unknown reads as shelf. Presentation only. One key, two surfaces: the library's bar and the + /// Interface settings row both write it. + public static let libraryView = "punktfunk.libraryView" + /// Open a browsable library straight onto its Collections (group-by-platform tiles) instead of + /// the shelf — the cross-client `library_collections` key. Off by default; a library that is + /// not worth browsing (one platform, one store) opens on the shelf regardless. Presentation + /// only. + public static let libraryCollections = "punktfunk.libraryCollections" + /// The TOUCH library grid's grouping — `""` (none, the default), `"platform"` or `"store"`: + /// one section per collated group. Touch-only: on the console the grouping is a PLACE + /// (Collections), not a mode of the shelf, so there is no cross-client key for it. The sort it + /// composes with is the shared `librarySort`. Presentation only. + public static let libraryGroupBy = "punktfunk.libraryGroupBy" /// macOS: take the window fullscreen while streaming and restore it on the host list. On by default. public static let fullscreenWhileStreaming = "punktfunk.fullscreenWhileStreaming" /// LEGACY (pre-tiered overlay): the old boolean stats-overlay toggle. Kept ONLY as the diff --git a/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift b/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift index 03aa0f01..7ccd5c67 100644 --- a/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift @@ -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 diff --git a/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift index cb71f517..97f95d6d 100644 --- a/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift @@ -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() { diff --git a/clients/apple/Tests/PunktfunkKitTests/LibraryCollationTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryCollationTests.swift new file mode 100644 index 00000000..8a51e37b --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/LibraryCollationTests.swift @@ -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) + } + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/LibraryGridCursorTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryGridCursorTests.swift new file mode 100644 index 00000000..24e8fcb3 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/LibraryGridCursorTests.swift @@ -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.. 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)) + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/LibraryPlacesTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryPlacesTests.swift new file mode 100644 index 00000000..08cbad5c --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/LibraryPlacesTests.swift @@ -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) + } +} diff --git a/clients/shared/console-vectors.json b/clients/shared/console-vectors.json index 7af4fb3b..bddc996c 100644 --- a/clients/shared/console-vectors.json +++ b/clients/shared/console-vectors.json @@ -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, diff --git a/clients/shared/library-collate-vectors.json b/clients/shared/library-collate-vectors.json new file mode 100644 index 00000000..1a5431f3 --- /dev/null +++ b/clients/shared/library-collate-vectors.json @@ -0,0 +1,137 @@ +{ + "$comment": "Cross-client parity vectors for LIBRARY COLLATION — the sort/group rules every console shell must implement identically (desktop pf-console-ui `collate.rs` is the source of truth; Apple `LibraryCollation.swift` and Android port it). One mixed library, then the exact groups each (sort, group_by) must produce, the flat lists a group filter yields, which libraries are 'worth browsing', the title fold, the store labels, and the persisted sort ids. `library` entries use the host's /api/v1/library wire shape so a client can decode them with its real model. Read by: crates/pf-console-ui/src/collate.rs (`vectors_match_the_shared_file`), clients/apple/Tests/PunktfunkKitTests/LibraryCollationTests.swift. Group labels sort BYTEWISE (so 'GOG' < 'Game' < 'Steam'); an ungrouped collation is one group keyed platform/'All' (never drawn); `filtered` with no filter flattens EVERY group, launchers included.", + "version": 1, + "library": [ + {"id": "steam:launcher", "store": "steam", "title": "Steam", "role": "launcher", "icon": "steam", "art": {}}, + {"id": "steam:292030", "store": "steam", "title": "The Witcher 3: Wild Hunt", "art": {}}, + {"id": "steam:1222700", "store": "steam", "title": "A Way Out", "art": {}}, + {"id": "custom:zelda-oot", "store": "custom", "title": "Zelda: Ocarina of Time", "platform": "N64", "art": {}}, + {"id": "custom:sm64", "store": "custom", "title": "Super Mario 64", "platform": "N64", "art": {}}, + {"id": "heroic:launcher", "store": "heroic", "title": "Heroic", "role": "launcher", "icon": "heroic", "art": {}}, + {"id": "custom:gow3", "store": "custom", "title": "God of War", "platform": "PS3", "art": {}}, + {"id": "custom:eclair", "store": "custom", "title": "Éclair", "platform": "PS3", "art": {}}, + {"id": "gog:witcher", "store": "gog", "title": "The Witcher", "art": {}}, + {"id": "weird:odyssey", "store": "weird", "title": "An Odyssey", "art": {}}, + {"id": "custom:the", "store": "custom", "title": "The", "platform": "PS3", "art": {}}, + {"id": "steam:620", "store": "steam", "title": "Portal 2", "platform": "PC", "art": {}}, + {"id": "steam:lower", "store": "steam", "title": "a lowercase start", "platform": "", "art": {}}, + {"id": "custom:gow-dup", "store": "custom", "title": "God of War", "platform": "PS3", "art": {}} + ], + "sort_title": [ + {"in": "The Witcher 3: Wild Hunt", "out": "witcher 3 wild hunt"}, + {"in": "A Way Out", "out": "way out"}, + {"in": "An Untitled Story", "out": "untitled story"}, + {"in": "Theme Hospital", "out": "theme hospital"}, + {"in": "Anno 1800", "out": "anno 1800"}, + {"in": "Pokémon: Red!", "out": "pokemon red"}, + {"in": "Éclair", "out": "eclair"}, + {"in": "Spider-Man 2", "out": "spiderman 2"}, + {"in": "The", "out": "the"}, + {"in": " A ", "out": "a"}, + {"in": "Ōkami", "out": "ōkami"}, + {"in": "Zelda: Ocarina of Time", "out": "zelda ocarina of time"} + ], + "store_labels": { + "steam": "Steam", "custom": "Custom", "heroic": "Heroic", "lutris": "Lutris", + "epic": "Epic", "gog": "GOG", "xbox": "Xbox", "weird": "Game", "": "Game" + }, + "sort_keys": [ + {"stored": "host", "key": "host"}, + {"stored": "title", "key": "title"}, + {"stored": "platform", "key": "platform"}, + {"stored": "store", "key": "store"}, + {"stored": "", "key": "host"}, + {"stored": "bogus", "key": "host"}, + {"stored": "Title", "key": "host"} + ], + "cases": [ + { + "name": "host order, ungrouped, is byte-identical to the shelf", + "sort": "host", "group_by": null, + "expect": [ + {"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]}, + {"kind": "platform", "name": "All", "label": "All", "ids": ["steam:292030", "steam:1222700", "custom:zelda-oot", "custom:sm64", "custom:gow3", "custom:eclair", "gog:witcher", "weird:odyssey", "custom:the", "steam:620", "steam:lower", "custom:gow-dup"]} + ] + }, + { + "name": "A–Z folds articles and diacritics; equal keys keep host order; launchers keep host order", + "sort": "title", "group_by": null, + "expect": [ + {"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]}, + {"kind": "platform", "name": "All", "label": "All", "ids": ["custom:eclair", "custom:gow3", "custom:gow-dup", "steam:lower", "weird:odyssey", "steam:620", "custom:sm64", "custom:the", "steam:1222700", "gog:witcher", "steam:292030", "custom:zelda-oot"]} + ] + }, + { + "name": "group by platform: platform-less store games bucket under their STORE, a generic-store title under Other, groups A–Z by label", + "sort": "host", "group_by": "platform", + "expect": [ + {"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]}, + {"kind": "store", "name": "GOG", "label": "GOG", "ids": ["gog:witcher"]}, + {"kind": "platform", "name": "N64", "label": "N64", "ids": ["custom:zelda-oot", "custom:sm64"]}, + {"kind": "platform", "name": "Other", "label": "Other", "ids": ["weird:odyssey"]}, + {"kind": "platform", "name": "PC", "label": "PC", "ids": ["steam:620"]}, + {"kind": "platform", "name": "PS3", "label": "PS3", "ids": ["custom:gow3", "custom:eclair", "custom:the", "custom:gow-dup"]}, + {"kind": "store", "name": "Steam", "label": "Steam", "ids": ["steam:292030", "steam:1222700", "steam:lower"]} + ] + }, + { + "name": "group by platform, sorted A–Z within each group", + "sort": "title", "group_by": "platform", + "expect": [ + {"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]}, + {"kind": "store", "name": "GOG", "label": "GOG", "ids": ["gog:witcher"]}, + {"kind": "platform", "name": "N64", "label": "N64", "ids": ["custom:sm64", "custom:zelda-oot"]}, + {"kind": "platform", "name": "Other", "label": "Other", "ids": ["weird:odyssey"]}, + {"kind": "platform", "name": "PC", "label": "PC", "ids": ["steam:620"]}, + {"kind": "platform", "name": "PS3", "label": "PS3", "ids": ["custom:eclair", "custom:gow3", "custom:gow-dup", "custom:the"]}, + {"kind": "store", "name": "Steam", "label": "Steam", "ids": ["steam:lower", "steam:1222700", "steam:292030"]} + ] + }, + { + "name": "sort by store, ungrouped: store label bytewise (GOG before Game), then title, then host order", + "sort": "store", "group_by": null, + "expect": [ + {"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]}, + {"kind": "platform", "name": "All", "label": "All", "ids": ["custom:eclair", "custom:gow3", "custom:gow-dup", "custom:sm64", "custom:the", "custom:zelda-oot", "gog:witcher", "weird:odyssey", "steam:lower", "steam:620", "steam:1222700", "steam:292030"]} + ] + }, + { + "name": "group by store, sorted by platform within: raw platform string first (empty leads), then title", + "sort": "platform", "group_by": "store", + "expect": [ + {"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]}, + {"kind": "store", "name": "Custom", "label": "Custom", "ids": ["custom:sm64", "custom:zelda-oot", "custom:eclair", "custom:gow3", "custom:gow-dup", "custom:the"]}, + {"kind": "store", "name": "GOG", "label": "GOG", "ids": ["gog:witcher"]}, + {"kind": "store", "name": "Game", "label": "Game", "ids": ["weird:odyssey"]}, + {"kind": "store", "name": "Steam", "label": "Steam", "ids": ["steam:lower", "steam:1222700", "steam:292030", "steam:620"]} + ] + }, + { + "name": "sort by platform, ungrouped", + "sort": "platform", "group_by": null, + "expect": [ + {"kind": "launchers", "label": "Launchers", "ids": ["steam:launcher", "heroic:launcher"]}, + {"kind": "platform", "name": "All", "label": "All", "ids": ["steam:lower", "weird:odyssey", "steam:1222700", "gog:witcher", "steam:292030", "custom:sm64", "custom:zelda-oot", "steam:620", "custom:eclair", "custom:gow3", "custom:gow-dup", "custom:the"]} + ] + } + ], + "filtered": [ + {"name": "a platform filter yields that group's games only", "sort": "host", "filter": {"kind": "platform", "name": "PS3"}, "expect": ["custom:gow3", "custom:eclair", "custom:the", "custom:gow-dup"]}, + {"name": "a store filter groups by store and sorts within", "sort": "title", "filter": {"kind": "store", "name": "Steam"}, "expect": ["steam:lower", "steam:620", "steam:1222700", "steam:292030"]}, + {"name": "a filter naming a group that does not exist yields nothing, never everything", "sort": "host", "filter": {"kind": "platform", "name": "SNES"}, "expect": []}, + {"name": "a filter of the wrong KIND for the group is not the group", "sort": "host", "filter": {"kind": "platform", "name": "Steam"}, "expect": []}, + {"name": "the launchers filter is the launcher group", "sort": "title", "filter": {"kind": "launchers"}, "expect": ["steam:launcher", "heroic:launcher"]}, + {"name": "no filter flattens every group, launchers first", "sort": "host", "filter": null, "expect": ["steam:launcher", "heroic:launcher", "steam:292030", "steam:1222700", "custom:zelda-oot", "custom:sm64", "custom:gow3", "custom:eclair", "gog:witcher", "weird:odyssey", "custom:the", "steam:620", "steam:lower", "custom:gow-dup"]}, + {"name": "no filter, A–Z", "sort": "title", "filter": null, "expect": ["steam:launcher", "heroic:launcher", "custom:eclair", "custom:gow3", "custom:gow-dup", "steam:lower", "weird:odyssey", "steam:620", "custom:sm64", "custom:the", "steam:1222700", "gog:witcher", "steam:292030", "custom:zelda-oot"]} + ], + "worth_browsing": [ + {"name": "the whole library", "ids": null, "expect": true}, + {"name": "a Steam-only library with no platform metadata is ONE group", "ids": ["steam:launcher", "steam:292030", "steam:1222700"], "expect": false}, + {"name": "two stores are two groups", "ids": ["steam:292030", "gog:witcher"], "expect": true}, + {"name": "empty", "ids": [], "expect": false}, + {"name": "launchers alone", "ids": ["steam:launcher", "heroic:launcher"], "expect": false}, + {"name": "one platform", "ids": ["custom:zelda-oot", "custom:sm64"], "expect": false}, + {"name": "one platform plus its launcher", "ids": ["heroic:launcher", "custom:gow3", "custom:eclair"], "expect": false}, + {"name": "a platform and a platform-less store title", "ids": ["custom:gow3", "steam:292030"], "expect": true} + ] +} diff --git a/crates/pf-console-ui/Cargo.toml b/crates/pf-console-ui/Cargo.toml index 90c34198..abf277a9 100644 --- a/crates/pf-console-ui/Cargo.toml +++ b/crates/pf-console-ui/Cargo.toml @@ -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" diff --git a/crates/pf-console-ui/src/collate.rs b/crates/pf-console-ui/src/collate.rs index 3215478a..71d3b0a3 100644 --- a/crates/pf-console-ui/src/collate.rs +++ b/crates/pf-console-ui/src/collate.rs @@ -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 = 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 { + 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 { + 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 = 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}" + ); + } + } }