feat(console): plumb platform to the shelf and give it a collation module

`GameEntry.platform` ("PC", "PS2", …) has crossed the wire since the model
landed and was thrown away at the console's boundary: both `LibraryGame` mapping
sites in the session binary built the struct without it. It is what the
rom-manager plugin populates, and without it "group by console" has nothing to
group by. Two lines, and the fake-library loader inherits it for free because it
maps the same entry type.

The interesting half is `collate` — a pure module, no Skia, because grouping
rules are exactly the kind of thing that reads obviously correct and is quietly
wrong, and because it is the half that has to be identical on every client. This
file is the portable spec the Apple and Android ports implement.

Two rules earn their tests:

- A platform-less game does NOT go to "Unknown". A Steam library has no
  platforms at all, so an "Unknown" bucket would swallow it whole and be a worse
  view than no grouping; a store-front game buckets under its STORE ("Steam"),
  which is both true and useful, and only an entry with neither lands in
  "Other".
- A-Z folds the leading article. "The Witcher 3" belongs under W; left alone
  every "The …" piles up under T and the sort is useless exactly where a long
  library needs it. English articles only, with a guard so a title that IS an
  article doesn't sort as an empty string and float to the front.

Launchers lead by construction rather than by every caller remembering to put
them there, so design D4 survives grouping for free. Everything returns INDICES:
the art cache, the fetch pump and the cursor all key off the shared model's
ordering, and handing back cloned games would fork the identity of every title.

The shelf now routes its display order through `collate::filtered` even
ungrouped and unsorted — which is why this lands green on its own, and more
importantly is the right shape: shelf, sort and the coming drill-in are then one
screen with one cursor arithmetic. `SortKey::HostOrder` is the default and is
asserted byte-identical to the order the host sent, so a user who never touches
a sort pill sees no change whatever. `LibraryScreen` gains a `view: Vec<usize>`
the cursor indexes instead of `games`, and a `filter` the drill-in will set.

`library_sort` joins `trust::Settings` beside `ui_palette` and `reduce_motion` —
presentation only, never in a profile, parsed leniently so a newer client's key
reads as today's shelf. Its pills arrive with the Collections screen; the sort
is honoured now.

`clients/session/fixtures/mixed-platform-library.json` is the standing dev asset
the rest of Part C is built against: launchers, five platforms, several stores,
platform-less entries, and two deliberately awkward titles ("The Witcher 3",
"Émigré") so a broken fold shows up on glass and not only in a unit test.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 111 tests green.
This commit is contained in:
2026-08-16 15:44:17 +02:00
parent be8183caab
commit c804a0dc9f
9 changed files with 534 additions and 15 deletions
+10
View File
@@ -22,6 +22,16 @@ session end returns to the library, B quits (Gaming Mode returns). Paired hosts
pairing is the desktop client / Decky plugin's job. `PUNKTFUNK_FAKE_LIBRARY=<file.json>`
feeds canned entries with no host (portrait paths starting with `/` load from disk).
`fixtures/mixed-platform-library.json` is the standing asset for the library's grouping and
sorting work: launchers, five platforms, several stores, and entries with no platform at
all — which is the case that matters, because a platform-less Steam library must not
collapse into one "Unknown" heap. Two titles are deliberately awkward ("The Witcher 3"
sorts under W, "Émigré" under E) so a broken article fold or diacritic relaxation shows up
on screen rather than only in a unit test:
PUNKTFUNK_FAKE_LIBRARY=clients/session/fixtures/mixed-platform-library.json \
punktfunk-session --browse
Reads the same identity / known-hosts / settings stores as the desktop client
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
@@ -0,0 +1,44 @@
[
{
"id": "launcher:steam",
"store": "steam",
"title": "Steam Big Picture",
"role": "launcher",
"icon": "steam"
},
{
"id": "launcher:heroic",
"store": "heroic",
"title": "Heroic",
"role": "launcher",
"icon": "heroic"
},
{ "id": "steam:570", "store": "steam", "title": "Dota 2" },
{ "id": "steam:220", "store": "steam", "title": "Half-Life 2" },
{ "id": "steam:1091500", "store": "steam", "title": "Cyberpunk 2077" },
{ "id": "steam:292030", "store": "steam", "title": "The Witcher 3: Wild Hunt" },
{ "id": "steam:377160", "store": "steam", "title": "Fallout 4" },
{ "id": "rom:ps3-1", "store": "custom", "title": "Demon's Souls", "platform": "PS3" },
{ "id": "rom:ps3-2", "store": "custom", "title": "The Last of Us", "platform": "PS3" },
{ "id": "rom:ps3-3", "store": "custom", "title": "Ni no Kuni", "platform": "PS3" },
{ "id": "rom:ps2-1", "store": "custom", "title": "Shadow of the Colossus", "platform": "PS2" },
{ "id": "rom:ps2-2", "store": "custom", "title": "Ico", "platform": "PS2" },
{ "id": "rom:ps2-3", "store": "custom", "title": "Ōkami", "platform": "PS2" },
{ "id": "rom:snes-1", "store": "custom", "title": "A Link to the Past", "platform": "SNES" },
{ "id": "rom:snes-2", "store": "custom", "title": "Super Metroid", "platform": "SNES" },
{ "id": "rom:snes-3", "store": "custom", "title": "Chrono Trigger", "platform": "SNES" },
{ "id": "rom:snes-4", "store": "custom", "title": "EarthBound", "platform": "SNES" },
{ "id": "rom:gc-1", "store": "custom", "title": "Metroid Prime", "platform": "GameCube" },
{ "id": "rom:gc-2", "store": "custom", "title": "The Wind Waker", "platform": "GameCube" },
{ "id": "epic:fn", "store": "epic", "title": "Alan Wake 2" },
{ "id": "gog:1", "store": "gog", "title": "An Untitled Story" },
{ "id": "custom:none-1", "store": "wat", "title": "Unlabelled Thing" },
{ "id": "custom:none-2", "store": "wat", "title": "Émigré" }
]
+2
View File
@@ -920,6 +920,7 @@ fn spawn_fetch(
store: g.store.clone(),
launcher: g.is_launcher(),
icon: g.icon_token().unwrap_or_default().to_string(),
platform: g.platform.clone(),
})
.collect(),
);
@@ -962,6 +963,7 @@ fn load_fake(shared: &LibraryShared, path: &str) {
store: g.store.clone(),
launcher: g.is_launcher(),
icon: g.icon_token().unwrap_or_default().to_string(),
platform: g.platform.clone(),
})
.collect(),
);
+11
View File
@@ -1307,6 +1307,16 @@ pub struct Settings {
/// `default` so pre-existing stores load with the full motion they have today.
#[serde(default)]
pub reduce_motion: bool,
/// How the console's game library orders titles within a group: `""`/unknown (the
/// host's own order — today's shelf, byte for byte), `"title"`, `"platform"` or
/// `"store"`. See `pf-console-ui`'s `collate` module, which is the portable spec the
/// Apple and Android ports implement.
///
/// Presentation only, like [`ui_palette`](Self::ui_palette), so it is a device
/// preference and never part of a settings profile. Parsed leniently — an unrecognized
/// value is a newer client's key, and the right answer to one is the default shelf.
#[serde(default)]
pub library_sort: String,
/// Send Wake-on-LAN before connecting to a saved host and wait for it to boot (the
/// Apple client's "Auto-wake on connect"). Default ON — that was the unconditional
/// behavior before this became a setting. Off is for hosts reached over a VPN, where
@@ -1513,6 +1523,7 @@ impl Default for Settings {
library_enabled: false,
ui_palette: default_ui_palette(),
reduce_motion: false,
library_sort: String::new(),
auto_wake: true,
invert_scroll: false,
speaker_device: String::new(),
+382
View File
@@ -0,0 +1,382 @@
//! Sorting and grouping the library — all policy, no Skia.
//!
//! Kept a separate, pure module for two reasons. It is the half of "alternate views and
//! group & sort" that has to be identical on every client, so this file is also the
//! portable SPEC the Apple and Android ports implement; and grouping rules are exactly the
//! kind of thing that reads obviously correct and is quietly wrong (a platform-less Steam
//! 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.
//!
//! 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.
use crate::library::{store_label, LibraryGame};
/// How titles are ordered within a group.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub(crate) enum SortKey {
/// 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.
#[default]
HostOrder,
/// AZ, case- and diacritic-relaxed, with the leading article folded away.
Title,
/// Platform label AZ, then title within it.
Platform,
/// Store label AZ, then title.
Store,
}
impl SortKey {
/// Parse the persisted `library_sort` 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 same rule `ui_palette` follows.
pub(crate) fn parse(s: &str) -> SortKey {
match s {
"title" => SortKey::Title,
"platform" => SortKey::Platform,
"store" => SortKey::Store,
_ => SortKey::HostOrder,
}
}
}
/// 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.
#[derive(Clone, PartialEq, Eq, Debug)]
pub(crate) enum GroupKey {
/// The launcher entries (design D4's leading group).
Launchers,
Platform(String),
Store(String),
}
/// One collated bucket: what it is, what to call it, and which games are in it.
#[derive(Clone, Debug)]
pub(crate) struct Group {
pub key: GroupKey,
pub label: String,
/// Indices into the slice passed to [`collate`], in display order.
pub games: Vec<usize>,
}
/// What to bucket by. `None` = one group holding everything (the plain shelf).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum GroupBy {
Platform,
Store,
}
/// Fold a title down to something sortable: lowercase, diacritics relaxed to their base
/// letter, punctuation dropped, and a leading article removed.
///
/// The article fold is what a user actually means by AZ. "The Witcher 3" belongs under W;
/// left alone, every "The …" in a library piles up under T and the sort is useless exactly
/// where it is most needed. English articles only — the host's titles are whatever the
/// store called them, and inventing rules for languages we cannot detect would file things
/// under letters nobody expects.
pub(crate) fn sort_title(title: &str) -> String {
let relaxed: String = title
.to_lowercase()
.chars()
.map(|c| match c {
'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' => 'a',
'é' | 'è' | 'ê' | 'ë' => 'e',
'í' | 'ì' | 'î' | 'ï' => 'i',
'ó' | 'ò' | 'ô' | 'ö' | 'õ' => 'o',
'ú' | 'ù' | 'û' | 'ü' => 'u',
'ç' => 'c',
'ñ' => 'n',
c => c,
})
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect();
let trimmed = relaxed.trim();
for article in ["the ", "a ", "an "] {
if let Some(rest) = trimmed.strip_prefix(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 = rest.trim();
if !rest.is_empty() {
return rest.to_string();
}
}
}
trimmed.to_string()
}
/// The bucket label 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".
fn bucket(g: &LibraryGame, by: GroupBy) -> GroupKey {
match by {
GroupBy::Platform => match g
.platform
.as_deref()
.map(str::trim)
.filter(|p| !p.is_empty())
{
Some(p) => GroupKey::Platform(p.to_string()),
None => match store_label(&g.store) {
"Game" => GroupKey::Platform("Other".to_string()),
store => GroupKey::Store(store.to_string()),
},
},
GroupBy::Store => GroupKey::Store(store_label(&g.store).to_string()),
}
}
fn label_of(key: &GroupKey) -> String {
match key {
GroupKey::Launchers => "Launchers".to_string(),
GroupKey::Platform(p) | GroupKey::Store(p) => p.clone(),
}
}
/// 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.
pub(crate) fn collate(
games: &[LibraryGame],
sort: SortKey,
group_by: Option<GroupBy>,
) -> Vec<Group> {
let mut launchers: Vec<usize> = Vec::new();
// Insertion-ordered rather than a map, so groups appear in the order the library first
// mentions them and two runs over the same library agree.
let mut buckets: Vec<(GroupKey, Vec<usize>)> = Vec::new();
for (i, g) in games.iter().enumerate() {
if g.launcher {
launchers.push(i);
continue;
}
let key = match group_by {
Some(by) => bucket(g, by),
// 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.
None => GroupKey::Platform("All".to_string()),
};
match buckets.iter_mut().find(|(k, _)| *k == key) {
Some((_, v)) => v.push(i),
None => buckets.push((key, vec![i])),
}
}
let order = |a: usize, b: usize| -> std::cmp::Ordering {
let (ga, gb) = (&games[a], &games[b]);
match sort {
// Untouched: the host's order IS the order, so the comparator never fires.
SortKey::HostOrder => a.cmp(&b),
SortKey::Title => sort_title(&ga.title)
.cmp(&sort_title(&gb.title))
.then(a.cmp(&b)),
SortKey::Platform => ga
.platform
.as_deref()
.unwrap_or("")
.cmp(gb.platform.as_deref().unwrap_or(""))
.then_with(|| sort_title(&ga.title).cmp(&sort_title(&gb.title)))
.then(a.cmp(&b)),
SortKey::Store => store_label(&ga.store)
.cmp(store_label(&gb.store))
.then_with(|| sort_title(&ga.title).cmp(&sort_title(&gb.title)))
.then(a.cmp(&b)),
}
};
let mut out: Vec<Group> = Vec::with_capacity(buckets.len() + 1);
if !launchers.is_empty() {
// 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.push(Group {
key: GroupKey::Launchers,
label: label_of(&GroupKey::Launchers),
games: launchers,
});
}
for (key, mut idx) in buckets {
// `sort_by` is stable, and every comparator falls back to the index, so equal keys
// keep the host's order rather than an arbitrary one.
idx.sort_by(|&a, &b| order(a, b));
out.push(Group {
label: label_of(&key),
key,
games: idx,
});
}
// Groups themselves go AZ by label, launchers excepted — they were pushed first and
// `sort_by` is stable, so pinning them by key keeps them leading.
out.sort_by(|a, b| {
let lead = |g: &Group| u8::from(g.key != GroupKey::Launchers);
lead(a).cmp(&lead(b)).then_with(|| a.label.cmp(&b.label))
});
out
}
/// The flat index list for a group filter — `None` = the whole library, in collated order.
pub(crate) fn filtered(
games: &[LibraryGame],
sort: SortKey,
filter: Option<&GroupKey>,
) -> Vec<usize> {
let by = match filter {
Some(GroupKey::Platform(_)) => Some(GroupBy::Platform),
Some(GroupKey::Store(_)) => Some(GroupBy::Store),
Some(GroupKey::Launchers) | None => None,
};
let groups = collate(games, sort, by);
match filter {
None => groups.into_iter().flat_map(|g| g.games).collect(),
Some(want) => groups
.into_iter()
.find(|g| &g.key == want)
.map(|g| g.games)
.unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every sort the module offers. A local list rather than a shipped `ALL` const: the
/// pills that need one arrive with the Collections screen, and a table nothing but a
/// test reads is dead weight in a crate that lints dead code as an error.
const EVERY_SORT: [SortKey; 4] = [
SortKey::HostOrder,
SortKey::Title,
SortKey::Platform,
SortKey::Store,
];
fn game(
id: &str,
title: &str,
store: &str,
platform: Option<&str>,
launcher: bool,
) -> LibraryGame {
LibraryGame {
id: id.into(),
title: title.into(),
store: store.into(),
launcher,
icon: String::new(),
platform: platform.map(str::to_string),
}
}
#[test]
fn article_fold_files_titles_where_a_reader_looks_for_them() {
assert_eq!(sort_title("The Witcher 3"), "witcher 3");
assert_eq!(sort_title("A Way Out"), "way out");
assert_eq!(sort_title("An Untitled Story"), "untitled story");
// Not an article, just a word starting with one.
assert_eq!(sort_title("Theme Hospital"), "theme hospital");
assert_eq!(sort_title("Anno 1800"), "anno 1800");
// Diacritics relax; punctuation goes.
assert_eq!(sort_title("Pokémon: Red!"), "pokemon red");
// A title that is ONLY an article keeps something to sort on.
assert_eq!(sort_title("The"), "the");
}
#[test]
fn platform_less_store_games_bucket_under_their_store_not_unknown() {
let games = [
game("a", "Dota 2", "steam", None, false),
game("b", "Half-Life", "steam", None, false),
game("c", "Shadow of the Colossus", "custom", Some("PS2"), false),
// Neither a platform nor a store we have a label for.
game("d", "Mystery", "wat", None, false),
];
let groups = collate(&games, SortKey::HostOrder, Some(GroupBy::Platform));
let labels: Vec<&str> = groups.iter().map(|g| g.label.as_str()).collect();
assert!(labels.contains(&"Steam"), "{labels:?}");
assert!(labels.contains(&"PS2"), "{labels:?}");
assert!(labels.contains(&"Other"), "{labels:?}");
assert!(
!labels.contains(&"Unknown"),
"a platform-less Steam library must not become one heap: {labels:?}"
);
let steam = groups.iter().find(|g| g.label == "Steam").unwrap();
assert_eq!(steam.games, vec![0, 1]);
}
#[test]
fn launchers_always_lead_whatever_the_sort() {
let games = [
game("z", "Zed", "steam", Some("PC"), false),
game("l", "Steam", "steam", None, true),
game("a", "Aaa", "steam", Some("PC"), false),
];
for sort in EVERY_SORT {
let groups = collate(&games, sort, Some(GroupBy::Platform));
assert_eq!(groups[0].key, GroupKey::Launchers, "{sort:?}");
assert_eq!(groups[0].games, vec![1], "{sort:?}");
}
}
#[test]
fn host_order_is_byte_identical_to_no_sorting_at_all() {
let games = [
game("c", "Zed", "steam", Some("PC"), false),
game("a", "Aaa", "steam", Some("PC"), false),
game("b", "Mmm", "steam", Some("PC"), false),
];
// The default must leave the shelf exactly as the host handed it over — a user who
// never touches the sort pills sees no change whatever.
assert_eq!(filtered(&games, SortKey::HostOrder, None), vec![0, 1, 2]);
assert_eq!(filtered(&games, SortKey::Title, None), vec![1, 2, 0]);
}
#[test]
fn equal_keys_keep_the_hosts_order() {
let games = [
game("a", "Same", "steam", Some("PC"), false),
game("b", "Same", "steam", Some("PC"), false),
game("c", "Same", "steam", Some("PC"), false),
];
assert_eq!(filtered(&games, SortKey::Title, None), vec![0, 1, 2]);
}
#[test]
fn filtering_returns_only_that_groups_games() {
let games = [
game("a", "Ico", "custom", Some("PS2"), false),
game("b", "Dota", "steam", None, false),
game("c", "SotC", "custom", Some("PS2"), false),
];
let want = GroupKey::Platform("PS2".into());
assert_eq!(
filtered(&games, SortKey::HostOrder, Some(&want)),
vec![0, 2]
);
// A filter naming a group that no longer exists yields nothing, rather than
// silently showing everything — a stale filter must not look like a working shelf.
let gone = GroupKey::Platform("PS3".into());
assert!(filtered(&games, SortKey::HostOrder, Some(&gone)).is_empty());
}
/// The persisted strings, pinned literally. They are a FILE FORMAT: renaming one here
/// silently resets every user's chosen sort to the default on their next launch.
#[test]
fn sort_keys_parse_from_their_stored_names_and_unknown_falls_back() {
assert_eq!(SortKey::parse("title"), SortKey::Title);
assert_eq!(SortKey::parse("platform"), SortKey::Platform);
assert_eq!(SortKey::parse("store"), SortKey::Store);
// Anything else is a newer client's key, and the right answer to one is today's
// shelf rather than an error — the rule `ui_palette` already follows.
assert_eq!(SortKey::parse("something-newer"), SortKey::HostOrder);
assert_eq!(SortKey::parse(""), SortKey::HostOrder);
assert_eq!(SortKey::default(), SortKey::HostOrder);
}
}
+2
View File
@@ -15,6 +15,8 @@
#[cfg(any(target_os = "linux", windows))]
mod anim;
#[cfg(any(target_os = "linux", windows))]
mod collate;
#[cfg(any(target_os = "linux", windows))]
mod glyphs;
#[cfg(any(target_os = "linux", windows))]
mod launcher_icons;
+7
View File
@@ -583,6 +583,11 @@ pub struct LibraryGame {
/// [`pf_client_core::library::GameEntry::icon_token`]. Empty when the entry names no mark;
/// a token we ship no art for simply draws nothing and the tile falls back to its name.
pub icon: String,
/// The system this title runs on (`"PC"`, `"PS2"`, …) — the host's own free-form display
/// string, passed through untouched. `None` for a store-front game whose host said
/// nothing, which is the common case: the rom-manager plugin populates this, Steam does
/// not. [`crate::collate`] is where that `None` is given a meaning.
pub platform: Option<String>,
}
struct Shared {
@@ -782,6 +787,7 @@ mod tests {
store: "steam".into(),
launcher,
icon: String::new(),
platform: None,
};
let shared = LibraryShared::default();
shared.set_games(vec![
@@ -811,6 +817,7 @@ mod tests {
store: "steam".into(),
launcher: false,
icon: String::new(),
platform: None,
})
.collect(),
);
+73 -15
View File
@@ -35,6 +35,16 @@ pub(crate) struct LibraryScreen {
generation: u64,
phase: LibraryPhase,
games: Vec<LibraryGame>,
/// Display order: indices into `games`, as [`crate::collate`] arranged them. The cursor
/// indexes THIS, not `games` — which is what lets the plain shelf, a chosen sort and a
/// collection drill-in all be the same screen with the same cursor arithmetic, and what
/// keeps the art cache keyed on the model's own identities rather than on positions.
view: Vec<usize>,
sort: crate::collate::SortKey,
/// `Some` = this shelf shows ONE collated group (the Collections drill-in). The shared
/// model is untouched by it: filtering is index-level, so the art pump and the fetch
/// flow never learn that a filter exists.
filter: Option<crate::collate::GroupKey>,
// Navigation: the integer cursor is the authority; the eased position chases it.
cursor: i32,
/// Each card's rect as last drawn (axis-aligned, scale applied — the perspective tilt
@@ -66,6 +76,9 @@ impl LibraryScreen {
generation: u64::MAX,
phase: LibraryPhase::Loading,
games: Vec::new(),
view: Vec::new(),
sort: crate::collate::SortKey::default(),
filter: None,
cursor: 0,
geom: Vec::new(),
anim: Spring::rest(0.0),
@@ -93,9 +106,9 @@ impl LibraryScreen {
let cursor = self.cursor.max(0) as usize;
// The cards actually on screen at rest — the ones the fan opens around.
let lo = cursor.saturating_sub(2);
let hi = (cursor + 3).min(self.games.len());
let have_art = self.games[lo..hi]
.iter()
let hi = (cursor + 3).min(self.len());
let have_art = (lo..hi)
.filter_map(|i| self.game(i))
.any(|g| self.art.contains_key(&g.id));
if have_art || t - since >= 0.4 {
self.entrance_armed = true;
@@ -103,6 +116,40 @@ impl LibraryScreen {
}
}
/// Adopt the persisted presentation settings. Read every frame rather than at
/// construction because they can be changed while this screen is on the stack, and a
/// shelf that only picked them up on re-entry would look broken for one visit.
fn adopt_settings(&mut self, ctx: &Ctx) {
let sort = crate::collate::SortKey::parse(&ctx.settings.library_sort);
if sort != self.sort {
self.sort = sort;
self.recollate();
}
}
/// Rebuild the display order after anything that could change it: a new game list, a
/// new sort, a new filter. The cursor is clamped rather than followed by identity — a
/// re-sort moves everything, so "keep the same index" and "keep the same title" are
/// both arbitrary, and the cheap one at least never points off the end.
fn recollate(&mut self) {
self.view = crate::collate::filtered(&self.games, self.sort, self.filter.as_ref());
self.cursor = self.cursor.clamp(0, (self.view.len() as i32 - 1).max(0));
}
/// The game at a DISPLAY index (`None` past the end, or if the order went stale).
fn game(&self, i: usize) -> Option<&LibraryGame> {
self.games.get(*self.view.get(i)?)
}
fn focused(&self) -> Option<&LibraryGame> {
self.game(self.cursor.max(0) as usize)
}
/// Tiles on the shelf — the FILTERED count, not the library's.
fn len(&self) -> usize {
self.view.len()
}
/// The screen's title: the host, and — when this shelf belongs to a pinned card — the
/// profile every launch off it will use, in the card's own `host · profile` shape.
pub(crate) fn title(&self) -> String {
@@ -137,7 +184,12 @@ impl LibraryScreen {
if self.shared.is_none() {
self.shared = Some(library.clone());
}
let Some(shared) = &self.shared else { return };
// Cloned rather than borrowed: `LibraryShared` is an `Arc` handle, so this costs a
// refcount, and holding a borrow of `self.shared` across the body would forbid the
// `&mut self` work below (re-collating the display order) for no benefit.
let Some(shared) = self.shared.clone() else {
return;
};
if shared.generation() != self.generation {
let (phase, games, generation) = shared.snapshot();
let fresh = self.games.len() != games.len()
@@ -157,7 +209,7 @@ impl LibraryScreen {
self.entrance_armed = false;
self.ready_at = None;
}
self.cursor = self.cursor.clamp(0, (self.games.len() as i32 - 1).max(0));
self.recollate();
}
for (id, bytes) in shared.drain_art() {
match Image::from_encoded(Data::new_copy(&bytes)) {
@@ -176,6 +228,7 @@ impl LibraryScreen {
fx: &mut Outbox,
) -> Option<MenuPulse> {
self.sync(ctx.library);
self.adopt_settings(ctx);
match &self.phase {
LibraryPhase::Ready => match ev {
MenuEvent::Move(MenuDir::Left) => self.step(-1, false),
@@ -183,7 +236,7 @@ impl LibraryScreen {
MenuEvent::JumpBack => self.step(-JUMP, true),
MenuEvent::JumpForward => self.step(JUMP, true),
MenuEvent::Confirm => {
let g = self.games.get(self.cursor as usize)?;
let g = self.focused()?;
fx.connect = Some(ConnectIntent {
addr: self.addr.clone(),
port: self.port,
@@ -211,7 +264,7 @@ impl LibraryScreen {
// it is the only per-game action there is, and a menu holding one row is
// a press the user pays for nothing.
MenuEvent::Tertiary => {
let g = self.games.get(self.cursor as usize)?;
let g = self.focused()?;
match self.game_link(&g.id) {
Some(url) => {
fx.copy = Some(url);
@@ -267,7 +320,7 @@ impl LibraryScreen {
.enumerate()
// The geometry is a frame old; a library refresh can shorten the shelf
// between the render that recorded it and this press.
.filter(|(i, r)| *i < self.games.len() && p.hits(**r))
.filter(|(i, r)| *i < self.len() && p.hits(**r))
.min_by_key(|(i, _)| (*i as i32 - self.cursor).abs())
.map(|(i, _)| i);
match hit {
@@ -287,7 +340,7 @@ impl LibraryScreen {
}
fn step(&mut self, delta: i32, clamp: bool) -> Option<MenuPulse> {
match step_cursor(self.cursor, self.games.len(), delta, clamp) {
match step_cursor(self.cursor, self.len(), delta, clamp) {
StepResult::Moved(to) => {
self.cursor = to;
Some(MenuPulse::Move)
@@ -305,7 +358,11 @@ 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()
self.view
.iter()
.map_while(|&i| self.games.get(i))
.take_while(|g| g.launcher)
.count()
}
/// Is the focused entry a launcher? (Drives the confirm hint: you *open* Steam, you *play* a
@@ -351,6 +408,7 @@ impl LibraryScreen {
ctx: &mut Ctx,
) {
self.sync(ctx.library);
self.adopt_settings(ctx);
let (w, cy_all) = (
f64::from(rect.width()),
f64::from(rect.top) + f64::from(rect.height()) / 2.0,
@@ -447,7 +505,7 @@ impl LibraryScreen {
// 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() {
if launchers > 0 && launchers < self.len() {
let heading = if (self.cursor as usize) < launchers {
"LAUNCHERS"
} else {
@@ -470,10 +528,10 @@ impl LibraryScreen {
// 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();
let mut order: Vec<usize> = (0..self.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse((i as i32 - self.cursor).abs()));
self.geom.clear();
self.geom.resize(self.games.len(), Rect::new_empty());
self.geom.resize(self.len(), Rect::new_empty());
for i in order {
let d = i as f64 - pos;
@@ -508,7 +566,7 @@ impl LibraryScreen {
);
let m = card_matrix(ccx, cy, angle, scale, card_w, card_h, PERSPECTIVE * k);
let game = &self.games[i];
let Some(game) = self.game(i) else { continue };
// The focused card's glow, drawn in SCREEN space before the card's own
// transform: it is light spilling AROUND the card, so it cannot live inside the
// rounded rect the card clips itself to. Fades with the sprung proximity rather
@@ -659,7 +717,7 @@ impl LibraryScreen {
}
// Detail block: focused title + store, in the band under the strip.
if let Some(g) = self.games.get(self.cursor as usize) {
if let Some(g) = self.focused() {
let cx = f64::from(rect.left) + w / 2.0;
fonts.centered(
canvas,
+3
View File
@@ -261,6 +261,7 @@ fn a_pinned_cards_library_launches_with_its_profile() {
store: "steam".into(),
launcher: false,
icon: String::new(),
platform: None,
}]);
s.handle_menu(MenuEvent::Confirm);
match s.take_action() {
@@ -292,6 +293,7 @@ fn a_primary_tiles_library_leaves_the_profile_to_the_binding() {
store: "steam".into(),
launcher: false,
icon: String::new(),
platform: None,
}]);
s.handle_menu(MenuEvent::Confirm);
assert!(matches!(
@@ -806,6 +808,7 @@ fn dump_console_screens() {
store: "steam".into(),
launcher: false,
icon: String::new(),
platform: None,
})
.collect(),
);