From a8ee7cd71b3ede917f2584fa5370938eefb1ddf0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 10:59:32 +0200 Subject: [PATCH 01/12] test(console): the collation rules become a shared vectors file the other clients can pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clients/shared/library-collate-vectors.json` pins, from `collate.rs`'s rules, the exact groups every (sort, group_by) must produce over one mixed library, the flat lists a group filter yields, which libraries are worth browsing, the title fold, the store labels and the persisted sort ids. `vectors_match_the_shared_file` reads it here; the Apple port's LibraryCollationTests reads the same file, so the three copies of the spec have a machine contract instead of prose. Desktop stays the source of truth — a rule change regenerates the file in the same commit. The fixture deliberately walks every rule the design doc names: a platform-less Steam title buckets under Steam, a generic-store title under Other, launchers lead in host order under every sort, equal A–Z keys keep the host's order, group labels sort bytewise (GOG before Game), a filter naming a missing group yields nothing, and `filtered(None)` flattens the launcher group too. --- clients/shared/library-collate-vectors.json | 137 +++++++++++++++++++ crates/pf-console-ui/Cargo.toml | 6 +- crates/pf-console-ui/src/collate.rs | 142 ++++++++++++++++++++ 3 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 clients/shared/library-collate-vectors.json diff --git a/clients/shared/library-collate-vectors.json b/clients/shared/library-collate-vectors.json new file mode 100644 index 000000000..1a5431f32 --- /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 90c341985..abf277a9e 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 3215478a1..71d3b0a3a 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}" + ); + } + } } -- 2.54.0 From eeed8ae8bbac077b7a51223284cb51c74bb38ad7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 11:08:45 +0200 Subject: [PATCH 02/12] feat(clients/apple): the library model learns `platform`, and the collation rules arrive as a tested module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-1): - `GameEntry.platform` — the host has sent it all along (`GameMeta`, flattened); `Codable` dropped it. It is the field the library's collections group by, and the detail band now reads `STORE · PLATFORM` when the host named one. - `LibraryCollation` (PunktfunkKit) — the Swift port of `pf-console-ui`'s `collate.rs`: `LibrarySortKey` (host/title/platform/store, the desktop's stored ids), `LibraryGroupKey`, `collate`/`filtered`/`worthBrowsing`, the article-and-diacritic title fold, the platform-less-buckets-under-its-store rule, bytewise label order. The desktop's eight tests are ported by name, and `testVectorsMatchTheSharedFile` reads the same `clients/shared/library-collate-vectors.json` the Rust test reads — the two copies of the spec now share a machine contract. - `LibraryOrder.display` replaces `LibraryView.ordered`, which 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, while its comment claimed the opposite. The desktop's `order()`: launchers lead, running leads within a band. - Three device keys with the desktop's ids (`librarySort`, `libraryView`, `libraryCollections`), and their rows on both settings surfaces (gamepad Interface tab, touch Library section). Presentation only, never in a profile. - The coverflow now shows the catalog's staleness ("Last known library — waking the host…" / "— the host didn't answer") on its detail band — the touch grid had it, the coverflow never did — and opens on the title last launched from the shelf (`LibraryScrollMemory`), the way the grid scrolls back to it. `GamepadCarousel` gains `initialItemID` for that. - Loading/error/empty in gamepad mode carry a legend (`A Retry` on an error, `B Back`); the zero-size back catcher answers A too. A controller-only user on an error screen had no visible way out. - `PUNKTFUNK_FAKE_LIBRARY=` — the desktop's dev hook, here: a host-shaped list (or the shared vectors file itself) stands in for a host, so grid/collections/sort can be driven on a Mac with none. `ShotMock.games` gains platforms for the store shots. --- .../Home/GamepadCarousel.swift | 19 +- .../Home/LibraryCoverflowView.swift | 61 +++- .../PunktfunkClient/Home/LibraryView.swift | 142 +++++++- .../Screenshots/ScreenshotScenes.swift | 4 +- .../Settings/GamepadSettingsView.swift | 21 ++ .../Settings/SettingsView+Sections.swift | 14 + .../Settings/SettingsView.swift | 4 + .../Connection/LibraryClient.swift | 6 + .../Connection/LibraryCollation.swift | 321 ++++++++++++++++++ .../PunktfunkShared/DefaultsKeys.swift | 19 +- .../LibraryClientTests.swift | 5 + .../LibraryCollationTests.swift | 289 ++++++++++++++++ 12 files changed, 873 insertions(+), 32 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkKit/Connection/LibraryCollation.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/LibraryCollationTests.swift diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 128980f6d..1b2212d24 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. @@ -172,9 +176,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 +216,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() } diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index a421887b4..de563865c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -29,6 +29,14 @@ struct LibraryCoverflowView: View { /// 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] = [:] + /// Whether these titles are remembered rather than observed (served from the catalog cache), + /// and what the host is doing about it. Drawn on the detail band's subtitle line, leading — + /// exactly where the desktop shelf draws it — and never as an error. + var staleness: LibraryStaleness = .none + /// 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) — 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)? @@ -129,6 +137,7 @@ struct LibraryCoverflowView: View { selection: $selection, itemWidth: coverWidth, spacing: 34, + initialItemID: initialSelection, onActivate: { onLaunch?($0.id) }, onTertiary: onCopyLink.map { copy in { copyCentered(copy) } }, onBack: { onDismiss?() }, @@ -211,7 +220,9 @@ struct LibraryCoverflowView: View { .foregroundStyle(ink.fg(0.45)) } - /// The centered title + store tag — empty (not hidden) so the layout doesn't jump. + /// The centered title + store tag — empty (not hidden) so the layout doesn't jump. The + /// staleness note shares the subtitle 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 = games.first { $0.id == selection } VStack(spacing: 6) { @@ -221,15 +232,33 @@ struct LibraryCoverflowView: View { .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)) + // 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 { + 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) } } .frame(maxWidth: .infinity) @@ -237,6 +266,18 @@ struct LibraryCoverflowView: View { .animation(.smooth(duration: 0.25), value: selection) } + /// `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: - Hint bar (pinned bottom-leading via safeAreaInset) private var hints: [GamepadHint] { diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 2e44cc302..073e4509d 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -152,7 +152,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 +177,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) @@ -199,6 +204,11 @@ struct LibraryView: View { LibraryCoverflowView( 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. @@ -223,16 +233,45 @@ 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 + 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 @@ -430,6 +469,15 @@ struct LibraryView: View { 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 +591,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 +625,59 @@ 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. + /// 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 +686,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/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 9ac4ab4e7..120902a16 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -214,11 +214,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": {}, diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 57fc12c2b..17658dcef 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -94,6 +94,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 +934,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 ab8b19a76..0a7b13a72 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 0e64fce21..1e40e2876 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/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift index d2957a607..059118d57 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 000000000..b75077a5a --- /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/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 856286dee..86020b1c2 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -192,8 +192,25 @@ 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" /// 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/LibraryClientTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryClientTests.swift index cb71f5174..97f95d6d0 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 000000000..8a51e37bd --- /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) + } + } +} -- 2.54.0 From 2541436e357714b3a5b730961970e9f11f5ceabf Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 11:24:46 +0200 Subject: [PATCH 03/12] feat(clients/apple): the gamepad library gets a grid arrangement and a live view/sort bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-2): - `LibraryConsoleView` — 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, the legend, the backdrop. `LibraryView` still owns the data. The bar's controls write the cross-client keys the Interface settings rows write, so the two surfaces can never disagree; both apply live and the focused TITLE survives a resort. - `LibraryBarView` — `SORT` Default · A–Z · Platform · Store leading, `VIEW` Shelf · Grid trailing; focus is an accent wash over the pill row (not the band — the desktop's own trap). ▲ from the field hands the controller over; ◀▶ step the sort clamped, L1/R1 pick the arrangement, ▼/A/B hand it back, and the legend swaps with it. Pills are tappable, and focusable Buttons on tvOS. - `LibraryGridView` — 2:3 cells 150×225 design units scaled by min(width, height)/800, columns 2…8 as they fit, the launcher rows squarely above the game rows with the heading between, the focused row riding a third of the way down on a spring, ×1.06 pop + accent ring OUTSIDE the cover, `Resume` in the coverflow's corner, entrance fanning on |Δrow|+|Δcol|. Cells are focusable Buttons on tvOS (a Siri Remote is no extended gamepad). - `LibraryGridCursor` (PunktfunkKit) — the port of `GridShape`/`grid_step`/`grid_col_hint`: one shape both renderer and cursor read; ◀▶ walk the row and refuse at its TRUE ends, ▲▼ change row carrying the remembered column, L1/R1 page three rows and land on the ends, navigation declines until a layout exists. The desktop's nine grid tests, by name. - `PosterImage` decodes at the DRAWN size (`CGImageSourceCreateThumbnailAtIndex`, 2× the cell's edge in pixels): a grid multiplies resident posters, and the desktop's grid was a slideshow until it stopped decoding 600×900 capsules for 150-point cells. The coverflow shares it. - The coverflow is now the strip and its heading only; its detail band, legend and backdrop moved up into the container. Screenshot scenes gain `11b-library-grid` and `11c-library-bar` (the bar FOCUSED — no shot ever showed the desktop's bar focused, which is how its mis-sized wash shipped). --- .../PunktfunkClient/Home/LibraryBarView.swift | 104 ++++ .../Home/LibraryConsoleView.swift | 362 ++++++++++++++ .../Home/LibraryCoverflowView.swift | 210 ++------- .../Home/LibraryGridView.swift | 444 ++++++++++++++++++ .../PunktfunkClient/Home/LibraryView.swift | 2 +- .../PunktfunkClient/Home/LibraryWidgets.swift | 40 +- .../Screenshots/ScreenshotScenes.swift | 20 +- .../Gamepad/LibraryGridCursor.swift | 140 ++++++ .../LibraryGridCursorTests.swift | 185 ++++++++ 9 files changed, 1333 insertions(+), 174 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift create mode 100644 clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift create mode 100644 clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift create mode 100644 clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridCursor.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/LibraryGridCursorTests.swift diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift new file mode 100644 index 000000000..6068c8207 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift @@ -0,0 +1,104 @@ +// 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. Reached with ▲ from the shelf (or the grid's top row); +// while focused ◀▶ 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 + 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 { + HStack(spacing: 0) { + group(caption: "SORT") { + ForEach(LibrarySortKey.all, id: \.self) { key in + pill(key.label, selected: key == sort, namespace: sortHighlight) { onSort(key) } + } + } + Spacer(minLength: 12) + group(caption: "VIEW") { + ForEach(LibraryArrangement.all, id: \.self) { view in + pill(view.label, selected: view == arrangement, namespace: viewHighlight) { + onArrangement(view) + } + } + } + } + .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(height: Self.bandHeight) + } + + /// 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/LibraryConsoleView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift new file mode 100644 index 000000000..358743475 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift @@ -0,0 +1,362 @@ +// 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)? + /// Button X — copy the focused title's `punktfunk://` link. nil where the platform has no + /// clipboard (tvOS), which also drops the hint. + var onCopyLink: ((GameEntry) -> Void)? + /// Whether this screen owns the controller — the shell gates it mid-transition and under the + /// connect takeover. + var controllerActive = true + /// Screenshot/dev overrides: force an arrangement, open with the bar focused. + var arrangementOverride: LibraryArrangement? + var barFocusedInitially = 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 = "" + /// 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 copy hint's acknowledgement (there is no toast on this surface — the legend says it + /// itself, and forgets it the moment the focus moves off the cover it was about). + @State private var copied = false + @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). + private var displayed: [GameEntry] { + LibraryCollation.filtered(games, sort: sort, filter: nil).map { games[$0] } + } + private var focused: GameEntry? { displayed.first { $0.id == focusID } } + /// The field owns the controller only while the bar does not. + private var fieldActive: Bool { controllerActive && !barFocused } + + var body: some View { + VStack(spacing: 0) { + LibraryBarView( + sort: sort, arrangement: arrangement, focused: barFocused, compact: compact, + onSort: { setSort($0) }, onArrangement: { setArrangement($0) } + ) + .padding(.top, compact ? 2 : 6) + .padding(.bottom, LibraryBarView.gap - 4) + field + .frame(maxWidth: .infinity, maxHeight: .infinity) + detailPanel + .padding(.top, 8) + .padding(.bottom, compact ? 4 : 10) + } + .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { + GamepadHintBar(hints: barFocused ? barHints : 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() + .task(id: copied) { + guard copied else { return } + try? await Task.sleep(for: .milliseconds(1600)) + withAnimation(.smooth(duration: 0.2)) { copied = false } + } + .onChange(of: focusID) { _, _ in copied = false } + .onAppear { + barFocused = barFocusedInitially + wireBar() + if barFocused, controllerActive { barInput.start() } + } + .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 { + switch arrangement { + case .shelf: + LibraryCoverflowView( + games: displayed, artLoader: artLoader, focusID: $focusID, onLaunch: onLaunch, + running: running, initialSelection: focusID ?? initialSelection, + onBack: onDismiss, + onTertiary: onCopyLink.map { copy in { copyFocused(copy) } }, + onUp: { enterBar() }, + controllerActive: fieldActive) + case .grid: + LibraryGridView( + games: displayed, artLoader: artLoader, focusID: $focusID, onLaunch: onLaunch, + running: running, initialSelection: focusID ?? initialSelection, + onBack: onDismiss, + onTertiary: onCopyLink.map { copy in { copyFocused(copy) } }, + onUp: { enterBar() }, + controllerActive: fieldActive) + } + } + + // MARK: - Detail band + + /// The focused title + its provenance — empty (not hidden) so the layout doesn't jump. The + /// staleness note shares the subtitle 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: 6) { + Text(game?.title ?? " ") + .font(.geist(compact ? 22 : 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 { + 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) + } + } + .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 an iPhone, whose legend is + /// already at its width; never on tvOS (a Siri Remote has no shoulders) — the settings + /// strip's own rule. + private var showsShoulderHint: Bool { + #if os(tvOS) + false + #elseif os(iOS) + hSizeClass == .regular + #else + true + #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) } })) + } + if let onCopyLink { + hints.append(.init( + glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), + text: copied ? "Copied" : "Copy link", + action: { copyFocused(onCopyLink) })) + } + if showsShoulderHint { + hints.append(.init( + glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"), + text: "Jump")) + } + 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 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 + + /// Hand the FOCUSED title to the copy action — read at press time, inert with nothing focused. + private func copyFocused(_ copy: (GameEntry) -> Void) { + guard let game = focused else { return } + copy(game) + withAnimation(.smooth(duration: 0.2)) { copied = true } + } + + private func setSort(_ key: LibrarySortKey) { + guard key != sort else { return } + sortRaw = key.stored + } + + private func setArrangement(_ view: LibraryArrangement) { + guard view != arrangement else { return } + viewRaw = view.stored + } + + // MARK: - Bar input + + private func enterBar() { + guard !barFocused else { return } + barHaptics.move() + withAnimation(.smooth(duration: 0.18)) { barFocused = true } + } + + private func leaveBar() { + guard barFocused else { return } + withAnimation(.smooth(duration: 0.18)) { 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 screen's pill row wraps, this + // bar does not; the desktop draws the same distinction). + let all = LibrarySortKey.all + guard let i = all.firstIndex(of: sort) else { return setSort(all[0]) } + let target = i + (direction == .right ? 1 : -1) + guard all.indices.contains(target) else { return barBoundary() } + barHaptics.move() + setSort(all[target]) + 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 de563865c..293808703 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,52 +18,37 @@ 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] = [:] - /// Whether these titles are remembered rather than observed (served from the catalog cache), - /// and what the host is doing about it. Drawn on the detail band's subtitle line, leading — - /// exactly where the desktop shelf draws it — and never as an error. - var staleness: LibraryStaleness = .none /// 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) — 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. + /// 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. - @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. @@ -82,39 +66,18 @@ 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) + let reserved: CGFloat = 8 + (showsGroupHeading ? 26 : 0) let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9)) let coverWidth = coverHeight * 2 / 3 @@ -124,8 +87,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) @@ -134,13 +95,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 @@ -159,7 +122,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) { @@ -195,14 +161,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 { @@ -213,95 +171,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. The - /// staleness note shares the subtitle 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 = 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) - // 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 { - 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) - } - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 24) - .animation(.smooth(duration: 0.25), value: selection) - } - - /// `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: - 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 000000000..7de3f5a6e --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift @@ -0,0 +1,444 @@ +// 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, the focused row +// riding 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. A Deck-shaped +// window gets 7, an iPad in landscape 6, a phone in landscape 6 small ones, 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 + /// True while a programmatic scroll is settling the focused row — the first scroll on mount + /// is seated (no animation), later ones are sprung. + @State private var seated = false + + /// 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 k = min(max(min(geo.size.width, geo.size.height) / 800, 0.75), 3) + let cellW = 150 * k, cellH = 225 * k, gap = 16 * k, margin = 48 * k + let headingH = 30 * k, labelGap = 10 * k + let avail = geo.size.width - 2 * margin + let colsNow = min(max(Int((avail + gap) / (cellW + gap)), 2), 8) + let s = LibraryGridShape(len: games.count, cols: colsNow, launchers: launcherCount) + ScrollViewReader { proxy in + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 0) { + // Top inset = the heading band, UNCONDITIONALLY, so the first row's + // entrance and focus pop are never clipped; the caption only when the + // field has two groups. + heading(s.split > 0 ? "LAUNCHERS" : nil, height: headingH, k: k) + if s.split > 0 { + section( + games[.. some View { + HStack { + if let text { + Text(text) + .font(.geist(11 * k, .semibold, relativeTo: .caption2)) + .tracking(1.4) + .foregroundStyle(ink.fg(0.45)) + } + } + .frame(height: height, alignment: .leading) + .padding(.vertical, 4 * k) + } + + /// One section's rows. Fixed-width columns so the two sections' cells align vertically and a + /// partial last row sits leading — exactly the shape the cursor was told. + private func section( + _ entries: [(Int, GameEntry)], cols: Int, cellW: CGFloat, cellH: CGFloat, gap: CGFloat, + labelGap: CGFloat, k: CGFloat, shape: LibraryGridShape + ) -> some View { + LazyVGrid( + columns: Array( + repeating: GridItem(.fixed(cellW), spacing: gap, alignment: .top), count: cols), + alignment: .leading, spacing: gap + labelGap + ) { + ForEach(entries, id: \.1.id) { index, game in + #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: cellW, height: cellH, k: k, shape: shape) + } + .buttonStyle(ConsoleBareButtonStyle()) + .focused($tvFocus, equals: game.id) + .id(game.id) + #else + cell(game, index: index, width: cellW, height: cellH, k: k, shape: shape) + .id(game.id) + #endif + } + } + } + + 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 } + } +} + +/// 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/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 073e4509d..959d88bd3 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -201,7 +201,7 @@ struct LibraryView: View { consoleField(emptyState) } else { if gamepadUIActive { - LibraryCoverflowView( + LibraryConsoleView( games: ordered, artLoader: artLoader, onLaunch: launchAndRemember, running: running, staleness: staleness, diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 75671bdb2..49b823835 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 120902a16..591269b7a 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -40,6 +40,14 @@ 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)) + }, ] #if os(iOS) || os(macOS) // The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots @@ -267,14 +275,18 @@ 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 body: some View { - LibraryCoverflowView( + LibraryConsoleView( games: ShotMock.games, artLoader: ShotPosterArt.source, - onLaunch: { _ in }, onDismiss: {}, controllerActive: false) + onLaunch: { _ in }, onDismiss: {}, controllerActive: false, + arrangementOverride: arrangement, barFocusedInitially: barFocused) } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridCursor.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryGridCursor.swift new file mode 100644 index 000000000..f59d267c4 --- /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/Tests/PunktfunkKitTests/LibraryGridCursorTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryGridCursorTests.swift new file mode 100644 index 000000000..24e8fcb3a --- /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)) + } +} -- 2.54.0 From af2f51e9cc09f8f9d4a38d90e8859e10635ccdde Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 11:32:32 +0200 Subject: [PATCH 04/12] =?UTF-8?q?feat(clients/apple):=20Collections=20?= =?UTF-8?q?=E2=80=94=20group=20by=20platform,=20walk=20them,=20open=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The third slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-3): the user's flow verbatim, "group by platform → navigate the platforms → pick PS3 → see its games". - `LibraryPlaceStack` (PunktfunkKit) — the desktop's screen stack for the library, as a value INSIDE the library layer rather than deeper `GamepadShell` screens (its screen enum is derived with depth ≤ 1 by construction; macOS shows the library as a sheet, tvOS as a cover; and the desktop's own property is that drilling never touches the model). shelf → Y → collections → A → filtered shelf → B → collections → B → shelf → B → dismiss; a Collections ROOT offers Y "All titles" as a drill-in; Y is refused with a pulse on any drilled shelf and its hint hidden. The flows are tests, not glass. - `CollectionsHandover.decide` — "start in collections", decided ONCE per shelf: refused when off or drilled, loading/empty/error decide nothing, a CACHED catalog counts as ready, and the library must be worth browsing (≥ 2 non-launcher groups — a platform-less Steam library is one group, by design). The desktop's epoch rule has no counterpart: an Apple shelf's view owns its own fetch. - `LibraryCollectionsView` — one tile per collated group in the home carousel's tile language (same size, glass, recede), with label, count, kind caption, and a DECK of up to three covers from the sort-first titles: front 118 tall, each slot back 7 % smaller, 18 pt right, 7 pt up, 6° turned, drawn back-to-front over HARD contact plates. A launcher fans its brand mark and is never fetched; a one-title group never fakes a stack. L1/R1 step the sort WRAPPING (the shelf's bar clamps — the desktop draws the same distinction); the sort pills ride the same bar, VIEW hidden. `GamepadCarousel` gains `onShoulder` for it. - `LibraryConsoleView` hosts the places: a filtered shelf is `filtered(games, sort, group)` over the same catalog, art loader and running map (nothing re-fetches on drill-in or back); pushes and pops move with the shell's own transition; the shelf legend gains `Y Collections` where it applies; the title reads `host · profile · collection` on both the pinned iOS title and the sheet/cover navigation title. Screenshot scene `11d-collections`. --- .../Home/GamepadCarousel.swift | 5 +- .../Home/GamepadLibraryScreen.swift | 13 +- .../PunktfunkClient/Home/LibraryBarView.swift | 12 +- .../Home/LibraryCollectionsView.swift | 235 ++++++++++++++++++ .../Home/LibraryConsoleView.swift | 225 ++++++++++++++--- .../PunktfunkClient/Home/LibraryView.swift | 21 +- .../Screenshots/ScreenshotScenes.swift | 8 +- .../PunktfunkKit/Gamepad/LibraryPlaces.swift | 109 ++++++++ .../LibraryPlacesTests.swift | 63 +++++ 9 files changed, 648 insertions(+), 43 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift create mode 100644 clients/apple/Sources/PunktfunkKit/Gamepad/LibraryPlaces.swift create mode 100644 clients/apple/Tests/PunktfunkKitTests/LibraryPlacesTests.swift diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index 1b2212d24..07615ec70 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -61,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 @@ -342,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/GamepadLibraryScreen.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift index 3ab10a9c3..06f9e0486 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/LibraryBarView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift index 6068c8207..ea3b23c26 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift @@ -23,6 +23,8 @@ struct LibraryBarView: View { 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 @@ -42,10 +44,12 @@ struct LibraryBarView: View { } } Spacer(minLength: 12) - group(caption: "VIEW") { - ForEach(LibraryArrangement.all, id: \.self) { view in - pill(view.label, selected: view == arrangement, namespace: viewHighlight) { - onArrangement(view) + if showsView { + group(caption: "VIEW") { + ForEach(LibraryArrangement.all, id: \.self) { view in + pill(view.label, selected: view == arrangement, namespace: viewHighlight) { + onArrangement(view) + } } } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift new file mode 100644 index 000000000..6d3be3ce8 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift @@ -0,0 +1,235 @@ +// 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 + 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, + 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) + .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 + + #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 = 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 index 358743475..cdd5364c6 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift @@ -41,9 +41,14 @@ struct LibraryConsoleView: View { /// Whether this screen owns the controller — the shell gates it mid-transition and under the /// connect takeover. var controllerActive = true - /// Screenshot/dev overrides: force an arrangement, open with the bar focused. + /// 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? @Environment(\.gamepadHostedInShell) private var hostedInShell @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -56,6 +61,14 @@ struct LibraryConsoleView: View { #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 @@ -73,30 +86,56 @@ struct LibraryConsoleView: View { 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). + /// (`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: nil).map { games[$0] } + 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 the bar does not. private var fieldActive: Bool { controllerActive && !barFocused } + /// 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 { - VStack(spacing: 0) { + // 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) { LibraryBarView( - sort: sort, arrangement: arrangement, focused: barFocused, compact: compact, + sort: sort, arrangement: arrangement, focused: barFocused, + showsView: !top.isCollections, compact: compact, onSort: { setSort($0) }, onArrangement: { setArrangement($0) } ) .padding(.top, compact ? 2 : 6) .padding(.bottom, LibraryBarView.gap - 4) field .frame(maxWidth: .infinity, maxHeight: .infinity) - detailPanel - .padding(.top, 8) - .padding(.bottom, compact ? 4 : 10) + if !top.isCollections { + detailPanel + .padding(.top, 8) + .padding(.bottom, compact ? 4 : 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 ? 44 : 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(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact))) } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { - GamepadHintBar(hints: barFocused ? barHints : hints) + GamepadHintBar(hints: barFocused ? barHints : (top.isCollections ? collectionHints : hints)) .padding(.leading, 22) .padding(.vertical, compact ? 6 : 10) } @@ -115,10 +154,19 @@ struct LibraryConsoleView: View { } .onChange(of: focusID) { _, _ in copied = false } .onAppear { + decideHandover() barFocused = barFocusedInitially 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() } } @@ -144,23 +192,35 @@ struct LibraryConsoleView: View { /// 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 { - switch arrangement { - case .shelf: - LibraryCoverflowView( - games: displayed, artLoader: artLoader, focusID: $focusID, onLaunch: onLaunch, - running: running, initialSelection: focusID ?? initialSelection, - onBack: onDismiss, - onTertiary: onCopyLink.map { copy in { copyFocused(copy) } }, - onUp: { enterBar() }, - controllerActive: fieldActive) - case .grid: - LibraryGridView( - games: displayed, artLoader: artLoader, focusID: $focusID, onLaunch: onLaunch, - running: running, initialSelection: focusID ?? initialSelection, - onBack: onDismiss, - onTertiary: onCopyLink.map { copy in { copyFocused(copy) } }, - onUp: { enterBar() }, + 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) }, 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: onCopyLink.map { copy in { copyFocused(copy) } }, + 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: onCopyLink.map { copy in { copyFocused(copy) } }, + onUp: { enterBar() }, + controllerActive: fieldActive) + } } } @@ -257,6 +317,13 @@ struct LibraryConsoleView: View { 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() })) + } if let onCopyLink { hints.append(.init( glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), @@ -275,6 +342,34 @@ struct LibraryConsoleView: View { 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() })) + } + if showsShoulderHint { + hints.append(.init( + glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"), + text: "Sort")) + } + 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] { [ @@ -302,6 +397,75 @@ struct LibraryConsoleView: View { 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 ? .easeOut(duration: 0.2) : 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 @@ -339,14 +503,9 @@ struct LibraryConsoleView: View { private func barMove(_ direction: GamepadMenuInput.Direction) { switch direction { case .left, .right: - // Step the sort, CLAMPED — no wrap (the collections screen's pill row wraps, this - // bar does not; the desktop draws the same distinction). - let all = LibrarySortKey.all - guard let i = all.firstIndex(of: sort) else { return setSort(all[0]) } - let target = i + (direction == .right ? 1 : -1) - guard all.indices.contains(target) else { return barBoundary() } - barHaptics.move() - setSort(all[target]) + // 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: diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 959d88bd3..5919b10f9 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -68,6 +68,11 @@ 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? @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,7 +123,7 @@ struct LibraryView: View { var body: some View { content - .navigationTitle("\(target.title(in: profiles)) — Library") + .navigationTitle("\(shelfTitle) — Library") #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif @@ -213,7 +218,11 @@ struct LibraryView: View { // 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. onCopyLink: LinkClipboard.isAvailable ? { copyLink($0) } : nil, - controllerActive: controllerActive) + 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. @@ -625,6 +634,14 @@ struct LibraryView: View { } } + /// `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 diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 591269b7a..64cb1bd15 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -48,6 +48,10 @@ enum ShotScenes { 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)) + }, ] #if os(iOS) || os(macOS) // The gamepad-mode console screens (no tvOS — native focus engine there). Dev-only shots @@ -281,12 +285,14 @@ private struct ShotHome: View { private struct ShotLibrary: View { var arrangement: LibraryArrangement? var barFocused = false + var collections = false var body: some View { LibraryConsoleView( games: ShotMock.games, artLoader: ShotPosterArt.source, onLaunch: { _ in }, onDismiss: {}, controllerActive: false, - arrangementOverride: arrangement, barFocusedInitially: barFocused) + arrangementOverride: arrangement, barFocusedInitially: barFocused, + startInCollectionsOverride: collections) } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryPlaces.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/LibraryPlaces.swift new file mode 100644 index 000000000..ac6f0e1b9 --- /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/Tests/PunktfunkKitTests/LibraryPlacesTests.swift b/clients/apple/Tests/PunktfunkKitTests/LibraryPlacesTests.swift new file mode 100644 index 000000000..08cbad5cd --- /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) + } +} -- 2.54.0 From aec6ccf8878bf80e8a93a35e242a0392b3b32650 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 11:36:56 +0200 Subject: [PATCH 05/12] feat(clients/apple): the shell's push/pop becomes the console's spring, and the contract is pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-4): - `ConsoleContract.swift` (PunktfunkShared): `ConsoleMotion` — the vectors' `motion_spring` numbers (response 0.42, damping 0.88, slide 36, scales 0.985/0.96, reveal 0.4, interruptible) — and `GpSettingsTab`, moved out of the executable target so `ConsoleVectorsTests` can reach both. `testTabNamesMatchTheSharedVectors` (this client's trailing About is its own; the desktop's Input is `desktop_only`) and `testMotionMatchesTheSharedVectors` (the v2 block) join the palette test; the vectors' v1 `$deprecated` note now names Android as the last v1 reader. - `GamepadShellMotion.screen` is `.spring(0.42, 0.88)` — the console's `springs::NAV` — instead of the 0.26 s ease-out-cubic; Reduce Motion crossfades on the desktop's `REDUCED_NAV` (0.22, 1.0) with no slide and no scale, instead of snapping. - Interruptible: while a push is in flight no layer owns the controller, so a zero-size `MidPushBackCatcher` takes B alone and clears the trigger that raised the top screen — the same spring carries it back down, the way the desktop retargets its NAV spring to 0. A and the rest stay dropped until the spring has passed 0.85 of its travel (`inputOpensAfter`, 0.20 s), not until a tween has finished (0.28 s): the wall is gone, the double-tap protection stays. - The library layer's place stack rides the same spring and the same reduce-motion crossfade. --- .../Home/GamepadHomeView.swift | 62 +++++++++++++++-- .../PunktfunkClient/Home/GamepadShell.swift | 28 +++++--- .../Home/LibraryConsoleView.swift | 7 +- .../Settings/GamepadSettingsView.swift | 13 +--- .../PunktfunkShared/ConsoleContract.swift | 61 +++++++++++++++++ .../ConsoleVectorsTests.swift | 68 +++++++++++++++++-- clients/shared/console-vectors.json | 2 +- 7 files changed, 208 insertions(+), 33 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkShared/ConsoleContract.swift diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 8c1d7ed68..c988820d5 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/GamepadShell.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift index 48b520d10..f5c0e5beb 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/LibraryConsoleView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift index cdd5364c6..5762f4313 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift @@ -132,7 +132,10 @@ struct LibraryConsoleView: View { // 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(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact))) + .transition( + reduceMotion + ? .opacity + : .gamepadScreen(slide: GamepadShellMotion.slide(compact: compact))) } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { GamepadHintBar(hints: barFocused ? barHints : (top.isCollections ? collectionHints : hints)) @@ -416,7 +419,7 @@ struct LibraryConsoleView: View { /// The push/pop choreography between places — the shell's own (`GamepadShellMotion`), a plain /// crossfade under Reduce Motion. private var placeMotion: Animation { - reduceMotion ? .easeOut(duration: 0.2) : GamepadShellMotion.screen + reduceMotion ? GamepadShellMotion.reducedScreen : GamepadShellMotion.screen } /// The "start in collections" hand-over — decided once per shelf. This view is mounted only diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 17658dcef..6ff1f94dc 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 diff --git a/clients/apple/Sources/PunktfunkShared/ConsoleContract.swift b/clients/apple/Sources/PunktfunkShared/ConsoleContract.swift new file mode 100644 index 000000000..8f4cb9a59 --- /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/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift b/clients/apple/Tests/PunktfunkKitTests/ConsoleVectorsTests.swift index 03aa0f014..7ccd5c67b 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/shared/console-vectors.json b/clients/shared/console-vectors.json index 7af4fb3b8..bddc996cf 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, -- 2.54.0 From 849a991c3a951553d672c3094acd4b7aea54bdc1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 11:38:28 +0200 Subject: [PATCH 06/12] feat(clients/apple): the touch library grid learns sort and group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fifth slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-5): the plain grid draws `LibraryCollation.collate(catalog, sort, groupBy)` — launchers lead, then one section per group under the grouping, each in the chosen sort — instead of its two fixed Launchers/Games sections, with a toolbar menu for both. The sort is the shared `librarySort` key (the same one the console's bar writes); the grouping (`libraryGroupBy`, none/platform/ store) is this grid's own, because on the console grouping is a PLACE (Collections), not a mode of the shelf, and sections are the touch analogue of that place. `LibraryGridNav` already took sections, so the keyboard cursor needed no change; ungrouped and launcher-less, the grid renders exactly as it always did. --- .../PunktfunkClient/Home/LibraryView.swift | 78 +++++++++++++++---- .../PunktfunkShared/DefaultsKeys.swift | 5 ++ 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 5919b10f9..988408123 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -73,6 +73,11 @@ struct LibraryView: View { 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 @@ -129,9 +134,17 @@ struct LibraryView: View { #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 @@ -281,23 +294,34 @@ struct LibraryView: View { } #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() @@ -335,7 +359,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) } @@ -351,9 +375,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) } @@ -475,6 +499,26 @@ 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 diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 86020b1c2..e6a55859a 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -211,6 +211,11 @@ public enum DefaultsKey { /// 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 -- 2.54.0 From 25b97b50929ff7603b297ce79328e56c1aad06eb Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 12:02:34 +0200 Subject: [PATCH 07/12] =?UTF-8?q?fix(clients/apple):=20the=20grid's=20firs?= =?UTF-8?q?t=20on-glass=20pass=20=E2=80=94=20a=20stick=20that=20moved=20tw?= =?UTF-8?q?ice,=20rows=20over=20the=20chrome,=20and=20a=20menu=20for=20the?= =?UTF-8?q?=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things the iPad found in the grid arrangement (design/apple-console-ui-parity.md, PR-2/3): - "Navigation jumps at random." `GamepadMenuInput` resolved the stick per 60 Hz sample and fired a move whenever the resolved DIRECTION changed — a single flick to the right passes through samples where |y| > |x| on the way out of the dead zone, so one gesture read as UP, then RIGHT. Invisible on the carousels (their vertical axis is inert or a menu), a random jump on any 2-D field. Now an engaged direction stays engaged until ITS OWN input releases. - The grid drew over the pinned title and the legend's tray, and rows vanished while "still in view": `.scrollClipDisabled()`. Rows are clipped to the viewport again; the margins and the heading bands are what give the focused cell's pop and ring their room, and each cell carries an invisible halo inside its scroll target so a minimal scroll never shaves them. - The "focused row rides at 34 %" seat cut the row above half away after ONE step down with the whole field in reach — a jump, not a chase. A move now scrolls only as far as it must; the 34 % seat is kept for the initial restore, and the top row scrolls all the way up so the heading shows. - The legend gets the bottom tray blur every other console screen has (rows scroll under it). - "Copy link" is no longer a face-button action on a gamepad UI: X opens the title's Options menu (`LibraryTitleOptionsView` — the desktop's per-title menu: Copy link · Cancel, with the host-options idiom's title band, explainer and `A Select · B Back`); the field recedes under it the way the launcher recedes under a shell layer. Screenshot scene `11e-library-options`; the library scenes can now take `PUNKTFUNK_FAKE_LIBRARY` and `PUNKTFUNK_SHOT_INTERACTIVE=1` so the grid can be driven from a Mac keyboard. --- .../Home/LibraryConsoleView.swift | 83 +++++--- .../Home/LibraryGridView.swift | 48 +++-- .../Home/LibraryTitleOptionsView.swift | 183 ++++++++++++++++++ .../PunktfunkClient/Home/LibraryView.swift | 3 +- .../Screenshots/ScreenshotScenes.swift | 35 +++- .../Gamepad/GamepadMenuInput.swift | 17 +- 6 files changed, 326 insertions(+), 43 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkClient/Home/LibraryTitleOptionsView.swift diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift index 5762f4313..77d98a41c 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift @@ -35,9 +35,12 @@ struct LibraryConsoleView: View { var initialSelection: String? /// Button B at the shelf — dismisses the library screen. var onDismiss: (() -> Void)? - /// Button X — copy the focused title's `punktfunk://` link. nil where the platform has no - /// clipboard (tvOS), which also drops the hint. + /// 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 @@ -49,6 +52,8 @@ struct LibraryConsoleView: View { 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 @@ -74,9 +79,8 @@ struct LibraryConsoleView: View { /// 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 copy hint's acknowledgement (there is no toast on this surface — the legend says it - /// itself, and forgets it the moment the focus moves off the cover it was about). - @State private var copied = false + /// 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 @@ -96,8 +100,10 @@ struct LibraryConsoleView: View { LibraryCollation.collate(games, sort: sort, groupBy: .platform) } private var focused: GameEntry? { displayed.first { $0.id == focusID } } - /// The field owns the controller only while the bar does not. - private var fieldActive: Bool { controllerActive && !barFocused } + /// 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) @@ -136,11 +142,34 @@ struct LibraryConsoleView: View { 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) + // 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) { - GamepadHintBar(hints: barFocused ? barHints : (top.isCollections ? collectionHints : hints)) - .padding(.leading, 22) - .padding(.vertical, compact ? 6 : 10) + 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). @@ -150,15 +179,10 @@ struct LibraryConsoleView: View { // 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() - .task(id: copied) { - guard copied else { return } - try? await Task.sleep(for: .milliseconds(1600)) - withAnimation(.smooth(duration: 0.2)) { copied = false } - } - .onChange(of: focusID) { _, _ in copied = false } .onAppear { decideHandover() barFocused = barFocusedInitially + if optionsInitially, offersOptions, let first = displayed.first { optionsFor = first } wireBar() if barFocused, controllerActive { barInput.start() } } @@ -211,7 +235,7 @@ struct LibraryConsoleView: View { running: running, initialSelection: focusID ?? initialSelection, onBack: { back() }, onSecondary: { openCollections() }, - onTertiary: onCopyLink.map { copy in { copyFocused(copy) } }, + onTertiary: offersOptions ? { openOptions() } : nil, onUp: { enterBar() }, controllerActive: fieldActive) case .grid: @@ -220,7 +244,7 @@ struct LibraryConsoleView: View { running: running, initialSelection: focusID ?? initialSelection, onBack: { back() }, onSecondary: { openCollections() }, - onTertiary: onCopyLink.map { copy in { copyFocused(copy) } }, + onTertiary: offersOptions ? { openOptions() } : nil, onUp: { enterBar() }, controllerActive: fieldActive) } @@ -327,11 +351,11 @@ struct LibraryConsoleView: View { glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Collections", action: { openCollections() })) } - if let onCopyLink { + // The desktop's `X Options` — a title's own actions live in a menu, not on a face button. + if offersOptions { hints.append(.init( - glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), - text: copied ? "Copied" : "Copy link", - action: { copyFocused(onCopyLink) })) + glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Options", + action: { openOptions() })) } if showsShoulderHint { hints.append(.init( @@ -388,11 +412,16 @@ struct LibraryConsoleView: View { // MARK: - Actions - /// Hand the FOCUSED title to the copy action — read at press time, inert with nothing focused. - private func copyFocused(_ copy: (GameEntry) -> Void) { - guard let game = focused else { return } - copy(game) - withAnimation(.smooth(duration: 0.2)) { copied = true } + /// 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) { diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift index 7de3f5a6e..9797fd508 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift @@ -1,7 +1,7 @@ // 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, the focused row -// riding 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 +// 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. @@ -95,6 +95,7 @@ struct LibraryGridView: View { // entrance and focus pop are never clipped; the caption only when the // field has two groups. heading(s.split > 0 ? "LAUNCHERS" : nil, height: headingH, k: k) + .id(Self.topID) if s.split > 0 { section( games[.. some View { - LazyVGrid( + // Each cell carries an invisible vertical HALO inside the frame `scrollTo` targets, so a + // minimal scroll leaves the focused cell's ring and ×1.06 pop clear of the viewport edge + // instead of shaving them; the row spacing gives the halo back, so the pitch is unchanged. + let halo = 10 * k + return LazyVGrid( columns: Array( repeating: GridItem(.fixed(cellW), spacing: gap, alignment: .top), count: cols), - alignment: .leading, spacing: gap + labelGap + alignment: .leading, spacing: max(0, gap + labelGap - 2 * halo) ) { ForEach(entries, id: \.1.id) { index, game in #if os(tvOS) @@ -234,9 +258,11 @@ struct LibraryGridView: View { } .buttonStyle(ConsoleBareButtonStyle()) .focused($tvFocus, equals: game.id) + .padding(.vertical, halo) .id(game.id) #else cell(game, index: index, width: cellW, height: cellH, k: k, shape: shape) + .padding(.vertical, halo) .id(game.id) #endif } diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryTitleOptionsView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryTitleOptionsView.swift new file mode 100644 index 000000000..c580044b4 --- /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 988408123..3652d0003 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -229,8 +229,9 @@ struct LibraryView: View { 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, + hostName: host.displayName, controllerActive: controllerActive, onCollectionChanged: { label in collectionLabel = label diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 64cb1bd15..28daa9442 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -52,6 +52,10 @@ enum ShotScenes { 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 @@ -286,13 +290,38 @@ 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 { LibraryConsoleView( - games: ShotMock.games, artLoader: ShotPosterArt.source, - onLaunch: { _ in }, onDismiss: {}, controllerActive: false, + 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) + startInCollectionsOverride: collections, optionsInitially: options) } } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift index d162ebebd..534aef32d 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 } -- 2.54.0 From 70da960a37a49fbd472d97b9d324cd362e2ed903 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 12:22:26 +0200 Subject: [PATCH 08/12] =?UTF-8?q?fix(clients/apple):=20the=20grid=20owns?= =?UTF-8?q?=20its=20scroll=20offset=20=E2=80=94=20one=20impulse=20per=20mo?= =?UTF-8?q?ve,=20not=20two?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On glass a single step down read as two impulses: the ScrollView settled, then moved again as the lazy grid laid rows out under it. Every number in this field is fixed and known (cell, gap, margin, heading band, label air, all × k), so the grid no longer asks a ScrollView + lazy layout to guess: `GridGeometry` computes each row's place, `scrollY` is ONE sprung scalar on a plain offset (`springs::FOCUS`), the viewport clips, and rows are culled by the same arithmetic (one row of look-ahead across the whole travel) — the desktop console's own model. Moves reveal the focused cell minimally (ring and pop included), row 0 goes all the way up, a restored cursor is seated a third of the way down. A finger still drags the field on iOS; the Mac keeps keys, pad and click (no trackpad wheel on the grid — a small, named trade against the phantom impulse). --- .../Home/LibraryGridView.swift | 371 ++++++++++-------- 1 file changed, 213 insertions(+), 158 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift index 9797fd508..37cbb6ecd 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift @@ -62,9 +62,14 @@ struct LibraryGridView: View { @State private var entranceAnchor = 0 @State private var artSettled = 0 @State private var artWaitOver = false - /// True while a programmatic scroll is settling the focused row — the first scroll on mount - /// is seated (no animation), later ones are sprung. + /// 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. @@ -82,122 +87,116 @@ struct LibraryGridView: View { var body: some View { GeometryReader { geo in - let k = min(max(min(geo.size.width, geo.size.height) / 800, 0.75), 3) - let cellW = 150 * k, cellH = 225 * k, gap = 16 * k, margin = 48 * k - let headingH = 30 * k, labelGap = 10 * k - let avail = geo.size.width - 2 * margin - let colsNow = min(max(Int((avail + gap) / (cellW + gap)), 2), 8) - let s = LibraryGridShape(len: games.count, cols: colsNow, launchers: launcherCount) - ScrollViewReader { proxy in - ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 0) { - // Top inset = the heading band, UNCONDITIONALLY, so the first row's - // entrance and focus pop are never clipped; the caption only when the - // field has two groups. - heading(s.split > 0 ? "LAUNCHERS" : nil, height: headingH, k: k) - .id(Self.topID) - if s.split > 0 { - section( - games[.. 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) } - .scrollIndicators(.never) - // Clipped to the viewport, deliberately: with the clip off, rows drew over the - // pinned title and the legend's tray on glass, and the lazy grid still recycled - // them at the viewport edge — a row vanishing while "still in view". The margins - // and the heading bands are what give the focused cell's pop and ring their room. - .offset(bump) - .onAppear { - if cols != colsNow { cols = colsNow } - seed() - wire() - if controllerActive { input.start() } - // Seated, not sprung: the field opens where the cursor is. - if let id = focusID { proxy.scrollTo(id, anchor: focusAnchor) } - seated = true - armEntrance() - } - .onChange(of: colsNow) { _, c in - cols = c - // A resize re-flows the rows; keep the focused title in view, seated. - if let id = focusID { proxy.scrollTo(id, anchor: focusAnchor) } - } - .onChange(of: focusID) { _, id in - guard let id, seated else { return } - // A MOVE scrolls only as far as it must to keep the focused cell in view - // (`anchor: nil`), never to re-seat it: on glass, the desktop's "focused row - // rides at 34 %" rule cut the row above half away after ONE step down with - // the whole field still in reach — a jump, not a chase. The 34 % seat is - // kept for the initial restore, where landing mid-view is the point. - // On the top row, go all the way up so the heading band shows too. - let onTopRow = shape.cell(of: cursor).row == 0 - let scroll = { - if onTopRow { - proxy.scrollTo(Self.topID, anchor: .top) - } else { - proxy.scrollTo(id, anchor: nil) - } - } - if reduceMotion { - scroll() - } else { - // `springs::FOCUS` — the desktop's scroll chase. - withAnimation(.spring(response: 0.30, dampingFraction: 0.80)) { scroll() } + 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 @@ -229,44 +249,6 @@ struct LibraryGridView: View { } } .frame(height: height, alignment: .leading) - .padding(.vertical, 4 * k) - } - - /// One section's rows. Fixed-width columns so the two sections' cells align vertically and a - /// partial last row sits leading — exactly the shape the cursor was told. - private func section( - _ entries: [(Int, GameEntry)], cols: Int, cellW: CGFloat, cellH: CGFloat, gap: CGFloat, - labelGap: CGFloat, k: CGFloat, shape: LibraryGridShape - ) -> some View { - // Each cell carries an invisible vertical HALO inside the frame `scrollTo` targets, so a - // minimal scroll leaves the focused cell's ring and ×1.06 pop clear of the viewport edge - // instead of shaving them; the row spacing gives the halo back, so the pitch is unchanged. - let halo = 10 * k - return LazyVGrid( - columns: Array( - repeating: GridItem(.fixed(cellW), spacing: gap, alignment: .top), count: cols), - alignment: .leading, spacing: max(0, gap + labelGap - 2 * halo) - ) { - ForEach(entries, id: \.1.id) { index, game in - #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: cellW, height: cellH, k: k, shape: shape) - } - .buttonStyle(ConsoleBareButtonStyle()) - .focused($tvFocus, equals: game.id) - .padding(.vertical, halo) - .id(game.id) - #else - cell(game, index: index, width: cellW, height: cellH, k: k, shape: shape) - .padding(.vertical, halo) - .id(game.id) - #endif - } - } } private func cell( @@ -432,6 +414,79 @@ struct LibraryGridView: View { } } +/// 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) { + k = min(max(min(size.width, size.height) / 800, 0.75), 3) + cellW = 150 * k; cellH = 225 * k; gap = 16 * k; margin = 48 * k + headingH = 30 * k; labelGap = 10 * k; halo = 10 * k + let avail = size.width - 2 * margin + cols = min(max(Int((avail + gap) / (cellW + gap)), 2), 8) + 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). -- 2.54.0 From a3872ed4df5679027aa420b9932a83751cc7d58d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 12:32:56 +0200 Subject: [PATCH 09/12] =?UTF-8?q?fix(clients/apple):=20the=20view/sort=20b?= =?UTF-8?q?ar=20becomes=20a=20tray=20=E2=80=94=20down=20on=20=E2=96=B2,=20?= =?UTF-8?q?gone=20otherwise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fixed band above the field ate a third of a landscape phone and wrecked the shelf there. ▲ was already the way to the bar, so the bar is now only there when it is wanted: it slides down over the field on ▲ (or the legend's `Sort & view` cell, for a pointer) on the console's INDICATOR spring, with the top tray blur, and back up on ▼/A/B — the field keeps every point of height it has on every screen. Collections opens the same tray (sort only) on ▲. --- .../PunktfunkClient/Home/LibraryBarView.swift | 5 ++- .../Home/LibraryCollectionsView.swift | 3 ++ .../Home/LibraryConsoleView.swift | 37 ++++++++++++++----- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift index ea3b23c26..330116736 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift @@ -6,8 +6,9 @@ // // 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. Reached with ▲ from the shelf (or the grid's top row); -// while focused ◀▶ step the sort (clamped, no wrap), L1/R1 pick the arrangement outright, and +// 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. diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift index 6d3be3ce8..a2e080064 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift @@ -48,6 +48,8 @@ struct LibraryCollectionsView: View { 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 @@ -86,6 +88,7 @@ struct LibraryCollectionsView: View { onActivate: { onOpen($0.group.key) }, onSecondary: onAllTitles, onBack: onBack, + onUp: onUp, onShoulder: { right in onSortStep(right ? 1 : -1) }, isActive: controllerActive ) { tile, entrance in diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift index 77d98a41c..22d4e42e4 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift @@ -116,13 +116,6 @@ struct LibraryConsoleView: View { let top = places.top ZStack { VStack(spacing: 0) { - LibraryBarView( - sort: sort, arrangement: arrangement, focused: barFocused, - showsView: !top.isCollections, compact: compact, - onSort: { setSort($0) }, onArrangement: { setArrangement($0) } - ) - .padding(.top, compact ? 2 : 6) - .padding(.bottom, LibraryBarView.gap - 4) field .frame(maxWidth: .infinity, maxHeight: .infinity) if !top.isCollections { @@ -147,6 +140,25 @@ struct LibraryConsoleView: View { .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 { @@ -226,6 +238,7 @@ struct LibraryConsoleView: View { onAllTitles: places.offersAllTitles ? { openAllTitles() } : nil, onBack: { back() }, onSortStep: { stepSort(by: $0, wrapping: true) }, + onUp: { enterBar() }, controllerActive: fieldActive) } else { switch arrangement { @@ -505,15 +518,21 @@ struct LibraryConsoleView: View { // 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(.smooth(duration: 0.18)) { barFocused = true } + withAnimation(trayMotion) { barFocused = true } } private func leaveBar() { guard barFocused else { return } - withAnimation(.smooth(duration: 0.18)) { barFocused = false } + withAnimation(trayMotion) { barFocused = false } } private func wireBar() { -- 2.54.0 From 92f953fa3901361e9648434bce967dd7cef9cc56 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 12:49:20 +0200 Subject: [PATCH 10/12] =?UTF-8?q?fix(clients/apple):=20the=20library=20in?= =?UTF-8?q?=20a=20phone's=20height=20=E2=80=94=20rendered=20in=20the=20iPh?= =?UTF-8?q?one=20simulator,=20then=20made=20to=20fit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shelf's detail band loses its `STORE · PLATFORM` line (the cover's chip already says it, and the line only made the band tight); the grid keeps it except in a landscape phone's height. Compact height, from renders in the iPhone 16 Pro Max simulator: the grid's k floor drops from 0.75 (the Deck's) to 0.5 so a ~290-pt field holds two rows instead of one, with heading bands and captions floored at legibility; the coverflow holds a little cover height back so the title has air; the collection tile's deck shrinks to clear its label rail; the shoulder hint hides on any phone (a Pro Max in landscape is regular WIDTH — the rule is height-aware now) and Collections advertises `↑ Sort` in its place; a portrait phone's legend keeps A/Y/B (X and ▲ still work) and the sort/view tray stacks its two groups when the row won't fit. --- .../PunktfunkClient/Home/LibraryBarView.swift | 44 +++++++++------ .../Home/LibraryCollectionsView.swift | 6 ++- .../Home/LibraryConsoleView.swift | 54 +++++++++++++------ .../Home/LibraryCoverflowView.swift | 12 ++++- .../Home/LibraryGridView.swift | 12 +++-- 5 files changed, 92 insertions(+), 36 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift index 330116736..689c09eb9 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryBarView.swift @@ -38,21 +38,17 @@ struct LibraryBarView: View { static let gap: CGFloat = 12 var body: some View { - HStack(spacing: 0) { - group(caption: "SORT") { - ForEach(LibrarySortKey.all, id: \.self) { key in - pill(key.label, selected: key == sort, namespace: sortHighlight) { onSort(key) } - } + // 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 } } - Spacer(minLength: 12) - if showsView { - group(caption: "VIEW") { - ForEach(LibraryArrangement.all, id: \.self) { view in - pill(view.label, selected: view == arrangement, namespace: viewHighlight) { - onArrangement(view) - } - } - } + VStack(alignment: .leading, spacing: 6) { + sortGroup + if showsView { viewGroup } } } .padding(.horizontal, 12) @@ -66,7 +62,25 @@ struct LibraryBarView: View { } .animation(.smooth(duration: 0.18), value: focused) .padding(.horizontal, 12) - .frame(height: Self.bandHeight) + .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. diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift index a2e080064..daffbc024 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCollectionsView.swift @@ -105,7 +105,7 @@ struct LibraryCollectionsView: View { /// 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) + 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)) @@ -127,6 +127,8 @@ private struct CollectionTileView: View { 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 @@ -196,7 +198,7 @@ private struct CollectionTileView: View { /// Up to three covers from the group's sort-first titles, fanned back-to-front. private var deck: some View { - let coverH = Self.coverH + 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. diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift index 22d4e42e4..89557e48e 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryConsoleView.swift @@ -120,12 +120,12 @@ struct LibraryConsoleView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) if !top.isCollections { detailPanel - .padding(.top, 8) - .padding(.bottom, compact ? 4 : 10) + .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 ? 44 : 60) + Color.clear.frame(height: compact ? 30 : 60) } } // A push/pop is a state change inside `placeMotion` (see the mutations below), so the @@ -266,14 +266,20 @@ struct LibraryConsoleView: View { // MARK: - Detail band - /// The focused title + its provenance — empty (not hidden) so the layout doesn't jump. The - /// staleness note shares the subtitle line, leading, the way the desktop shelf draws it: the - /// titles stay, the wording says where they came from. + /// 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: 6) { + VStack(spacing: compact ? 3 : 6) { Text(game?.title ?? " ") - .font(.geist(compact ? 22 : 25, .bold, relativeTo: .title)) + .font(.geist(compact ? 20 : 25, .bold, relativeTo: .title)) .foregroundStyle(ink.fg) .lineLimit(1) .minimumScaleFactor(0.75) @@ -296,7 +302,7 @@ struct LibraryConsoleView: View { } } .frame(maxWidth: .infinity, alignment: .leading) - if let game { + if let game, showsSubtitle { Text(subtitle(for: game)) .font(.geist(11, .semibold, relativeTo: .caption2)) .tracking(1.2) @@ -306,6 +312,8 @@ struct LibraryConsoleView: View { } 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) @@ -326,19 +334,29 @@ struct LibraryConsoleView: View { // MARK: - Legend - /// Whether the legend advertises the shoulder jump. Held back on an iPhone, whose legend is - /// already at its width; never on tvOS (a Siri Remote has no shoulders) — the settings - /// strip's own rule. + /// 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 + 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] { @@ -365,7 +383,7 @@ struct LibraryConsoleView: View { action: { openCollections() })) } // The desktop's `X Options` — a title's own actions live in a menu, not on a face button. - if offersOptions { + if offersOptions, !narrowLegend { hints.append(.init( glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Options", action: { openOptions() })) @@ -375,7 +393,9 @@ struct LibraryConsoleView: View { glyph: buttonGlyph(\.leftShoulder, fallback: "l1.rectangle.roundedbottom"), text: "Jump")) } - hints.append(.init(glyph: "arrow.up", text: "Sort & view", action: { enterBar() })) + 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?() })) @@ -399,10 +419,14 @@ struct LibraryConsoleView: View { 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", diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 293808703..3186f2a3f 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -49,6 +49,14 @@ struct LibraryCoverflowView: View { /// focus, and the shell gates it mid-transition and under the connect takeover. var controllerActive = true + #if os(iOS) + /// `.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 + /// 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. @@ -77,7 +85,9 @@ struct LibraryCoverflowView: View { // 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 = 8 + (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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift index 37cbb6ecd..19ba16193 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift @@ -242,8 +242,9 @@ struct LibraryGridView: View { 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(11 * k, .semibold, relativeTo: .caption2)) + .font(.geist(max(9, 11 * k), .semibold, relativeTo: .caption2)) .tracking(1.4) .foregroundStyle(ink.fg(0.45)) } @@ -429,9 +430,14 @@ private struct GridGeometry { let viewH: CGFloat init(size: CGSize, len: Int, launchers: Int) { - k = min(max(min(size.width, size.height) / 800, 0.75), 3) + // The desktop clamps k at 0.75 (its smallest field is a Deck's 800). A landscape phone's + // field is ~290 pt tall; at 0.75 one row of 169-pt cells filled it. 0.5 (75×112 cells) + // gets two rows in — a grid, not a strip. + k = min(max(min(size.width, size.height) / 800, 0.5), 3) cellW = 150 * k; cellH = 225 * k; gap = 16 * k; margin = 48 * k - headingH = 30 * k; labelGap = 10 * k; halo = 10 * 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(24, 30 * k); labelGap = 10 * k; halo = max(6, 10 * k) let avail = size.width - 2 * margin cols = min(max(Int((avail + gap) / (cellW + gap)), 2), 8) shape = LibraryGridShape(len: len, cols: cols, launchers: launchers) -- 2.54.0 From f5a5d19a3d24d6eb3e70ba18e275f680f8747aa2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 12:59:39 +0200 Subject: [PATCH 11/12] fix(clients/apple): the grid fills the field's width, and the keyboard's field comes down to the keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid: the columns now stretch to fill the safe-area width (whatever the last cell would have left over is shared out, cells keep 2:3) instead of stopping short of the right edge — on a phone the slack was a fifth of the width — and the k floor goes back to the Deck's 0.75, so the covers stay poster-sized and the field scrolls; the heading band gets a floor that clears the focused cell's ring at every k. Keyboard: the on-screen keyboard tray covered whatever field rows happened to be under it. While a row is being typed into it now flies from its place in the list to a seat directly above the keys (matched geometry, the tray's spring) and back when the keyboard closes; its slot in the list stays empty meanwhile so nothing reflows. Same in the pair ceremony. Shot scene `08b-gamepad-addhost-typing` (`PUNKTFUNK_SHOT_EDITING=`) renders it. --- .../Home/GamepadAddHostView.swift | 77 +++++++++++++------ .../Home/LibraryGridView.swift | 24 +++--- .../Screenshots/ScreenshotScenes.swift | 5 ++ .../Trust/GamepadPairView.swift | 54 ++++++++----- 4 files changed, 110 insertions(+), 50 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 9e039b68b..563df682b 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,10 @@ 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. + .matchedGeometryEffect(id: row.id, in: fieldFlight, isSource: editing != row.id) + .opacity(editing == row.id ? 0 : 1) } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { @@ -173,29 +190,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) + .frame(maxWidth: .infinity) + .matchedGeometryEffect(id: row.id, in: fieldFlight, isSource: true) + .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/LibraryGridView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift index 19ba16193..ef2bf70ed 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryGridView.swift @@ -14,8 +14,9 @@ // 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. A Deck-shaped -// window gets 7, an iPad in landscape 6, a phone in landscape 6 small ones, an Apple TV 8. +// `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 @@ -430,16 +431,21 @@ private struct GridGeometry { let viewH: CGFloat init(size: CGSize, len: Int, launchers: Int) { - // The desktop clamps k at 0.75 (its smallest field is a Deck's 800). A landscape phone's - // field is ~290 pt tall; at 0.75 one row of 169-pt cells filled it. 0.5 (75×112 cells) - // gets two rows in — a grid, not a strip. - k = min(max(min(size.width, size.height) / 800, 0.5), 3) - cellW = 150 * k; cellH = 225 * k; gap = 16 * k; margin = 48 * k + // 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(24, 30 * k); labelGap = 10 * k; halo = max(6, 10 * k) + headingH = max(32, 30 * k); labelGap = 10 * k; halo = max(8, 10 * k) let avail = size.width - 2 * margin - cols = min(max(Int((avail + gap) / (cellW + gap)), 2), 8) + 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 } diff --git a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift index 28daa9442..8852073ec 100644 --- a/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift +++ b/clients/apple/Sources/PunktfunkClient/Screenshots/ScreenshotScenes.swift @@ -70,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)) }, diff --git a/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift index 2dc974e25..460560626 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,10 @@ 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. + .matchedGeometryEffect(id: row.id, in: fieldFlight, isSource: editing != row.id) + .opacity(editing == row.id ? 0 : 1) } .frame(maxWidth: .infinity) .safeAreaInset(edge: .top, spacing: 0) { @@ -144,25 +150,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) + .frame(maxWidth: .infinity) + .matchedGeometryEffect(id: row.id, in: fieldFlight, isSource: true) + .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 -- 2.54.0 From a7eadbb68072a3c7aca824cb075a1e662a2934c8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 18 Aug 2026 13:14:34 +0200 Subject: [PATCH 12/12] =?UTF-8?q?fix(clients/apple):=20the=20keyboard=20tr?= =?UTF-8?q?ay=20=E2=80=94=20a=20flight=20that=20lands,=20hardware=20typing?= =?UTF-8?q?,=20and=20Esc=20that=20means=20Done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The seated row's flight went sideways off-screen while fading: two live matched views with the source flag swapped sent the INVISIBLE list row flying, and the seated row merely appeared. Now exactly one matched view per id at any time — the list row hands its frame to an invisible provider that unmounts when editing starts, so the inserted seated row animates in from it (and its removal flies back). Verified frame by frame on the Mac. - A hardware keyboard types straight into the field while the tray is up (an iPad on a Magic Keyboard, a Mac): characters insert (the field's own `allowed` set is the rule — a capital or an umlaut the on-screen grid doesn't offer is fine), ⌫ deletes, arrows drive the key cursor, Return and Esc are Done, ⌘-anything is left to the system. No text field, so no system keyboard is ever raised. State writes are deferred off the key-event dispatch — written synchronously they re-rendered the screen mid-delivery and a fast burst lost every other keystroke. - Esc while typing used to close the whole screen (the screen's cancel shortcut fired first); that shortcut is inactive while the tray is up, and Esc is the tray's Done. --- .../Home/GamepadAddHostView.swift | 30 ++++++--- .../Home/GamepadKeyboard.swift | 64 ++++++++++++++++++- .../Trust/GamepadPairView.swift | 30 ++++++--- 3 files changed, 107 insertions(+), 17 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index 563df682b..cf89d7f9d 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -82,8 +82,18 @@ struct GamepadAddHostView: View { .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. - .matchedGeometryEffect(id: row.id, in: fieldFlight, isSource: editing != row.id) .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) { @@ -148,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) @@ -198,8 +212,8 @@ struct GamepadAddHostView: View { rowView(row, focused: true) .frame(maxWidth: metrics.rowMaxWidth) .padding(.horizontal, 24) + .matchedGeometryEffect(id: row.id, in: fieldFlight) .frame(maxWidth: .infinity) - .matchedGeometryEffect(id: row.id, in: fieldFlight, isSource: true) .transition(.opacity) } VStack(spacing: 10) { diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift index 66d67eceb..9bc0ad69d 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/Trust/GamepadPairView.swift b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift index 460560626..ae9f16ae7 100644 --- a/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/GamepadPairView.swift @@ -72,8 +72,18 @@ struct GamepadPairView: View { .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. - .matchedGeometryEffect(id: row.id, in: fieldFlight, isSource: editing != row.id) .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) { @@ -113,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) + } } } @@ -156,8 +170,8 @@ struct GamepadPairView: View { rowView(row, focused: true) .frame(maxWidth: metrics.rowMaxWidth) .padding(.horizontal, 24) + .matchedGeometryEffect(id: row.id, in: fieldFlight) .frame(maxWidth: .infinity) - .matchedGeometryEffect(id: row.id, in: fieldFlight, isSource: true) .transition(.opacity) } VStack(spacing: 10) { -- 2.54.0