Files
qbz/crates/qbz-ui/ui/shell/KioskDiscover.slint
T
vicrodh f1eeaf9126 feat(steam-deck): game mode support (plan PR1-PR5)
- PR1: gamescope detection at boot — X11 backend, scale 1, kiosk
  fullscreen; tray + miniplayer suppressed (click-offset fix).
- PR2: full keyboard navigation for the kiosk (3 zones, focus ring,
  scroll-into-view; typing never navigates).
- PR3: native gamepad via gilrs — D-pad/stick/A/B to synthetic Slint
  keys with hysteresis, repeat and debounce; B suppresses Escape while
  a text field is focused (closes the Steam OSK).
- PR4: Steam OSK via the steam://open/keyboard deeplink on text focus.
- PR5: scripts/qbz-add-to-steam (stdlib-only, 19/19 unit tests) with
  artwork assets + pure-gamepad controller layout.

Spec: qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md

km
2026-07-25 12:04:58 -06:00

262 lines
12 KiB
Plaintext

// KioskDiscover — lightweight Discover/Home for the kiosk (2.0.2 frente #3).
//
// Reads the SAME DiscoverState the desktop HomeView reads (no data copy, so the
// artwork pipeline's in-place cover pushes flow through), but renders simple
// horizontal shelves of KioskCards instead of the heavy Carousel/AlbumCard
// stack — lighter for a Pi. v1 covers the album-carousel rows (newReleases,
// qobuzissimes, editorPicks, pressAwards, idealDiscography, mostStreamed); the
// non-album rows (playlists, mixes, slim rails) are skipped for now.
//
// Keyboard/gamepad nav (PR 2): SHELF mode. The nav index space is the 3 tab
// pills (indices 0-2) followed by one index per shelf (columns == 1, so
// Up/Down walk shelves). Left/Right can't move the global index (that would
// jump shelves), so the shell emits an `hmove` pulse that the focused shelf
// consumes into its LOCAL card cursor. Sections with no albums render
// nothing (the playlist/mix/slim rows above) — the focused shelf skips over
// them in the last travel direction (`KioskNavState.dir`), since Slint has
// no cross-loop accumulator to pre-compact the index space.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { DiscoverState, HomeState, HomeActions, KioskNavState } from "../state.slint";
import { KioskCard } from "KioskCard.slint";
component TabPill inherits Rectangle {
in property <string> label;
in property <bool> active;
// Keyboard/gamepad nav (PR 2): this pill holds the shell focus ring.
in property <bool> nav-focused: false;
callback clicked;
horizontal-stretch: 0;
height: 40px;
border-radius: Radius.sm;
background: root.active ? Theme.accent : ta.has-hover ? Theme.alpha-6 : transparent;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
HorizontalLayout {
padding-left: 16px;
padding-right: 16px;
Text {
text: root.label;
color: root.active ? Theme.accent-text : Theme.text-secondary;
font-size: 15px;
font-weight: 600;
vertical-alignment: center;
}
}
ta := TouchArea {
clicked => {
root.clicked();
}
}
}
export component KioskDiscover inherits Rectangle {
callback album-clicked(string);
callback open-artist(string);
callback media-action(string, string, string);
callback discover-view-all(string, string);
// ---- Nav geometry publish (PR 2): 3 tab pills + one index per shelf.
// Re-published on tab switch and whenever the section set changes.
property <int> nav-section-count: (DiscoverState.active-tab == "editorPicks"
? DiscoverState.editor-sections
: DiscoverState.active-tab == "forYou"
? DiscoverState.foryou-sections
: DiscoverState.home-sections).length;
function publish-nav() {
KioskNavState.tabs = 3;
KioskNavState.shelves = true;
KioskNavState.columns = 1;
KioskNavState.count = 3 + root.nav-section-count;
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-count-probe: root.nav-section-count;
changed nav-count-probe => {
root.publish-nav();
}
property <string> nav-tab-probe: DiscoverState.active-tab;
changed nav-tab-probe => {
root.publish-nav();
}
// Enter on a tab pill → the same tab switch the pills' TouchAreas drive.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content"
&& KioskNavState.index < KioskNavState.tabs) {
if (KioskNavState.index == 0) {
HomeActions.select-tab("home");
} else if (KioskNavState.index == 1) {
HomeActions.select-tab("editorPicks");
} else if (KioskNavState.index == 2) {
HomeActions.select-tab("forYou");
}
}
}
page-flick := Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
page := VerticalLayout {
padding: 16px;
spacing: 22px;
// Tab pill — Home / Editor's Picks / For You.
HorizontalLayout {
spacing: 6px;
alignment: start;
Rectangle {
width: pills.preferred-width;
height: 50px;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
pills := HorizontalLayout {
padding: 5px;
spacing: 4px;
TabPill {
label: @tr("Home");
active: DiscoverState.active-tab == "home";
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
clicked => { HomeActions.select-tab("home"); }
}
TabPill {
label: @tr("Editor's Picks");
active: DiscoverState.active-tab == "editorPicks";
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
clicked => { HomeActions.select-tab("editorPicks"); }
}
TabPill {
label: @tr("For You");
active: DiscoverState.active-tab == "forYou";
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2;
clicked => { HomeActions.select-tab("forYou"); }
}
}
}
}
// Album shelves for the active tab. Only rows carrying album data
// render (length > 0); playlist/mix/slim rows are skipped in v1.
for desc[s] in (DiscoverState.active-tab == "editorPicks"
? DiscoverState.editor-sections
: DiscoverState.active-tab == "forYou"
? DiscoverState.foryou-sections
: DiscoverState.home-sections): shelf-vl := VerticalLayout {
// PR 2 — per-shelf nav state. `cursor` is the focused card
// inside this shelf; `shelf-focused` mirrors the global index.
property <int> cursor: 0;
property <bool> shelf-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& KioskNavState.index - KioskNavState.tabs == s;
// Skip over shelves that render nothing (empty / non-album
// sections): nudge the index one more step in the last travel
// direction. The write re-fires the probes of the next shelf,
// so the cascade walks contiguous gaps and converges.
property <int> nav-skip-probe: KioskNavState.index;
changed nav-skip-probe => {
if (KioskNavState.zone == "content" && KioskNavState.nav-active
&& KioskNavState.index - KioskNavState.tabs == s
&& desc.section.albums.length == 0 && KioskNavState.dir != 0) {
KioskNavState.index = Math.max(KioskNavState.tabs,
Math.min(KioskNavState.count - 1, KioskNavState.index + KioskNavState.dir));
}
}
// Left/Right shelf move: the shell pulses `hmove`; the focused
// shelf consumes it into its cursor (and resets the pulse).
property <int> hmove-probe: KioskNavState.hmove;
changed hmove-probe => {
if (self.shelf-focused && KioskNavState.hmove != 0
&& desc.section.albums.length > 0) {
self.cursor = Math.clamp(self.cursor + KioskNavState.hmove,
0, desc.section.albums.length - 1);
KioskNavState.hmove = 0;
}
}
// Enter pulse → open the cursor album.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.shelf-focused && desc.section.albums.length > 0
&& self.cursor < desc.section.albums.length) {
root.album-clicked(desc.section.albums[self.cursor].id);
}
}
// Keep the focused shelf's strip inside the page viewport.
// Page coords are fixed arithmetic: 16px pad + 50px tab row +
// 22px spacing, then a 240px pitch per shelf block (24px
// title + 12px + 182px strip + 22px spacing). viewport-y is
// WRITTEN from this callback on a settled layout, never read
// by a layout-feeding binding ("Recursion detected" class).
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.shelf-focused && desc.section.albums.length > 0) {
if (88px + s * 240px < -page-flick.viewport-y) {
page-flick.viewport-y = -(88px + s * 240px - 8px);
} else if (88px + s * 240px + 218px > -page-flick.viewport-y + page-flick.height) {
page-flick.viewport-y = -(88px + s * 240px + 218px - page-flick.height + 8px);
}
}
}
if desc.section.albums.length > 0: VerticalLayout {
spacing: 12px;
// Fixed-height title wrapper (was a bare Text): makes the
// shelf block a constant 218px so the scroll-into-view
// arithmetic above stays exact.
Rectangle {
height: 24px;
Text {
text: desc.section.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: 600;
vertical-alignment: center;
}
}
strip-flick := Flickable {
height: 182px;
viewport-width: strip.preferred-width;
viewport-height: self.height;
// Keep the cursor card inside the strip's horizontal
// viewport (card pitch = 128px art + 14px spacing).
property <int> cursor-probe: shelf-vl.cursor;
changed cursor-probe => {
if (shelf-vl.shelf-focused) {
if (shelf-vl.cursor * 142px < -self.viewport-x) {
self.viewport-x = -(shelf-vl.cursor * 142px - 8px);
} else if (shelf-vl.cursor * 142px + 128px > -self.viewport-x + self.width) {
self.viewport-x = -(shelf-vl.cursor * 142px + 128px - self.width + 8px);
}
}
}
strip := HorizontalLayout {
spacing: 14px;
alignment: start;
for album[i] in desc.section.albums: KioskCard {
album: album;
nav-focused: shelf-vl.shelf-focused && i == shelf-vl.cursor;
clicked(id) => {
root.album-clicked(id);
}
}
}
}
}
}
}
}
}