feat(clients/library): a launcher tile looks like one, on every client

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.
This commit is contained in:
2026-08-06 14:35:24 +02:00
parent 6f07bd94d3
commit 883c317872
15 changed files with 554 additions and 77 deletions
@@ -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),
)
}
@@ -37,9 +37,51 @@ data class Artwork(val portrait: String?, val header: String?, val hero: String?
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
}
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
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:<appid>` / `custom:<id>`).
*
* [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<GameEntry>.launchersFirst(): List<GameEntry> {
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. */
@@ -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
@@ -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)
@@ -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)
}
}
@@ -38,12 +38,46 @@ public struct LaunchSpec: Codable, Hashable, Sendable {
/// One title in the unified library. `id` is store-qualified: `steam:<appid>` / `custom:<id>`.
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").
+7
View File
@@ -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.
+10 -2
View File
@@ -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) => {
+74 -3
View File
@@ -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<HashMap<String, gdk::Texture>>,
@@ -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<State>) {
/// immediately; the rest keep their monogram placeholder until `load_art` delivers.
fn render(state: &Rc<State>, 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<State>, 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<State>, 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<State>, 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);
+2
View File
@@ -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(),
);
+85 -28
View File
@@ -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<AppCtx>, set_library: &AsyncSetState<Library
id: g.id.clone(),
title: g.title.clone(),
store: g.store.clone(),
launcher: g.is_launcher(),
})
.collect(),
);
@@ -215,6 +220,17 @@ fn initials(title: &str) -> 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<Element> = 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,
));
}
}
}
+59
View File
@@ -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<LibraryGame>) {
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));
+80 -13
View File
@@ -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<Hint> {
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<usize> = (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),
+1
View File
@@ -251,6 +251,7 @@ fn dump_console_screens() {
id: format!("steam:{i}"),
title: (*t).to_string(),
store: "steam".into(),
launcher: false,
})
.collect(),
);
+42 -1
View File
@@ -371,7 +371,7 @@ pub fn delete_custom(id: &str) -> Result<MutateOutcome<()>> {
/// 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).