From 883c3178721bc8151d9541b47ba485ce1e69d5c9 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 14:35:24 +0200 Subject: [PATCH] feat(clients/library): a launcher tile looks like one, on every client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host has been able to describe a launcher entry since M2 — `role: "launcher"`, the `steam_ui` and `launcher_ui` kinds — and the web console has grouped them into their own rail since M4. No other client ever looked. `pf-client-core` decoded `role` into an `is_launcher()` helper with zero call sites, and the shared console model dropped the field entirely on its way to the renderer. So a launcher tile arrived everywhere else as an ordinary game with no cover art: indistinguishable from a title whose poster failed to load, sorted into the middle of the alphabet, and captioned "Play". One contract, implemented in each client's own idiom: * launchers never interleave with titles — they lead, and each group keeps the host's title order * grid surfaces get a labelled section; a coverflow keeps its single carousel and names the group the cursor is in, changing as it crosses the boundary. A second focus rail would mean a new up/down nav model in three renderers for two or three tiles * an art-less launcher gets an accent face naming its launcher, not a title monogram on the neutral one — "opens Steam", not "a cover that didn't load" * anything that is not `"launcher"` is a game, and a host that omits the field renders exactly as before (design D4's intended degradation) * launching is unchanged: the client sends an id, the host resolves the recipe The grouping is enforced once per client stack rather than per screen. In the console UI it is an invariant of `LibraryShared::set_games`, so the cursor arithmetic, the art pump and every future consumer inherit it; on Apple and Android it is applied where the library is fetched/parsed. Fixed in passing: the Apple and Android store badges were hard-coded `isCustom ? "Custom" : "Steam"`, so every Lutris, GOG, Heroic, Epic and Xbox title was labelled "Steam". Both now carry the same store table the Rust clients use. The CLI's `--library` gains a fourth column (`game`/`launcher`), appended rather than folded into an existing one so anything reading the first three is untouched. Gates: punktfunk-host 436 passed / 0 failed and pf-console-ui 49 passed / 0 failed on .21 (three new tests), workspace clippy -D warnings and cargo fmt --check clean there; `swift build` of the full PunktfunkClient and `:app:compileDebugKotlin` clean on macOS; `cargo check` + `clippy -D warnings` for the Windows client on .173. Still unproven on hardware: no launcher tile has been clicked on a real host — that needs the plugins published, which needs this branch's base merged first. --- .../kotlin/io/unom/punktfunk/LibraryScreen.kt | 31 ++++- .../io/unom/punktfunk/kit/library/Library.kt | 49 +++++++- .../Home/LibraryCoverflowView.swift | 42 +++++-- .../PunktfunkClient/Home/LibraryView.swift | 53 ++++++-- .../PunktfunkClient/Home/LibraryWidgets.swift | 13 +- .../Connection/LibraryClient.swift | 36 +++++- clients/linux/src/app.rs | 7 ++ clients/linux/src/cli.rs | 12 +- clients/linux/src/ui_library.rs | 77 +++++++++++- clients/session/src/console.rs | 2 + clients/windows/src/app/library.rs | 113 +++++++++++++----- crates/pf-console-ui/src/library.rs | 59 +++++++++ crates/pf-console-ui/src/screens/library.rs | 93 ++++++++++++-- crates/pf-console-ui/src/shell/tests.rs | 1 + crates/punktfunk-host/src/library/custom.rs | 43 ++++++- 15 files changed, 554 insertions(+), 77 deletions(-) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 93939b23..c4d18801 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -241,7 +241,22 @@ private fun Coverflow( onActivate = { games.getOrNull(navTarget)?.let(onLaunch) }, ) + // Design D4: the launcher entries lead the strip (the client groups them at parse time). + // A coverflow is one-dimensional, so instead of a second focus rail the heading names the + // group the cursor is in and changes as it crosses the boundary. Only drawn when the + // library actually has both groups — otherwise the screen is exactly what it was. + val bothGroups = games.any { it.isLauncher } && games.any { !it.isLauncher } Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) { + if (bothGroups) { + Text( + if (current?.isLauncher == true) "LAUNCHERS" else "GAMES", + style = MaterialTheme.typography.labelSmall, + color = Color.White.copy(alpha = 0.45f), + letterSpacing = 2.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) + } HorizontalPager( state = pagerState, pageSize = PageSize.Fixed(coverWidth), @@ -305,7 +320,8 @@ private fun Coverflow( ) if (current != null) { Text( - if (current.isCustom) "CUSTOM" else "STEAM", + if (current.isLauncher) "${current.storeLabel.uppercase()} \u00B7 LAUNCHER" + else current.storeLabel.uppercase(), style = MaterialTheme.typography.labelMedium, color = Color.White.copy(alpha = 0.5f), letterSpacing = 2.sp, @@ -339,8 +355,10 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder ) } else { + // A launcher rarely has poster art. Naming the launcher says "opens Steam"; the title + // would read as "a game whose cover failed to load". Text( - game.title, + if (game.isLauncher) game.storeLabel else game.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = Color.White.copy(alpha = 0.75f), @@ -348,15 +366,18 @@ private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Mo modifier = Modifier.padding(12.dp), ) } - // Store badge, top-start. + // Store badge, top-start — brand-filled for a launcher entry (design D4). Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) { Text( - if (game.isCustom) "Custom" else "Steam", + game.storeLabel, style = MaterialTheme.typography.labelSmall, color = Color.White, modifier = Modifier .clip(RoundedCornerShape(50)) - .background(Color.Black.copy(alpha = 0.5f)) + .background( + if (game.isLauncher) MaterialTheme.colorScheme.primary + else Color.Black.copy(alpha = 0.5f), + ) .padding(horizontal = 8.dp, vertical = 3.dp), ) } diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt index 6088df8a..3dfbeeb2 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/library/Library.kt @@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String? val posterCandidates: List get() = listOfNotNull(portrait, header, hero) } -/** One title in the unified library. [id] is store-qualified (`steam:` / `custom:`). */ -data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) { +/** + * One title in the unified library. [id] is store-qualified (`steam:` / `custom:`). + * + * [role] is `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that + * opens the launcher itself (Steam Big Picture, Heroic) rather than a title. Kept a plain nullable + * String on purpose: the host owns the vocabulary, and an unknown future value must degrade to a + * game rather than break the decode (design D4). + */ +data class GameEntry( + val id: String, + val store: String, + val title: String, + val art: Artwork, + val role: String? = null, +) { val isCustom: Boolean get() = store == "custom" + + /** Whether this entry opens a launcher rather than a game. */ + val isLauncher: Boolean get() = role == "launcher" + + /** + * Display name for the store badge — the same table the other clients use + * (`pf-console-ui::library::store_label`). Before this the UI said "Steam" for every non-custom + * entry, which a Lutris or GOG title made a lie. + */ + val storeLabel: String get() = when (store) { + "steam" -> "Steam" + "custom" -> "Custom" + "heroic" -> "Heroic" + "lutris" -> "Lutris" + "epic" -> "Epic" + "gog" -> "GOG" + "xbox" -> "Xbox" + else -> "Game" + } +} + +/** + * Design D4: launcher entries lead the shelf, keeping the host's title order within each group. + * Applied once where the library is fetched, so no screen has to remember the rule — and a library + * without launcher entries comes back untouched. + */ +fun List.launchersFirst(): List { + val launchers = filter { it.isLauncher } + return if (launchers.isEmpty()) this else launchers + filterNot { it.isLauncher } } /** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */ @@ -108,10 +150,11 @@ object LibraryClient { header = resolveArt(str(art, "header"), base), hero = resolveArt(str(art, "hero"), base), ), + role = str(o, "role"), ), ) } - return out + return out.launchersFirst() } /** A present, non-null, non-blank JSON string field, else null. */ diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 0c7248fd..e4d3593f 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -52,12 +52,15 @@ struct LibraryCoverflowView: 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 // clamped by width on a narrow screen. - let reserved: CGFloat = compact ? 72 : 96 // detail line + spacers + let reserved: CGFloat = (compact ? 72 : 96) + (showsGroupHeading ? 26 : 0) let coverHeight = min(360, min(max(140, size.height - reserved), size.width * 0.9)) let coverWidth = coverHeight * 2 / 3 VStack(spacing: 0) { Spacer(minLength: 4) + if showsGroupHeading { + groupHeading.padding(.bottom, 6) + } carousel(coverWidth: coverWidth, coverHeight: coverHeight) detailPanel .padding(.top, 12) @@ -89,7 +92,9 @@ struct LibraryCoverflowView: View { PosterImage(candidates: game.art.posterCandidates, title: game.title, session: imageSession) .frame(width: width, height: height) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) } + .overlay(alignment: .topLeading) { + StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher) + } .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(.white.opacity(0.12), lineWidth: 1) @@ -112,6 +117,23 @@ struct LibraryCoverflowView: View { } } + /// 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 { + games.contains(where: \.isLauncher) && games.contains { !$0.isLauncher } + } + + /// Which group the cursor is in. A coverflow is one-dimensional, so instead of a second focus + /// 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 } + return Text(selected?.isLauncher == true ? "LAUNCHERS" : "GAMES") + .font(.geist(11, .semibold, relativeTo: .caption2)) + .tracking(1.4) + .foregroundStyle(.white.opacity(0.45)) + } + /// The centered title + store tag — empty (not hidden) so the layout doesn't jump. @ViewBuilder private var detailPanel: some View { let game = games.first { $0.id == selection } @@ -123,10 +145,13 @@ struct LibraryCoverflowView: View { .minimumScaleFactor(0.75) .multilineTextAlignment(.center) if let game { - Text(game.isCustom ? "CUSTOM" : "STEAM") - .font(.geist(11, .semibold, relativeTo: .caption2)) - .tracking(1.2) - .foregroundStyle(.white.opacity(0.5)) + Text( + game.isLauncher + ? "\(game.storeLabel.uppercased()) · LAUNCHER" : game.storeLabel.uppercased() + ) + .font(.geist(11, .semibold, relativeTo: .caption2)) + .tracking(1.2) + .foregroundStyle(.white.opacity(0.5)) } } .frame(maxWidth: .infinity) @@ -139,7 +164,10 @@ struct LibraryCoverflowView: View { private var hints: [GamepadHint] { var hints: [GamepadHint] = [] if onLaunch != nil { - hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch")) + // 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")) } hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close")) return hints diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 8945e2d1..03a2e918 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -80,21 +80,47 @@ struct LibraryView: View { } private var grid: some View { - ScrollView { - LazyVGrid(columns: columns, spacing: 18) { - ForEach(games) { game in - if let onLaunch { - Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) } - .buttonStyle(.plain) - } else { - GameCard(game: game, imageSession: imageSession) - } + // 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 = games.filter(\.isLauncher) + let titles = games.filter { !$0.isLauncher } + let both = !launchers.isEmpty && !titles.isEmpty + return ScrollView { + VStack(alignment: .leading, spacing: 18) { + if !launchers.isEmpty { + if both { sectionHeader("Launchers") } + tiles(launchers) + } + if !titles.isEmpty { + if both { sectionHeader("Games") } + tiles(titles) } } .padding() } } + private func tiles(_ entries: [GameEntry]) -> some View { + LazyVGrid(columns: columns, spacing: 18) { + ForEach(entries) { game in + if let onLaunch { + Button { onLaunch(game.id) } label: { GameCard(game: game, imageSession: imageSession) } + .buttonStyle(.plain) + } else { + GameCard(game: game, imageSession: imageSession) + } + } + } + } + + private func sectionHeader(_ text: String) -> some View { + Text(text) + .font(.geist(12, .semibold, relativeTo: .caption)) + .tracking(1.1) + .foregroundStyle(.secondary) + } + private var columns: [GridItem] { #if os(tvOS) let minW: CGFloat = 220 @@ -152,12 +178,15 @@ struct LibraryView: View { return } do { + // `launchersFirst` groups launcher entries ahead of titles once, here, so the grid and + // the gamepad coverflow both inherit the D4 ordering. games = try await LibraryClient.fetch( address: current.address, port: current.effectiveMgmtPort, certPEM: identity.certPEM, keyPEM: identity.keyPEM, - hostFingerprint: current.pinnedSHA256) + hostFingerprint: current.pinnedSHA256 + ).launchersFirst imageSession?.finishTasksAndInvalidate() imageSession = try LibraryImageLoader.session( address: current.address, @@ -185,7 +214,9 @@ private struct GameCard: View { .aspectRatio(2.0 / 3.0, contentMode: .fit) .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay(alignment: .topLeading) { StoreBadge(isCustom: game.isCustom) } + .overlay(alignment: .topLeading) { + StoreBadge(label: game.storeLabel, isLauncher: game.isLauncher) + } Text(game.title) .font(.geist(12, relativeTo: .caption)) .lineLimit(2) diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift index 77548a4d..79d2219d 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -12,14 +12,21 @@ import AppKit /// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster — /// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell. struct StoreBadge: View { - let isCustom: Bool + /// Which store surfaced the entry, already resolved to a display name (`GameEntry.storeLabel`). + let label: String + /// A launcher entry (design D4) gets the brand fill, so "opens Steam" is legible at poster size + /// without reading the title. + var isLauncher: Bool = false var body: some View { - Text(isCustom ? "Custom" : "Steam") + Text(label) .font(.geist(11, .semibold, relativeTo: .caption2)) + .foregroundStyle(isLauncher ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) .padding(.horizontal, 6) .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) + .background( + isLauncher ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.ultraThinMaterial), + in: Capsule()) .padding(6) } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift index 6a7f459a..b7024989 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift @@ -38,12 +38,46 @@ public struct LaunchSpec: Codable, Hashable, Sendable { /// One title in the unified library. `id` is store-qualified: `steam:` / `custom:`. public struct GameEntry: Codable, Hashable, Identifiable, Sendable { public var id: String - public var store: String // "steam" | "custom" + public var store: String // "steam" | "custom" | "lutris" | "heroic" | "epic" | "gog" | "xbox" public var title: String public var art: Artwork public var launch: LaunchSpec? + /// `"game"` (the default, and what an older host omits) or `"launcher"` — an entry that opens + /// the launcher itself (Steam Big Picture, Heroic) rather than a title. Deliberately a plain + /// optional String: the host owns the vocabulary, and an unknown future value must never fail + /// the whole library decode. Anything that isn't `"launcher"` is a game (design D4). + public var role: String? public var isCustom: Bool { store == "custom" } + + /// Whether this entry opens a launcher rather than a game. + public var isLauncher: Bool { role == "launcher" } + + /// Display name for the store badge — the same table the Rust clients use + /// (`pf-console-ui::library::store_label`). Before this existed the badge said "Steam" for + /// every non-custom entry, which a Lutris or GOG title made a lie. + public var storeLabel: String { + switch store { + case "steam": return "Steam" + case "custom": return "Custom" + case "heroic": return "Heroic" + case "lutris": return "Lutris" + case "epic": return "Epic" + case "gog": return "GOG" + case "xbox": return "Xbox" + default: return "Game" + } + } +} + +public extension Array where Element == GameEntry { + /// Design D4: launcher entries lead the shelf, and the host's title order survives within each + /// group. Applied once where the library is fetched, so no individual view has to remember + /// the rule — and a library without launcher entries comes back untouched. + var launchersFirst: [GameEntry] { + let launchers = filter(\.isLauncher) + return launchers.isEmpty ? self : launchers + filter { !$0.isLauncher } + } } /// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet"). diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 6d23c499..a75bf9de 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -61,6 +61,13 @@ const CSS: &str = " .pf-poster { border-radius: 10px; background: alpha(currentColor, 0.08); } .pf-poster-monogram { font-size: 2.4em; font-weight: bold; color: alpha(currentColor, 0.45); } .pf-store-badge { color: white; background: rgba(0, 0, 0, 0.55); } +/* Launcher entries (design D4) open the launcher itself. They rarely have poster art, so an + art-less one must not read as a game whose cover failed to load: accent face, the launcher + named instead of a title monogram, and an accent badge. */ +.pf-poster.pf-launcher { background: alpha(@accent_color, 0.18); } +.pf-poster-launcher-name { font-size: 1.15em; font-weight: bold; color: alpha(currentColor, 0.85); } +.pf-store-badge.pf-launcher { color: white; background: @accent_color; } +.pf-group-heading { font-size: 0.8em; font-weight: bold; color: alpha(currentColor, 0.55); } "; /// Everything the shell shares below the component tree. diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index ce558a22..2ab415d9 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -204,10 +204,18 @@ pub fn headless_library(target: &str) -> glib::ExitCode { }); match crate::library::fetch_games(&addr, port, &identity, pin) { Ok(games) => { + // A fourth column, appended: `game` or `launcher` (design D4). Appended rather than + // folded into an existing field so anything reading the first three columns is + // untouched. for g in &games { - println!("{}\t{}\t{}", g.id, g.store, g.title); + let role = if g.is_launcher() { "launcher" } else { "game" }; + println!("{}\t{}\t{}\t{}", g.id, g.store, g.title, role); + } + let launchers = games.iter().filter(|g| g.is_launcher()).count(); + match launchers { + 0 => println!("{} game(s)", games.len()), + n => println!("{} game(s), {} launcher(s)", games.len() - n, n), } - println!("{} game(s)", games.len()); glib::ExitCode::SUCCESS } Err(e) => { diff --git a/clients/linux/src/ui_library.rs b/clients/linux/src/ui_library.rs index 62c278e5..be1d45d7 100644 --- a/clients/linux/src/ui_library.rs +++ b/clients/linux/src/ui_library.rs @@ -28,6 +28,12 @@ struct State { req: ConnectRequest, stack: gtk::Stack, flow: gtk::FlowBox, + /// Launcher entries (design D4) get their own shelf above the games, so a handful of ways to + /// open a launcher aren't buried in a 400-title grid. Hidden outright when there are none. + launcher_flow: gtk::FlowBox, + launchers_group: gtk::Box, + /// The "Games" heading — only earns its space once a Launchers shelf is above it. + games_heading: gtk::Label, error_page: adw::StatusPage, /// Per-page poster cache (entry id → texture) — a Retry re-renders without refetching. art: RefCell>, @@ -94,11 +100,44 @@ fn build( flow.connect_child_activated(|_, child| { child.activate(); }); + // The launcher shelf: same tile geometry as the games grid, its own FlowBox so the two + // groups never interleave and each wraps on its own. + let launcher_flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .activate_on_single_click(true) + .homogeneous(true) + .min_children_per_line(2) + .max_children_per_line(6) + .column_spacing(12) + .row_spacing(18) + .valign(gtk::Align::Start) + .build(); + launcher_flow.connect_child_activated(|_, child| { + child.activate(); + }); + let launchers_heading = gtk::Label::new(Some("Launchers")); + launchers_heading.add_css_class("pf-group-heading"); + launchers_heading.set_halign(gtk::Align::Start); + launchers_heading.set_margin_bottom(8); + let launchers_group = gtk::Box::new(gtk::Orientation::Vertical, 0); + launchers_group.append(&launchers_heading); + launchers_group.append(&launcher_flow); + launchers_group.set_margin_bottom(24); + launchers_group.set_visible(false); + + let games_heading = gtk::Label::new(Some("Games")); + games_heading.add_css_class("pf-group-heading"); + games_heading.set_halign(gtk::Align::Start); + games_heading.set_margin_bottom(8); + games_heading.set_visible(false); + let content = gtk::Box::new(gtk::Orientation::Vertical, 0); content.set_margin_top(24); content.set_margin_bottom(24); content.set_margin_start(12); content.set_margin_end(12); + content.append(&launchers_group); + content.append(&games_heading); content.append(&flow); let clamp = adw::Clamp::builder() .maximum_size(1100) @@ -166,6 +205,9 @@ fn build( req, stack, flow, + launcher_flow, + launchers_group, + games_heading, error_page, art: RefCell::new(HashMap::new()), pics: RefCell::new(HashMap::new()), @@ -224,18 +266,41 @@ fn load(state: &Rc) { /// immediately; the rest keep their monogram placeholder until `load_art` delivers. fn render(state: &Rc, games: &[GameEntry]) { state.flow.remove_all(); + state.launcher_flow.remove_all(); state.pics.borrow_mut().clear(); - for game in games { + // Design D4: launchers never interleave with titles. The host already sorts by title, and + // `partition` is stable, so each group keeps that order. + let (launchers, titles): (Vec<&GameEntry>, Vec<&GameEntry>) = + games.iter().partition(|g| g.is_launcher()); + for game in &launchers { + state.launcher_flow.append(&game_card(state, game)); + } + for game in &titles { state.flow.append(&game_card(state, game)); } + // A library with no launcher entries looks exactly as it did before this existed. + state.launchers_group.set_visible(!launchers.is_empty()); + state + .games_heading + .set_visible(!launchers.is_empty() && !titles.is_empty()); } /// One poster tile: 2:3 art (~150×225 logical) over the title, with a store badge and a /// monogram placeholder underneath the async art. Activation starts a session launching /// this title (silent on a pinned host — the normal trust gate applies). fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { - let monogram = gtk::Label::new(Some(&initials(&game.title))); - monogram.add_css_class("pf-poster-monogram"); + // A launcher usually ships no poster. Naming the launcher on an accent face says "opens + // Steam"; a title monogram on the neutral face would say "a game whose cover didn't load". + let launcher = game.is_launcher(); + let monogram = if launcher { + let l = gtk::Label::new(Some(store_label(&game.store))); + l.add_css_class("pf-poster-launcher-name"); + l + } else { + let l = gtk::Label::new(Some(&initials(&game.title))); + l.add_css_class("pf-poster-monogram"); + l + }; monogram.set_halign(gtk::Align::Center); monogram.set_valign(gtk::Align::Center); let placeholder = gtk::Box::new(gtk::Orientation::Vertical, 0); @@ -252,6 +317,9 @@ fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { let badge = gtk::Label::new(Some(store_label(&game.store))); badge.add_css_class("pf-pill"); badge.add_css_class("pf-store-badge"); + if launcher { + badge.add_css_class("pf-launcher"); + } badge.set_halign(gtk::Align::Start); badge.set_valign(gtk::Align::Start); badge.set_margin_start(6); @@ -262,6 +330,9 @@ fn game_card(state: &Rc, game: &GameEntry) -> gtk::FlowBoxChild { poster.add_overlay(&pic); poster.add_overlay(&badge); poster.add_css_class("pf-poster"); + if launcher { + poster.add_css_class("pf-launcher"); + } poster.set_overflow(gtk::Overflow::Hidden); poster.set_size_request(150, 225); poster.set_halign(gtk::Align::Center); diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index a424d70a..907ff9db 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -791,6 +791,7 @@ fn spawn_fetch( id: g.id.clone(), title: g.title.clone(), store: g.store.clone(), + launcher: g.is_launcher(), }) .collect(), ); @@ -831,6 +832,7 @@ fn load_fake(shared: &LibraryShared, path: &str) { id: g.id.clone(), title: g.title.clone(), store: g.store.clone(), + launcher: g.is_launcher(), }) .collect(), ); diff --git a/clients/windows/src/app/library.rs b/clients/windows/src/app/library.rs index 773eaf86..42561c44 100644 --- a/clients/windows/src/app/library.rs +++ b/clients/windows/src/app/library.rs @@ -39,6 +39,10 @@ pub(crate) struct Game { pub(crate) id: String, pub(crate) title: String, pub(crate) store: String, + /// This entry opens the launcher itself (Steam Big Picture, Heroic) rather than a title — + /// design D4. Reduced from the wire's `role` by `GameEntry::is_launcher`, so "anything that + /// isn't `launcher` is a game" is decided in one place for every client. + pub(crate) launcher: bool, } #[derive(Clone, PartialEq, Default)] @@ -135,6 +139,7 @@ pub(crate) fn start_fetch(ctx: &Arc, set_library: &AsyncSetState String { .collect() } +/// A small group label above a tile grid ("Launchers" / "Games"). Only drawn when the page shows +/// both groups — a single unlabelled grid is what every launcher-less library looked like before. +fn group_heading(text: &str) -> Element { + text_block(text) + .font_size(12.0) + .semibold() + .foreground(ThemeRef::SecondaryText) + .margin(edges(2.0, 8.0, 2.0, 2.0)) + .into() +} + /// One poster tile: the artwork (or a monogram placeholder while it loads) with the store /// badge overlaid top-left, the title below, tap-to-launch across the whole tile. fn poster_tile( @@ -228,13 +244,20 @@ fn poster_tile( .stretch(Stretch::UniformToFill) .height(poster_h) .into(), + // A launcher rarely has poster art, and an art-less launcher drawn like an art-less game + // reads as "a game whose cover failed to load". So it names its launcher instead of + // showing a title monogram, and the frame below picks up the accent stroke. None => border( - text_block(initials(&game.title)) - .font_size(28.0) - .semibold() - .foreground(ThemeRef::SecondaryText) - .horizontal_alignment(HorizontalAlignment::Center) - .vertical_alignment(VerticalAlignment::Center), + text_block(if game.launcher { + store_label(&game.store).to_string() + } else { + initials(&game.title) + }) + .font_size(if game.launcher { 18.0 } else { 28.0 }) + .semibold() + .foreground(ThemeRef::SecondaryText) + .horizontal_alignment(HorizontalAlignment::Center) + .vertical_alignment(VerticalAlignment::Center), ) .background(ThemeRef::SubtleFill) .height(poster_h) @@ -242,14 +265,27 @@ fn poster_tile( }; let framed = border(grid(vec![ poster, - pill(store_label(&game.store), Pill::Neutral) - .horizontal_alignment(HorizontalAlignment::Left) - .vertical_alignment(VerticalAlignment::Top) - .margin(uniform(6.0)) - .into(), + // `Pill::Info` rather than a solid accent fill — `style.rs` is explicit that + // white-on-bright is unreadable here. + pill( + store_label(&game.store), + if game.launcher { + Pill::Info + } else { + Pill::Neutral + }, + ) + .horizontal_alignment(HorizontalAlignment::Left) + .vertical_alignment(VerticalAlignment::Top) + .margin(uniform(6.0)) + .into(), ])) .corner_radius(8.0) - .border_brush(ThemeRef::CardStroke) + .border_brush(if game.launcher { + ThemeRef::Accent + } else { + ThemeRef::CardStroke + }) .border_thickness(uniform(1.0)); border( @@ -332,22 +368,43 @@ pub(crate) fn library_page(props: &LibraryProps, cx: &mut RenderCx) -> Element { .into(), ), LibraryPhase::Ready(games) => { - let tiles: Vec = games - .iter() - .map(|g| { - let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone()); - let (target, id) = (target.clone(), g.id.clone()); - poster_tile( - g, - props.state.art.get(&g.id).map(String::as_str), - poster_h, - Box::new(move || { - initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st) - }), - ) - }) - .collect(); - body.push(tile_grid(tiles, cols, POSTER_GAP)); + let tile = |g: &Game| -> Element { + let (ctx2, ss, st) = (ctx.clone(), ss.clone(), st.clone()); + let (target, id) = (target.clone(), g.id.clone()); + poster_tile( + g, + props.state.art.get(&g.id).map(String::as_str), + poster_h, + Box::new(move || initiate_launch(&ctx2, target.clone(), id.clone(), &ss, &st)), + ) + }; + // Design D4: launcher entries get their own shelf above the titles, never + // interleaved. `partition` is stable, so the host's title order survives in each + // group. Headings appear only when both groups exist, so a library without launcher + // entries renders exactly as it did before. + let (launchers, titles): (Vec<&Game>, Vec<&Game>) = + games.iter().partition(|g| g.launcher); + let both = !launchers.is_empty() && !titles.is_empty(); + if !launchers.is_empty() { + if both { + body.push(group_heading("Launchers")); + } + body.push(tile_grid( + launchers.iter().map(|g| tile(g)).collect(), + cols, + POSTER_GAP, + )); + } + if !titles.is_empty() { + if both { + body.push(group_heading("Games")); + } + body.push(tile_grid( + titles.iter().map(|g| tile(g)).collect(), + cols, + POSTER_GAP, + )); + } } } diff --git a/crates/pf-console-ui/src/library.rs b/crates/pf-console-ui/src/library.rs index d74fec31..d6c43e0a 100644 --- a/crates/pf-console-ui/src/library.rs +++ b/crates/pf-console-ui/src/library.rs @@ -306,6 +306,11 @@ pub struct LibraryGame { pub id: String, pub title: String, pub store: String, + /// This entry opens the launcher itself (Steam Big Picture, Heroic, Lutris) rather than a + /// title — design D4. The host's `role` field, already reduced to a boolean by + /// [`pf_client_core::library::GameEntry::is_launcher`] so the "anything that isn't + /// `launcher` is a game" rule lives in exactly one place. + pub launcher: bool, } struct Shared { @@ -341,7 +346,15 @@ impl LibraryShared { } /// Loaded games → the carousel (empty = the empty scene). + /// + /// **Launcher entries are moved to the front, keeping the host's title order within each + /// group.** Grouping here rather than in the renderer means the carousel's cursor arithmetic, + /// the art pump and every future consumer of this model all inherit the invariant for free — + /// a launcher tile is never buried in the middle of a 400-title shelf. pub fn set_games(&self, games: Vec) { + let mut games = games; + // `sort_by_key` is stable, so this is a partition that preserves the incoming order. + games.sort_by_key(|g| !g.launcher); let mut s = self.0.lock().unwrap(); s.phase = if games.is_empty() { LibraryPhase::Empty @@ -408,6 +421,52 @@ mod tests { assert_eq!(step_cursor(0, 0, 1, false), StepResult::Boundary); } + /// Design D4: launcher entries lead the shelf, and the host's title order survives within + /// each group. The renderer's `launcher_count()` reads the launcher group as the prefix + /// `0..n`, so an interleaved list would silently mislabel the group heading. + #[test] + fn set_games_groups_launchers_first_and_keeps_title_order() { + let g = |title: &str, launcher: bool| LibraryGame { + id: format!("steam:{title}"), + title: title.to_string(), + store: "steam".into(), + launcher, + }; + let shared = LibraryShared::default(); + shared.set_games(vec![ + g("Celeste", false), + g("Big Picture", true), + g("Portal 2", false), + g("Heroic", true), + ]); + let (phase, games, _) = shared.snapshot(); + assert!(matches!(phase, LibraryPhase::Ready)); + let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect(); + assert_eq!(titles, ["Big Picture", "Heroic", "Celeste", "Portal 2"]); + assert_eq!(games.iter().take_while(|g| g.launcher).count(), 2); + } + + /// A library with no launcher entries is untouched — the whole point of the grouping being + /// invisible until a plugin actually publishes a launcher tile. + #[test] + fn set_games_leaves_a_launcher_less_library_alone() { + let shared = LibraryShared::default(); + shared.set_games( + ["Celeste", "Portal 2", "Tunic"] + .iter() + .map(|t| LibraryGame { + id: format!("steam:{t}"), + title: (*t).to_string(), + store: "steam".into(), + launcher: false, + }) + .collect(), + ); + let (_, games, _) = shared.snapshot(); + let titles: Vec<&str> = games.iter().map(|g| g.title.as_str()).collect(); + assert_eq!(titles, ["Celeste", "Portal 2", "Tunic"]); + } + #[test] fn jump_clamps_onto_the_ends() { assert_eq!(step_cursor(1, 5, -JUMP, true), StepResult::Moved(0)); diff --git a/crates/pf-console-ui/src/screens/library.rs b/crates/pf-console-ui/src/screens/library.rs index c7d13722..558abd7b 100644 --- a/crates/pf-console-ui/src/screens/library.rs +++ b/crates/pf-console-ui/src/screens/library.rs @@ -12,7 +12,7 @@ use crate::library::{ }; use crate::model::{ConsoleCmd, HostRow}; use crate::screens::{ConnectIntent, Ctx, Outbox}; -use crate::theme::{white, Fonts, DIM, W, WHITE}; +use crate::theme::{brand, white, Fonts, DIM, W, WHITE}; use pf_client_core::gamepad::{MenuDir, MenuEvent, MenuPulse}; use skia_safe::{Canvas, Color4f, Data, Image, Paint, Point, RRect, Rect, M44}; use std::collections::HashMap; @@ -168,10 +168,31 @@ impl LibraryScreen { } } + /// How many launcher entries lead the shelf — [`LibraryShared::set_games`] groups them at the + /// front, so the launcher group is always the prefix `0..launcher_count()`. + fn launcher_count(&self) -> usize { + self.games.iter().take_while(|g| g.launcher).count() + } + + /// Is the focused entry a launcher? (Drives the confirm hint: you *open* Steam, you *play* a + /// game.) + fn focused_is_launcher(&self) -> bool { + self.games + .get(self.cursor as usize) + .is_some_and(|g| g.launcher) + } + pub(crate) fn hints(&self, _ctx: &Ctx) -> Vec { match &self.phase { LibraryPhase::Ready => vec![ - Hint::new(HintKey::Confirm, "Play"), + Hint::new( + HintKey::Confirm, + if self.focused_is_launcher() { + "Open" + } else { + "Play" + }, + ), Hint::new(HintKey::Shoulders, "Jump"), Hint::new(HintKey::Back, "Back"), ], @@ -277,6 +298,30 @@ impl LibraryScreen { let pos = self.anim.pos; let bump = self.bump.pos * k; + // Group heading. The model groups launcher entries at the front (design D4), and a + // coverflow is one-dimensional — so instead of a second focus rail (a new up/down nav + // model, in three renderers, for two or three tiles) the heading names the group the + // cursor is in and changes as it crosses the boundary. Drawn only when the shelf + // actually has both groups, so a library without launchers looks exactly as before. + let launchers = self.launcher_count(); + if launchers > 0 && launchers < self.games.len() { + let heading = if (self.cursor as usize) < launchers { + "LAUNCHERS" + } else { + "GAMES" + }; + fonts.centered( + canvas, + heading, + W::SemiBold, + 12.0 * k, + white(0.5), + f64::from(rect.left) + w / 2.0, + cy - card_h / 2.0 - 22.0 * k, + w * 0.5, + ); + } + // Paint order = draw order: farthest from the (integer) cursor first, so the // dense side stacks overlap toward the focus. let mut order: Vec = (0..self.games.len()).collect(); @@ -326,21 +371,31 @@ impl LibraryScreen { } None => { // Solid face, not glass: the side cards OVERLAP. - canvas.draw_rect( - crect, - &Paint::new(Color4f::new(0.118, 0.118, 0.145, 1.0), None), - ); - let mono = initials(&game.title); - let font = fonts.font(W::Bold, 38.0 * k); - let tw = font.measure_str(&mono, None).0; + // + // A launcher tile usually has no poster, and an art-less launcher drawn like + // an art-less game reads as "a game whose cover failed to load". So it gets + // the brand-tinted face and names its launcher, instead of a title monogram. + let face = if game.launcher { + Color4f::new(0.153, 0.137, 0.267, 1.0) + } else { + Color4f::new(0.118, 0.118, 0.145, 1.0) + }; + canvas.draw_rect(crect, &Paint::new(face, None)); + let (glyph, size, ink) = if game.launcher { + (store_label(&game.store).to_string(), 22.0 * k, white(0.85)) + } else { + (initials(&game.title), 38.0 * k, white(0.45)) + }; + let font = fonts.font(W::Bold, size); + let tw = font.measure_str(&glyph, None).0; canvas.draw_str( - &mono, + &glyph, Point::new( (card_w as f32 - tw) / 2.0, card_h as f32 / 2.0 + 13.0 * k as f32, ), &font, - &Paint::new(white(0.45), None), + &Paint::new(ink, None), ); } } @@ -351,13 +406,20 @@ impl LibraryScreen { let tw = fonts.measure(label, W::SemiBold, size) as f64; let (px, py) = (8.0 * k, 8.0 * k); let (bw, bh) = (tw + 16.0 * k, 20.0 * k); + // Brand-filled for a launcher, smoked glass for a game — the one cue that + // survives being three cards deep in the recede. + let pill = if game.launcher { + brand(0.85) + } else { + Color4f::new(0.0, 0.0, 0.0, 0.55) + }; canvas.draw_rrect( RRect::new_rect_xy( Rect::from_xywh(px as f32, py as f32, bw as f32, bh as f32), (bh / 2.0) as f32, (bh / 2.0) as f32, ), - &Paint::new(Color4f::new(0.0, 0.0, 0.0, 0.55), None), + &Paint::new(pill, None), ); fonts.draw( canvas, @@ -395,9 +457,14 @@ impl LibraryScreen { f64::from(rect.bottom) - 64.0 * k, w * 0.8, ); + let sub = if g.launcher { + format!("{} · LAUNCHER", store_label(&g.store).to_uppercase()) + } else { + store_label(&g.store).to_uppercase() + }; fonts.centered( canvas, - &store_label(&g.store).to_uppercase(), + &sub, W::Regular, 12.0 * k, white(0.5), diff --git a/crates/pf-console-ui/src/shell/tests.rs b/crates/pf-console-ui/src/shell/tests.rs index d49e1a44..1e458742 100644 --- a/crates/pf-console-ui/src/shell/tests.rs +++ b/crates/pf-console-ui/src/shell/tests.rs @@ -251,6 +251,7 @@ fn dump_console_screens() { id: format!("steam:{i}"), title: (*t).to_string(), store: "steam".into(), + launcher: false, }) .collect(), ); diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index 56c9a5b4..67f5e8a1 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -371,7 +371,7 @@ pub fn delete_custom(id: &str) -> Result> { /// copies of the very primitive the `/hooks` carve-out exists to withhold. /// /// Returns the field name for the error message, so a plugin author sees exactly what was refused. -/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`, +/// The other launch kinds (`steam_appid`, `steam_ui`, `launcher_ui`, `epic`, `gog`, `aumid`, `playnite`, /// `lutris_id`, `heroic`) are all /// host-resolved from a validated id and stay open to every lane — a provider plugin can still /// publish its whole catalogue, it just cannot hand the host a shell command to run. @@ -743,6 +743,47 @@ mod tests { assert!(library_id_for(&r3[0]).starts_with("custom:")); } + /// A plugin's `launchers(cfg)` tile, end to end: `role: "launcher"` survives the reconcile onto + /// the stored entry AND onto the `GameEntry` a client renders, keeps the deterministic claimed + /// id, and stays out of the wire for ordinary games. + /// + /// This is the path the lutris and heroic plugins publish through, and nothing exercised it + /// before — every earlier test reconciled `GameRole::Game`, which is the serde default, so the + /// field could have been dropped anywhere between the payload and the client without a failure. + #[test] + fn a_launcher_entry_survives_reconcile_onto_the_wire() { + let mut launcher = input("launcher", "Lutris"); + launcher.role = GameRole::Launcher; + launcher.launch = Some(LaunchSpec { + kind: "launcher_ui".into(), + value: "lutris".into(), + }); + + let mut entries = Vec::new(); + let out = reconcile_entries( + &mut entries, + "lutris", + Some("lutris"), + vec![launcher, input("42", "Some Game")], + ); + + assert_eq!(library_id_for(&out[0]), "lutris:launcher"); + assert_eq!(out[0].role, GameRole::Launcher); + assert_eq!(out[1].role, GameRole::Game, "the game is untouched"); + + // Onto the wire: the client sees `role`, and `is_game` keeps it off ordinary entries. + let tile: GameEntry = out[0].clone().into(); + assert_eq!(tile.role, GameRole::Launcher); + let v = serde_json::to_value(&tile).unwrap(); + assert_eq!(v["role"], "launcher"); + let game: GameEntry = out[1].clone().into(); + let vg = serde_json::to_value(&game).unwrap(); + assert!( + vg.get("role").is_none(), + "a game's role stays off the wire, so old clients are unaffected" + ); + } + /// The metadata contract on the wire and on disk: fields serialize FLAT (no `meta` nesting — /// clients and plugins see `platform` beside `title`), absent fields vanish entirely, and a /// pre-metadata `library.json` / payload still parses (all-optional).