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
This commit is contained in:
vicrodh committed 2026-07-25 12:04:58 -06:00
1 parent 55caada4c4
commit f1eeaf9126
24 files changed
+2758 -51

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+481
View File
@@ -0,0 +1,481 @@
// QBZ — Steam Deck (neptune) controller layout, seeded by qbz-add-to-steam.
//
// Based on the stock Steam "Gamepad" template (controller_neptune_gamepad_joystick.vdf,
// Steam client 2026-06) — PURE GAMEPAD: only virtual X360 pad output, NO keyboard
// or mouse emulation, so QBZ's own gilrs gamepad thread is the single input source
// (see qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md, PR 3/PR 5).
//
// The one deviation from the template: Y is bound to Steam's "Show Keyboard"
// system action (controller_action SHOW_KEYBOARD — token verified in
// steamclient.so) as a manual OSK fallback next to the steam://open/keyboard
// deeplink. Y therefore does NOT emit an XInput button.
//
// TODO(verify-on-hardware): this file is authored from the known exported
// config format, not yet exported/validated by the Deck UI. Do the one-time
// validation from the plan: load it on a real Deck in Game Mode, confirm the
// layout applies on first launch (configset_<serial>.vdf path) and that Y
// opens the OSK, then re-export and replace this file if the format drifted.
"controller_mappings"
{
"version" "3"
"title" "QBZ Kiosk (gamepad + Y=keyboard)"
"description" "Pure gamepad layout for the QBZ kiosk: D-pad/stick navigate, A activates, B goes back, Y shows the Steam keyboard. No keyboard/mouse emulation."
"controller_type" "controller_neptune"
"major_revision" "0"
"minor_revision" "0"
"group"
{
"id" "0"
"mode" "four_buttons"
"name" ""
"description" ""
"inputs"
{
"button_a"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button A, Activate, , "
}
}
}
}
"button_b"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button B, Back, , "
}
}
}
}
"button_x"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button X, , , "
}
}
}
}
"button_y"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "controller_action SHOW_KEYBOARD, Show Keyboard, , "
}
}
}
}
}
}
"group"
{
"id" "1"
"mode" "dpad"
"name" ""
"description" ""
"inputs"
{
"dpad_north"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button dpad_up, , , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
}
"dpad_south"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button dpad_down, , , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
}
"dpad_east"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button dpad_right, , , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
}
"dpad_west"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button dpad_left, , , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
}
}
}
"group"
{
"id" "2"
"mode" "joystick_move"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Soft_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_RIGHT, , , "
}
}
}
}
}
}
"group"
{
"id" "3"
"mode" "joystick_move"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_LEFT, , , "
}
}
}
}
}
"settings"
{
"deadzone_inner_radius" "7199"
}
}
"group"
{
"id" "4"
"mode" "trigger"
"name" ""
"description" ""
"inputs"
{
}
"settings"
{
"output_trigger" "1"
}
}
"group"
{
"id" "5"
"mode" "trigger"
"name" ""
"description" ""
"inputs"
{
}
"settings"
{
"output_trigger" "2"
}
}
"group"
{
"id" "7"
"mode" "switches"
"name" ""
"description" ""
"inputs"
{
"button_escape"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button start, , , "
}
}
}
}
"button_menu"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button select, , , "
}
}
}
}
"left_bumper"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button shoulder_left, , , "
}
}
}
}
"right_bumper"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button shoulder_right, , , "
}
}
}
}
"button_back_left"
{
"activators"
{
"Full_Press"
{
"bindings"
{
}
}
}
}
"button_back_right"
{
"activators"
{
"Full_Press"
{
"bindings"
{
}
}
}
}
"button_back_left_upper"
{
"activators"
{
"Full_Press"
{
"bindings"
{
}
}
}
}
"button_back_right_upper"
{
"activators"
{
"Full_Press"
{
"bindings"
{
}
}
}
}
}
}
"group"
{
"id" "8"
"mode" "joystick_move"
"name" ""
"description" ""
"inputs"
{
"click"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button JOYSTICK_RIGHT, , , "
}
}
}
}
}
}
"group"
{
"id" "9"
"mode" "dpad"
"name" ""
"description" ""
"inputs"
{
"dpad_north"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_UP, , , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
}
"dpad_south"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_DOWN, , , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
}
"dpad_east"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_RIGHT, , , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
}
"dpad_west"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_LEFT, , , "
}
"settings"
{
"haptic_intensity" "1"
}
}
}
}
}
"settings"
{
"requires_click" "0"
}
}
"preset"
{
"id" "0"
"name" "Default"
"group_source_bindings"
{
"7" "switch active"
"0" "button_diamond active"
"1" "left_trackpad active"
"2" "right_trackpad active"
"3" "joystick active"
"4" "left_trigger active"
"5" "right_trigger active"
"8" "right_joystick active"
"9" "dpad active"
}
}
"settings"
{
"left_trackpad_mode" "0"
"right_trackpad_mode" "0"
}
}
+8
View File
@@ -11,4 +11,12 @@
// the user is typing (mirrors the Tauri `isInputTarget` check).
export global UiFocusState {
in-out property <bool> text-input-focused: false;
// Steam Deck OSK (Steam plan PR 4): fired on every focus transition so
// Rust (steam_osk) can open/close the Steam OSK — every text field rides
// it with zero per-field wiring. Mode: 0 = single-line (all fields today).
in-out property <int> osk-mode: 0;
callback osk(bool /* focused */, int /* mode */);
changed text-input-focused => {
root.osk(root.text-input-focused, root.osk-mode);
}
}
+53 -3
View File
@@ -2,9 +2,13 @@
// (same as the desktop AlbumPageView): a header (big cover + title + artist +
// quality + Play/Shuffle) and a simple track list (number + title + duration).
// Album track rows carry no artwork, so we show the track number.
//
// Keyboard/gamepad nav (PR 2): the track list is the nav model (1-column
// list, no tabs — index == track index). Enter plays the focused track. The
// header buttons (Play / Shuffle / artist link) stay touch-only in v1.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { AlbumState, NowPlayingState } from "../state.slint";
import { AlbumState, NowPlayingState, KioskNavState } from "../state.slint";
component PillButton inherits Rectangle {
in property <string> label;
@@ -37,7 +41,32 @@ export component KioskAlbum inherits Rectangle {
property <length> pad: 22px;
Flickable {
// ---- Nav geometry publish (PR 2): no tabs, one index per track.
function publish-nav() {
KioskNavState.tabs = 0;
KioskNavState.shelves = false;
KioskNavState.columns = 1;
KioskNavState.count = AlbumState.tracks.length;
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-len-probe: AlbumState.tracks.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter pulse → play the focused track (the row TouchArea's own action).
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content"
&& KioskNavState.index >= 0 && KioskNavState.index < AlbumState.tracks.length) {
root.media-action("track", AlbumState.tracks[KioskNavState.index].id, "play");
}
}
page-flick := Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
@@ -122,9 +151,30 @@ export component KioskAlbum inherits Rectangle {
border-radius: Radius.sm;
// Same local-track-id alias as TrackPlayCell (offline-cache tracks).
property <bool> current: NowPlayingState.track-id == track.id || (NowPlayingState.local-track-id != "" && NowPlayingState.local-track-id == track.id);
background: trow.has-hover
// PR 2 — focus ring + scroll-into-view. Page coords are
// fixed arithmetic: 22px pad + ~180px header + 22px
// spacing, then a 54px pitch (52px row + 2px spacing).
// viewport-y is written from a callback on a settled
// layout ("Recursion detected" class).
property <bool> row-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content" && KioskNavState.index == i;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.row-focused) {
if (224px + i * 54px < -page-flick.viewport-y) {
page-flick.viewport-y = -(224px + i * 54px - 8px);
} else if (224px + i * 54px + 52px > -page-flick.viewport-y + page-flick.height) {
page-flick.viewport-y = -(224px + i * 54px + 52px - page-flick.height + 8px);
}
}
}
background: self.row-focused
? Theme.accent.with-alpha(0.18)
: trow.has-hover
? Theme.alpha-6
: self.current ? Theme.accent.with-alpha(0.10) : transparent;
border-width: self.row-focused ? 2px : 0px;
border-color: Theme.accent;
HorizontalLayout {
padding-left: 12px;
padding-right: 12px;
+36 -1
View File
@@ -8,7 +8,7 @@
// plain full grid mounted every card at once and spiked CPU on entry — a Pi
// can't afford that. Binds `[AlbumCardItem]` so artwork pushes land on the cards.
import { Theme } from "../foundation/semantic-colors.slint";
import { AlbumCardItem } from "../state.slint";
import { AlbumCardItem, KioskNavState } from "../state.slint";
import { KioskCard } from "KioskCard.slint";
export component KioskAlbumGrid inherits Flickable {
@@ -25,6 +25,40 @@ export component KioskAlbumGrid inherits Flickable {
viewport-width: self.width;
viewport-height: root.rows * (root.card-h + root.gap) + root.pad;
// ---- Keyboard/gamepad nav (PR 2). The HOSTING VIEW publishes the grid
// geometry (tabs / columns / count) into KioskNavState; the grid only
// mirrors the focus index (ring + scroll-into-view) and answers the
// Enter pulse. The content index space skips `tabs` leading tab entries.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& root.focused-item >= 0
&& root.focused-item < root.albums.length;
// Scroll the focused card's row into view. viewport-y is WRITTEN from
// this callback (plain imperative assignment on a settled layout), never
// read by a binding that feeds layout — the AlbumCollectionView
// "Recursion detected" panic class.
property <length> focus-top: root.pad + Math.floor(root.focused-item / root.columns) * (root.card-h + root.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (root.item-focused) {
if (root.focus-top < -self.viewport-y) {
self.viewport-y = -(root.focus-top - 8px);
} else if (root.focus-top + root.card-h > -self.viewport-y + self.height) {
self.viewport-y = -(root.focus-top + root.card-h - self.height + 8px);
}
}
}
// Enter pulse → open the focused album.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (root.item-focused) {
root.open(root.albums[root.focused-item].id);
}
}
Rectangle {
for album[i] in root.albums: slot := Rectangle {
x: root.pad + Math.mod(i, root.columns) * (root.card-w + root.gap);
@@ -39,6 +73,7 @@ export component KioskAlbumGrid inherits Flickable {
width: root.card-w;
art-size: root.card-w;
album: album;
nav-focused: root.item-focused && root.focused-item == i;
clicked(id) => {
root.open(id);
}
+104 -5
View File
@@ -2,9 +2,15 @@
// ArtistState (same as the desktop ArtistPageView): a header (round portrait +
// name + Play/Radio) and the discography as simple KioskCard shelves per release
// section (the junk "other" bucket is skipped).
//
// Keyboard/gamepad nav (PR 2): SHELF mode, same model as KioskDiscover — one
// nav index per shelf (columns == 1, no tabs), Left/Right pulses `hmove`
// consumed by the focused shelf's local cursor, skipped shelves ("other"
// bucket / empty) are stepped over in the last travel direction. The header
// buttons (Play / Radio) stay touch-only in v1.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { ArtistState } from "../state.slint";
import { ArtistState, KioskNavState } from "../state.slint";
import { KioskCard } from "KioskCard.slint";
export component KioskArtist inherits Rectangle {
@@ -13,7 +19,23 @@ export component KioskArtist inherits Rectangle {
property <length> pad: 22px;
Flickable {
// ---- Nav geometry publish (PR 2): no tab strip, one index per shelf.
function publish-nav() {
KioskNavState.tabs = 0;
KioskNavState.shelves = true;
KioskNavState.columns = 1;
KioskNavState.count = ArtistState.release-sections.length;
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-count-probe: ArtistState.release-sections.length;
changed nav-count-probe => {
root.publish-nav();
}
page-flick := Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
@@ -93,24 +115,101 @@ export component KioskArtist inherits Rectangle {
}
}
for section in ArtistState.release-sections: VerticalLayout {
for section[s] in ArtistState.release-sections: shelf-vl := VerticalLayout {
// PR 2 — per-shelf nav state (same idiom as KioskDiscover).
property <int> cursor: 0;
property <bool> shelf-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& KioskNavState.index - KioskNavState.tabs == s;
// Skip the shelves this view doesn't render ("other" bucket,
// empty sections) in the last travel direction.
property <int> nav-skip-probe: KioskNavState.index;
changed nav-skip-probe => {
if (KioskNavState.zone == "content" && KioskNavState.nav-active
&& KioskNavState.index - KioskNavState.tabs == s
&& (section.release-type == "other" || 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: consume the shell's `hmove` pulse.
property <int> hmove-probe: KioskNavState.hmove;
changed hmove-probe => {
if (self.shelf-focused && KioskNavState.hmove != 0
&& section.albums.length > 0) {
self.cursor = Math.clamp(self.cursor + KioskNavState.hmove,
0, 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 && section.albums.length > 0
&& self.cursor < section.albums.length) {
root.open-album(section.albums[self.cursor].id);
}
}
// Keep the focused shelf's strip inside the page viewport.
// Fixed page arithmetic: 22px pad + 140px header + 22px
// spacing, then a 240px pitch per shelf block (24px title +
// 12px + 182px strip + 22px spacing). viewport-y is written
// from a callback on a settled layout ("Recursion detected"
// class — never a layout-feeding binding).
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.shelf-focused && section.albums.length > 0) {
if (184px + s * 240px < -page-flick.viewport-y) {
page-flick.viewport-y = -(184px + s * 240px - 8px);
} else if (184px + s * 240px + 218px > -page-flick.viewport-y + page-flick.height) {
page-flick.viewport-y = -(184px + s * 240px + 218px - page-flick.height + 8px);
}
}
}
if section.release-type != "other" && section.albums.length > 0: VerticalLayout {
spacing: 12px;
// Fixed-height title wrapper — keeps the shelf block a
// constant 218px for the scroll arithmetic above.
Rectangle {
height: 24px;
Text {
text: section.title;
color: Theme.text-primary;
font-size: 18px;
font-weight: 600;
vertical-alignment: center;
}
Flickable {
}
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 in section.albums: KioskCard {
for album[i] in section.albums: KioskCard {
album: album;
nav-focused: shelf-vl.shelf-focused && i == shelf-vl.cursor;
clicked(id) => {
root.open-album(id);
}
@@ -3,15 +3,22 @@
// SearchState.artists (SlimItem: id/title/artwork) and FavoritesState.artists
// (FavoriteArtistItem: id/name/image).
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
export component KioskArtistCard inherits Rectangle {
in property <string> id;
in property <string> name;
in property <image> avatar;
in property <length> size: 120px;
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
in property <bool> nav-focused: false;
callback clicked(string);
width: root.size;
border-width: root.nav-focused ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
+8
View File
@@ -13,9 +13,17 @@ export component KioskCard inherits Rectangle {
in property <AlbumCardItem> album;
in property <length> art-size: 128px;
in property <bool> round: false;
// Keyboard/gamepad nav (PR 2): the hosting view sets this when the shell
// focus index lands on this card. A 3px accent ring + tint, readable at
// 10-foot distance.
in property <bool> nav-focused: false;
callback clicked(string);
width: root.art-size;
border-width: root.nav-focused ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
+144 -6
View File
@@ -6,19 +6,32 @@
// 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 } from "../state.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;
@@ -43,7 +56,48 @@ export component KioskDiscover inherits Rectangle {
callback media-action(string, string, string);
callback discover-view-all(string, string);
Flickable {
// ---- 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;
@@ -68,16 +122,19 @@ export component KioskDiscover inherits Rectangle {
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"); }
}
}
@@ -86,30 +143,111 @@ export component KioskDiscover inherits Rectangle {
// Album shelves for the active tab. Only rows carrying album data
// render (length > 0); playlist/mix/slim rows are skipped in v1.
for desc in (DiscoverState.active-tab == "editorPicks"
for desc[s] in (DiscoverState.active-tab == "editorPicks"
? DiscoverState.editor-sections
: DiscoverState.active-tab == "forYou"
? DiscoverState.foryou-sections
: DiscoverState.home-sections): VerticalLayout {
: 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;
}
}
Flickable {
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 in desc.section.albums: KioskCard {
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);
}
+186 -8
View File
@@ -5,10 +5,18 @@
// FavoritesActions.select-tab (Rust lazy-loads each tab). Covers are push-only
// except album covers (windowed — v1 shows the first ~60; the window hook is a
// follow-up).
//
// Keyboard/gamepad nav (PR 2): the nav index space is the 5 tabs (indices
// 0-4) followed by the active tab's items as a uniform grid (tracks = a
// 1-column list). The view publishes that geometry into KioskNavState; each
// container mirrors the focus index (ring + scroll-into-view) and answers
// the Enter pulse. viewport-y is only ever WRITTEN from callbacks on a
// settled layout, never read by layout-feeding bindings (the
// AlbumCollectionView "Recursion detected" panic class).
import { ListView } from "std-widgets.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { FavoritesState, FavoritesActions } from "../state.slint";
import { FavoritesState, FavoritesActions, KioskNavState } from "../state.slint";
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
import { KioskArtistCard } from "KioskArtistCard.slint";
import { KioskTrackRow } from "KioskTrackRow.slint";
@@ -16,9 +24,14 @@ import { KioskTrackRow } from "KioskTrackRow.slint";
component LibTab inherits Rectangle {
in property <string> label;
in property <string> id;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
in property <bool> nav-focused: false;
callback picked(string);
property <bool> active: FavoritesState.active-tab == root.id;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -50,6 +63,64 @@ export component KioskLibrary inherits Rectangle {
property <length> pad: 16px;
// ---- Nav geometry publish (PR 2): 5 tabs + the active tab's items.
function publish-nav() {
KioskNavState.tabs = 5;
KioskNavState.shelves = false;
if (FavoritesState.active-tab == "tracks") {
KioskNavState.columns = 1;
KioskNavState.count = 5 + FavoritesState.tracks-visible.length;
} else if (FavoritesState.active-tab == "albums") {
KioskNavState.columns = 6;
KioskNavState.count = 5 + FavoritesState.albums-visible.length;
} else if (FavoritesState.active-tab == "artists") {
KioskNavState.columns = 7;
KioskNavState.count = 5 + FavoritesState.artists-visible.length;
} else if (FavoritesState.active-tab == "playlists") {
KioskNavState.columns = 6;
KioskNavState.count = 5 + FavoritesState.playlists-visible.length;
} else {
KioskNavState.columns = 7;
KioskNavState.count = 5 + FavoritesState.labels-visible.length;
}
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <string> nav-tab-probe: FavoritesState.active-tab;
changed nav-tab-probe => {
root.publish-nav();
}
property <int> nav-len-probe: FavoritesState.tracks-visible.length
+ FavoritesState.albums-visible.length
+ FavoritesState.artists-visible.length
+ FavoritesState.playlists-visible.length
+ FavoritesState.labels-visible.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter on a tab → the same switch the tab TouchAreas drive. Items are
// activated by their own container (each watches the same pulse).
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) {
FavoritesActions.select-tab("tracks");
} else if (KioskNavState.index == 1) {
FavoritesActions.select-tab("albums");
} else if (KioskNavState.index == 2) {
FavoritesActions.select-tab("artists");
} else if (KioskNavState.index == 3) {
FavoritesActions.select-tab("playlists");
} else if (KioskNavState.index == 4) {
FavoritesActions.select-tab("labels");
}
}
}
VerticalLayout {
spacing: 0px;
@@ -61,11 +132,11 @@ export component KioskLibrary inherits Rectangle {
padding-right: root.pad;
spacing: 22px;
alignment: start;
LibTab { label: @tr("Tracks"); id: "tracks"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Albums"); id: "albums"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Artists"); id: "artists"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Playlists"); id: "playlists"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Labels"); id: "labels"; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Tracks"); id: "tracks"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Albums"); id: "albums"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Artists"); id: "artists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Playlists"); id: "playlists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(t) => { FavoritesActions.select-tab(t); } }
LibTab { label: @tr("Labels"); id: "labels"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 4; picked(t) => { FavoritesActions.select-tab(t); } }
}
Rectangle {
y: parent.height - 1px;
@@ -81,9 +152,33 @@ export component KioskLibrary inherits Rectangle {
// ListView windows the rows (only the visible band is mounted) — a
// plain VerticalLayout of every track spiked CPU on entry, which a Pi
// can't afford.
if FavoritesState.active-tab == "tracks": ListView {
for track in FavoritesState.tracks-visible: KioskTrackRow {
if FavoritesState.active-tab == "tracks": tracks-list := ListView {
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < FavoritesState.tracks-visible.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 62px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 62px - 8px);
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
FavoritesActions.play-track(FavoritesState.tracks-visible[self.focused-item].id);
}
}
for track[i] in FavoritesState.tracks-visible: KioskTrackRow {
track: track;
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
play(id) => {
FavoritesActions.play-track(id);
}
@@ -103,6 +198,31 @@ export component KioskLibrary inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(FavoritesState.artists-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < FavoritesState.artists-visible.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
root.open-artist(FavoritesState.artists-visible[self.focused-item].id);
}
}
Rectangle {
for artist[i] in FavoritesState.artists-visible: KioskArtistCard {
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
@@ -111,6 +231,7 @@ export component KioskLibrary inherits Rectangle {
id: artist.id;
name: artist.name;
avatar: artist.image;
nav-focused: arts.item-focused && arts.focused-item == i;
clicked(id) => {
root.open-artist(id);
}
@@ -124,6 +245,33 @@ export component KioskLibrary inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(FavoritesState.labels-visible.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < FavoritesState.labels-visible.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
FavoritesActions.open-label(
FavoritesState.labels-visible[self.focused-item].id,
FavoritesState.labels-visible[self.focused-item].name);
}
}
Rectangle {
for label[i] in FavoritesState.labels-visible: KioskArtistCard {
x: root.pad + Math.mod(i, labs.cols) * (labs.cell + labs.gap);
@@ -132,6 +280,7 @@ export component KioskLibrary inherits Rectangle {
id: label.id;
name: label.name;
avatar: label.image;
nav-focused: labs.item-focused && labs.focused-item == i;
clicked(id) => {
FavoritesActions.open-label(label.id, label.name);
}
@@ -145,11 +294,40 @@ export component KioskLibrary inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(FavoritesState.playlists-visible.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < FavoritesState.playlists-visible.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 42px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 42px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 42px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
FavoritesActions.open-playlist(FavoritesState.playlists-visible[self.focused-item].id);
}
}
Rectangle {
for pl[i] in FavoritesState.playlists-visible: Rectangle {
x: root.pad + Math.mod(i, pls.cols) * (pls.cell + pls.gap);
y: root.pad + Math.floor(i / pls.cols) * (pls.cell + 42px + pls.gap);
width: pls.cell;
border-width: pls.item-focused && pls.focused-item == i ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: pls.item-focused && pls.focused-item == i ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
spacing: 7px;
+119 -7
View File
@@ -5,10 +5,15 @@
// (open-album routes to ContentView.local-album in Rust). v1 simplifications:
// no album-window / tracks-load-more hooks yet (first band loads), folders as a
// flat grid.
//
// Keyboard/gamepad nav (PR 2): same model as KioskLibrary — the index space
// is the 4 tabs followed by the active tab's items as a uniform grid
// (tracks = a 1-column list). The view publishes the geometry; containers
// mirror the focus index and answer the Enter pulse.
import { ListView } from "std-widgets.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { LocalLibraryState, LocalLibraryActions } from "../state.slint";
import { LocalLibraryState, LocalLibraryActions, KioskNavState } from "../state.slint";
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
import { KioskArtistCard } from "KioskArtistCard.slint";
import { KioskTrackRow } from "KioskTrackRow.slint";
@@ -16,9 +21,14 @@ import { KioskTrackRow } from "KioskTrackRow.slint";
component LLTab inherits Rectangle {
in property <string> label;
in property <string> id;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
in property <bool> nav-focused: false;
callback picked(string);
property <bool> active: LocalLibraryState.active-tab == root.id;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -47,6 +57,57 @@ component LLTab inherits Rectangle {
export component KioskLocalLibrary inherits Rectangle {
property <length> pad: 16px;
// ---- Nav geometry publish (PR 2): 4 tabs + the active tab's items.
function publish-nav() {
KioskNavState.tabs = 4;
KioskNavState.shelves = false;
if (LocalLibraryState.active-tab == "albums") {
KioskNavState.columns = 6;
KioskNavState.count = 4 + LocalLibraryState.albums-visible.length;
} else if (LocalLibraryState.active-tab == "artists") {
KioskNavState.columns = 7;
KioskNavState.count = 4 + LocalLibraryState.artists.length;
} else if (LocalLibraryState.active-tab == "folders") {
KioskNavState.columns = 6;
KioskNavState.count = 4 + LocalLibraryState.folders-visible.length;
} else {
KioskNavState.columns = 1;
KioskNavState.count = 4 + LocalLibraryState.tracks-visible.length;
}
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <string> nav-tab-probe: LocalLibraryState.active-tab;
changed nav-tab-probe => {
root.publish-nav();
}
property <int> nav-len-probe: LocalLibraryState.albums-visible.length
+ LocalLibraryState.artists.length
+ LocalLibraryState.folders-visible.length
+ LocalLibraryState.tracks-visible.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter on a tab → the same switch the tab 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) {
LocalLibraryActions.select-tab("albums");
} else if (KioskNavState.index == 1) {
LocalLibraryActions.select-tab("artists");
} else if (KioskNavState.index == 2) {
LocalLibraryActions.select-tab("folders");
} else if (KioskNavState.index == 3) {
LocalLibraryActions.select-tab("tracks");
}
}
}
VerticalLayout {
spacing: 0px;
@@ -58,10 +119,10 @@ export component KioskLocalLibrary inherits Rectangle {
padding-right: root.pad;
spacing: 22px;
alignment: start;
LLTab { label: @tr("Albums"); id: "albums"; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Artists"); id: "artists"; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Folders"); id: "folders"; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Tracks"); id: "tracks"; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Albums"); id: "albums"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Artists"); id: "artists"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Folders"); id: "folders"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(t) => { LocalLibraryActions.select-tab(t); } }
LLTab { label: @tr("Tracks"); id: "tracks"; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(t) => { LocalLibraryActions.select-tab(t); } }
}
Rectangle {
y: parent.height - 1px;
@@ -94,6 +155,32 @@ export component KioskLocalLibrary inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(LocalLibraryState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < LocalLibraryState.artists.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
// The TouchArea path passes the artist NAME as id.
LocalLibraryActions.open-artist(LocalLibraryState.artists[self.focused-item].name);
}
}
Rectangle {
for artist[i] in LocalLibraryState.artists: KioskArtistCard {
x: root.pad + Math.mod(i, arts.cols) * (arts.cell + arts.gap);
@@ -102,6 +189,7 @@ export component KioskLocalLibrary inherits Rectangle {
id: artist.name;
name: artist.display-name;
avatar: artist.image;
nav-focused: arts.item-focused && arts.focused-item == i;
clicked(id) => {
LocalLibraryActions.open-artist(id);
}
@@ -109,9 +197,33 @@ export component KioskLocalLibrary inherits Rectangle {
}
}
if LocalLibraryState.active-tab == "tracks": ListView {
for track in LocalLibraryState.tracks-visible: KioskTrackRow {
if LocalLibraryState.active-tab == "tracks": tracks-list := ListView {
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < LocalLibraryState.tracks-visible.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 62px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 62px - 8px);
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
LocalLibraryActions.track-action(LocalLibraryState.tracks-visible[self.focused-item].id, "play");
}
}
for track[i] in LocalLibraryState.tracks-visible: KioskTrackRow {
track: track;
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
play(id) => {
LocalLibraryActions.track-action(id, "play");
}
+81 -1
View File
@@ -3,15 +3,24 @@
// Collections (separate ContentView routes); simple cover cards, tap opens the
// detail. Covers are push-only. Mounted for both ContentView.mixtapes and
// ContentView.collections.
//
// Keyboard/gamepad nav (PR 2): the index space is the 2 tabs followed by the
// active list as a 6-column grid. Enter on a tab routes through the same
// header-menu navigation the tab TouchAreas use.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState, ContentView } from "../state.slint";
import { MyQbzState, MyQbzActions, MixtapeCardItem, NavState, ContentView, KioskNavState } from "../state.slint";
component MyQbzTab inherits Rectangle {
in property <string> label;
in property <bool> active;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
in property <bool> nav-focused: false;
callback picked;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -40,8 +49,14 @@ component MyQbzTab inherits Rectangle {
component MyQbzCardLite inherits Rectangle {
in property <MixtapeCardItem> item;
in property <length> size: 150px;
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
in property <bool> nav-focused: false;
callback clicked(string);
width: root.size;
border-width: root.nav-focused ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: root.nav-focused ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
spacing: 7px;
@@ -94,6 +109,37 @@ export component KioskMyQBZ inherits Rectangle {
property <length> pad: 16px;
property <bool> on-collections: NavState.view == ContentView.collections;
// ---- Nav geometry publish (PR 2): 2 tabs + the active list.
function publish-nav() {
KioskNavState.tabs = 2;
KioskNavState.shelves = false;
KioskNavState.columns = 6;
KioskNavState.count = 2 + (root.on-collections
? MyQbzState.collections.length
: MyQbzState.mixtapes.length);
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-len-probe: MyQbzState.collections.length + MyQbzState.mixtapes.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter on a tab → the same route the tab TouchAreas navigate to.
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) {
root.navigate("myqbz-mixtapes");
} else if (KioskNavState.index == 1) {
root.navigate("myqbz-collections");
}
}
}
VerticalLayout {
spacing: 0px;
@@ -108,6 +154,7 @@ export component KioskMyQBZ inherits Rectangle {
MyQbzTab {
label: @tr("Mixtapes");
active: !root.on-collections;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
picked => {
root.navigate("myqbz-mixtapes");
}
@@ -115,6 +162,7 @@ export component KioskMyQBZ inherits Rectangle {
MyQbzTab {
label: @tr("Collections");
active: root.on-collections;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
picked => {
root.navigate("myqbz-collections");
}
@@ -136,12 +184,44 @@ export component KioskMyQBZ inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil((root.on-collections ? MyQbzState.collections.length : MyQbzState.mixtapes.length) / self.cols) * (self.cell + 44px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < (root.on-collections
? MyQbzState.collections.length
: MyQbzState.mixtapes.length);
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 44px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 44px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 44px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
if (root.on-collections) {
MyQbzActions.open-card(MyQbzState.collections[self.focused-item].id);
} else {
MyQbzActions.open-card(MyQbzState.mixtapes[self.focused-item].id);
}
}
}
Rectangle {
for item[i] in (root.on-collections ? MyQbzState.collections : MyQbzState.mixtapes): MyQbzCardLite {
x: root.pad + Math.mod(i, grid.cols) * (grid.cell + grid.gap);
y: root.pad + Math.floor(i / grid.cols) * (grid.cell + 44px + grid.gap);
size: grid.cell;
item: item;
nav-focused: grid.item-focused && grid.focused-item == i;
clicked(id) => {
MyQbzActions.open-card(id);
}
+104 -4
View File
@@ -7,15 +7,19 @@
// QueueState.panel-opened() on init so the paginated queue slice is fresh.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { NowPlayingState, QueueState, QueueItem, LyricsState, ShellState } from "../state.slint";
import { NowPlayingState, QueueState, QueueItem, LyricsState, ShellState, KioskNavState } from "../state.slint";
import { LyricsLinesView } from "LyricsLinesView.slint";
component QueueRowLite inherits Rectangle {
in property <QueueItem> item;
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskTrackRow.
in property <bool> nav-focused: false;
callback play;
height: 58px;
border-radius: Radius.sm;
background: qrow.has-hover ? Theme.alpha-6 : transparent;
background: root.nav-focused ? Theme.accent.with-alpha(0.18) : qrow.has-hover ? Theme.alpha-6 : transparent;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
HorizontalLayout {
padding-left: 8px;
padding-right: 12px;
@@ -74,8 +78,15 @@ component QueueRowLite inherits Rectangle {
component QueueTab inherits Rectangle {
in property <string> label;
in property <bool> active;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
// Only the right-panel Queue/History pair joins the nav model; the
// left Cover/Lyrics pair leaves this false.
in property <bool> nav-focused: false;
callback picked;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -115,11 +126,52 @@ export component KioskNowPlaying inherits Rectangle {
// lightweight lyrics surface (no immersive visualizer needed, Pi-friendly).
property <bool> show-lyrics: false;
// ---- Keyboard/gamepad nav (PR 2). The nav model is the right panel:
// 2 queue tabs (Up Next / History) followed by the active tab's rows as
// a 1-column list. Enter on a row plays it; Enter on a tab switches.
// The left panel (cover/lyrics toggle, artist link, seek) stays
// touch-only in v1; the transport row joins the "player" zone (ring
// below; Enter = play/pause handled by the shell).
function publish-nav() {
KioskNavState.tabs = 2;
KioskNavState.shelves = false;
KioskNavState.columns = 1;
KioskNavState.count = 2 + (QueueState.tab == 0
? QueueState.upcoming-page.length
: QueueState.history.length);
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
property <int> nav-tab-probe: QueueState.tab;
changed nav-tab-probe => {
root.publish-nav();
}
property <int> nav-len-probe: QueueState.upcoming-page.length + QueueState.history.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter pulse → queue tab switch / play the focused queue row.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content") {
if (KioskNavState.index < KioskNavState.tabs) {
QueueState.set-tab(KioskNavState.index);
} else {
if (QueueState.tab == 0 && KioskNavState.index - 2 < QueueState.upcoming-page.length) {
QueueState.play-upcoming(KioskNavState.index - 2);
} else if (QueueState.tab == 1 && KioskNavState.index - 2 < QueueState.history.length) {
QueueState.play-history(KioskNavState.index - 2);
}
}
}
}
init => {
QueueState.panel-opened();
// Mount in Cover mode — make sure a stale follow flag from a prior visit
// doesn't keep the sync engine running.
ShellState.kiosk-lyrics-follow = false;
root.publish-nav();
}
HorizontalLayout {
@@ -278,6 +330,12 @@ export component KioskNowPlaying inherits Rectangle {
// space on short windows).
Rectangle {
height: 46px;
// PR 2 — the "player" zone ring (PlayerBarSmall is hidden
// on this view, so the in-page transport carries it).
border-width: KioskNavState.nav-active && KioskNavState.zone == "player" ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: KioskNavState.nav-active && KioskNavState.zone == "player" ? Theme.accent.with-alpha(0.08) : transparent;
HorizontalLayout {
alignment: center;
TransportControls {
@@ -321,6 +379,7 @@ export component KioskNowPlaying inherits Rectangle {
QueueTab {
label: @tr("Up Next");
active: QueueState.tab == 0;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0;
picked => {
QueueState.set-tab(0);
}
@@ -328,6 +387,7 @@ export component KioskNowPlaying inherits Rectangle {
QueueTab {
label: @tr("History");
active: QueueState.tab == 1;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1;
picked => {
QueueState.set-tab(1);
}
@@ -341,26 +401,66 @@ export component KioskNowPlaying inherits Rectangle {
// the page is cheap.)
Rectangle {
vertical-stretch: 1;
if QueueState.tab == 0: Flickable {
if QueueState.tab == 0: up-flick := Flickable {
viewport-width: self.width;
viewport-height: up-body.preferred-height;
// PR 2 — scroll the focused row into view (60px
// pitch: 58px row + 2px spacing). Written from a
// callback on a settled layout.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < QueueState.upcoming-page.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 60px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 60px - 8px);
} else if (self.focused-item * 60px + 58px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 60px + 58px - self.height + 8px);
}
}
}
up-body := VerticalLayout {
spacing: 2px;
for item[i] in QueueState.upcoming-page: QueueRowLite {
item: item;
nav-focused: up-flick.item-focused && up-flick.focused-item == i;
play => {
QueueState.play-upcoming(i);
}
}
}
}
if QueueState.tab == 1: Flickable {
if QueueState.tab == 1: hist-flick := Flickable {
viewport-width: self.width;
viewport-height: hist-body.preferred-height;
// PR 2 — same scroll-into-view as the Up Next list.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < QueueState.history.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 60px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 60px - 8px);
} else if (self.focused-item * 60px + 58px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 60px + 58px - self.height + 8px);
}
}
}
hist-body := VerticalLayout {
spacing: 2px;
for item[i] in QueueState.history: QueueRowLite {
item: item;
nav-focused: hist-flick.item-focused && hist-flick.focused-item == i;
play => {
QueueState.play-history(i);
}
+141 -8
View File
@@ -8,7 +8,7 @@
import { ListView } from "std-widgets.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { SearchState, SearchActions } from "../state.slint";
import { SearchState, SearchActions, KioskNavState } from "../state.slint";
import { KioskAlbumGrid } from "KioskAlbumGrid.slint";
import { KioskArtistCard } from "KioskArtistCard.slint";
import { KioskTrackRow } from "KioskTrackRow.slint";
@@ -17,9 +17,14 @@ import { KioskCard } from "KioskCard.slint";
component SearchTab inherits Rectangle {
in property <string> label;
in property <int> index;
// Keyboard/gamepad nav (PR 2): this tab holds the shell focus ring.
in property <bool> nav-focused: false;
callback picked(int);
property <bool> active: SearchState.tab == root.index;
horizontal-stretch: 0;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
@@ -89,6 +94,55 @@ export component KioskSearch inherits Rectangle {
property <length> pad: 16px;
// ---- Keyboard/gamepad nav (PR 2). The index space is the 5 tabs
// (All/Albums/Tracks/Artists/Playlists) followed by the active tab's
// items as a uniform grid. The All dashboard publishes NO items in v1
// (tabs only — its hero/preview mosaic isn't a uniform grid); the tab
// strips remain fully reachable. Containers mirror the focus index and
// answer the Enter pulse; the albums grid wires itself (KioskAlbumGrid).
function publish-nav() {
KioskNavState.tabs = 5;
KioskNavState.shelves = false;
if (SearchState.tab == 1) {
KioskNavState.columns = 6;
KioskNavState.count = 5 + SearchState.albums.length;
} else if (SearchState.tab == 2) {
KioskNavState.columns = 1;
KioskNavState.count = 5 + SearchState.tracks.length;
} else if (SearchState.tab == 3) {
KioskNavState.columns = 7;
KioskNavState.count = 5 + SearchState.artists.length;
} else if (SearchState.tab == 4) {
KioskNavState.columns = 6;
KioskNavState.count = 5 + SearchState.playlists.length;
} else {
KioskNavState.columns = 1;
KioskNavState.count = 5;
}
KioskNavState.index = Math.max(0, Math.min(KioskNavState.index, KioskNavState.count - 1));
}
init => {
root.publish-nav();
}
property <int> nav-tab-probe: SearchState.tab;
changed nav-tab-probe => {
root.publish-nav();
}
property <int> nav-len-probe: SearchState.albums.length + SearchState.tracks.length
+ SearchState.artists.length + SearchState.playlists.length;
changed nav-len-probe => {
root.publish-nav();
}
// Enter on a tab → the same switch the tab TouchAreas drive.
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (KioskNavState.nav-active && KioskNavState.zone == "content"
&& KioskNavState.index < KioskNavState.tabs) {
SearchActions.tab-changed(KioskNavState.index);
}
}
VerticalLayout {
spacing: 0px;
@@ -101,11 +155,11 @@ export component KioskSearch inherits Rectangle {
padding-right: root.pad;
spacing: 22px;
alignment: start;
SearchTab { label: @tr("All"); index: 0; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Albums"); index: 1; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Tracks"); index: 2; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Artists"); index: 3; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Playlists"); index: 4; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("All"); index: 0; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 0; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Albums"); index: 1; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 1; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Tracks"); index: 2; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 2; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Artists"); index: 3; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 3; picked(i) => { SearchActions.tab-changed(i); } }
SearchTab { label: @tr("Playlists"); index: 4; nav-focused: KioskNavState.nav-active && KioskNavState.zone == "content" && KioskNavState.index == 4; picked(i) => { SearchActions.tab-changed(i); } }
}
Rectangle {
y: parent.height - 1px;
@@ -365,9 +419,33 @@ export component KioskSearch inherits Rectangle {
}
// Tracks — windowed list (ListView mounts only the visible band).
if SearchState.tab == 2: ListView {
for track in SearchState.tracks: KioskTrackRow {
if SearchState.tab == 2: tracks-list := ListView {
// PR 2 — focus ring + Enter + scroll-into-view (62px rows).
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < SearchState.tracks.length;
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focused-item * 62px < -self.viewport-y) {
self.viewport-y = -(self.focused-item * 62px - 8px);
} else if (self.focused-item * 62px + 62px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focused-item * 62px + 62px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
root.media-action("track", SearchState.tracks[self.focused-item].id, "play");
}
}
for track[i] in SearchState.tracks: KioskTrackRow {
track: track;
nav-focused: tracks-list.item-focused && tracks-list.focused-item == i;
play(id) => {
root.media-action("track", id, "play");
}
@@ -381,6 +459,31 @@ export component KioskSearch inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(SearchState.artists.length / self.cols) * (self.cell + 26px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < SearchState.artists.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 26px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 26px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 26px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
root.open-artist(SearchState.artists[self.focused-item].id);
}
}
Rectangle {
for artist[i] in SearchState.artists: KioskArtistCard {
x: root.pad + Math.mod(i, agrid.cols) * (agrid.cell + agrid.gap);
@@ -389,6 +492,7 @@ export component KioskSearch inherits Rectangle {
id: artist.id;
name: artist.title;
avatar: artist.artwork;
nav-focused: agrid.item-focused && agrid.focused-item == i;
clicked(id) => {
root.open-artist(id);
}
@@ -403,11 +507,40 @@ export component KioskSearch inherits Rectangle {
property <length> cell: (self.width - 2 * root.pad - (self.cols - 1) * self.gap) / self.cols;
viewport-width: self.width;
viewport-height: Math.ceil(SearchState.playlists.length / self.cols) * (self.cell + 42px + self.gap) + root.pad;
// PR 2 — focus ring + Enter + scroll-into-view.
property <int> focused-item: KioskNavState.index - KioskNavState.tabs;
property <bool> item-focused: KioskNavState.nav-active
&& KioskNavState.zone == "content"
&& self.focused-item >= 0
&& self.focused-item < SearchState.playlists.length;
property <length> focus-top: root.pad + Math.floor(self.focused-item / self.cols) * (self.cell + 42px + self.gap);
property <int> nav-index-probe: KioskNavState.index;
changed nav-index-probe => {
if (self.item-focused) {
if (self.focus-top < -self.viewport-y) {
self.viewport-y = -(self.focus-top - 8px);
} else if (self.focus-top + self.cell + 42px > -self.viewport-y + self.height) {
self.viewport-y = -(self.focus-top + self.cell + 42px - self.height + 8px);
}
}
}
property <int> act-probe: KioskNavState.activate-seq;
changed act-probe => {
if (self.item-focused) {
root.media-action("playlist", SearchState.playlists[self.focused-item].id, "open");
}
}
Rectangle {
for pl[i] in SearchState.playlists: Rectangle {
x: root.pad + Math.mod(i, pgrid.cols) * (pgrid.cell + pgrid.gap);
y: root.pad + Math.floor(i / pgrid.cols) * (pgrid.cell + 42px + pgrid.gap);
width: pgrid.cell;
border-width: pgrid.item-focused && pgrid.focused-item == i ? 3px : 0px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: pgrid.item-focused && pgrid.focused-item == i ? Theme.accent.with-alpha(0.12) : transparent;
VerticalLayout {
alignment: start;
spacing: 7px;
+211 -3
View File
@@ -19,7 +19,7 @@ import { LineEdit } from "std-widgets.slint";
import { Theme } from "../foundation/semantic-colors.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState } from "../state.slint";
import { NavState, ContentView, OfflineState, PlaylistState, PurchaseDetailState, SearchActions, UiFocusState, AppearanceState, ImmersiveState, NowPlayingState, KioskNavState } from "../state.slint";
import { ImmersiveView } from "../immersive/ImmersiveView.slint";
import { PlayerBarSmall } from "PlayerBarSmall.slint";
import { NavRail } from "NavRail.slint";
@@ -73,6 +73,131 @@ export component KioskShell inherits Rectangle {
callback logout();
callback close-app();
callback recovery-login();
// Keyboard/gamepad nav (PR 2): emitted by the root FocusScope on Enter.
// The internal dispatch runs through nav-activate() below; this public
// seam exists so an outer layer (or a future gamepad driver) can observe
// or trigger the same "activate the focused item" request.
callback activate-focused();
// ---- Kiosk keyboard navigation (Steam Deck Game Mode plan, PR 2) ----
// One root FocusScope drives the whole kiosk: arrows move a focus ring
// through three zones — rail (bottom NavRail) → content (the mounted
// view's items) → player (the transport bar) — Enter activates the
// focused item, Esc goes back. The zone model lives in KioskNavState
// (state.slint); the mounted content view publishes its geometry there
// (tabs / columns / count / shelves) and mirrors `index` onto its focus
// rings. Pure Slint: PR 3's gamepad feeds this same path by injecting
// synthetic key events, so nothing here is keyboard-specific.
//
// Zone cycle (Down): rail → content → player → rail; Up reverses. The
// content index space is `tabs` leading tab-strip entries followed by
// the item rows (a `columns`-wide grid; columns == 1 = list/shelves).
function nav-left() {
KioskNavState.nav-active = true;
KioskNavState.dir = 0;
if (KioskNavState.zone == "rail") {
KioskNavState.index = Math.max(0, KioskNavState.index - 1);
} else if (KioskNavState.zone == "content") {
if (KioskNavState.index < KioskNavState.tabs) {
KioskNavState.index = Math.max(0, KioskNavState.index - 1);
} else if (KioskNavState.shelves) {
KioskNavState.hmove = -1;
} else if (Math.mod(KioskNavState.index - KioskNavState.tabs, KioskNavState.columns) > 0) {
KioskNavState.index = KioskNavState.index - 1;
}
}
}
function nav-right() {
KioskNavState.nav-active = true;
KioskNavState.dir = 0;
if (KioskNavState.zone == "rail") {
KioskNavState.index = Math.min(rail.tile-count - 1, KioskNavState.index + 1);
} else if (KioskNavState.zone == "content") {
if (KioskNavState.index < KioskNavState.tabs) {
KioskNavState.index = Math.min(KioskNavState.tabs - 1, KioskNavState.index + 1);
} else if (KioskNavState.shelves) {
KioskNavState.hmove = 1;
} else if (Math.mod(KioskNavState.index - KioskNavState.tabs, KioskNavState.columns) < KioskNavState.columns - 1
&& KioskNavState.index + 1 < KioskNavState.count) {
KioskNavState.index = KioskNavState.index + 1;
}
}
}
function nav-up() {
KioskNavState.nav-active = true;
KioskNavState.dir = -1;
if (KioskNavState.zone == "content") {
if (KioskNavState.index >= KioskNavState.tabs + KioskNavState.columns) {
KioskNavState.index = KioskNavState.index - KioskNavState.columns;
} else if (KioskNavState.index >= KioskNavState.tabs) {
// Grid row 0 → the tab strip above it (column → tab).
KioskNavState.index = Math.min(KioskNavState.tabs - 1, KioskNavState.index - KioskNavState.tabs);
} else {
// Tab strip (top of the cycle) → wrap down to the rail.
KioskNavState.zone = "rail";
KioskNavState.index = 0;
}
} else if (KioskNavState.zone == "rail") {
KioskNavState.zone = "player";
KioskNavState.index = 0;
} else {
KioskNavState.zone = "content";
KioskNavState.index = 0;
}
}
function nav-down() {
KioskNavState.nav-active = true;
KioskNavState.dir = 1;
if (KioskNavState.zone == "content") {
if (KioskNavState.index < KioskNavState.tabs) {
// Tab strip → the first item row (or the player bar when the
// view carries no items).
if (KioskNavState.count > KioskNavState.tabs) {
KioskNavState.index = KioskNavState.tabs;
} else {
KioskNavState.zone = "player";
KioskNavState.index = 0;
}
} else if (KioskNavState.index + KioskNavState.columns < KioskNavState.count) {
KioskNavState.index = KioskNavState.index + KioskNavState.columns;
} else {
KioskNavState.zone = "player";
KioskNavState.index = 0;
}
} else if (KioskNavState.zone == "player") {
KioskNavState.zone = "rail";
KioskNavState.index = 0;
} else {
KioskNavState.zone = "content";
KioskNavState.index = 0;
}
}
function nav-activate() {
KioskNavState.nav-active = true;
if (KioskNavState.zone == "player") {
// The transport is ONE focusable unit: Enter = play/pause.
if (NowPlayingState.has-track) {
NowPlayingState.toggle-play();
}
} else if (KioskNavState.zone == "rail") {
rail.activate-index(KioskNavState.index);
} else {
// Content views own their activation. They are conditionally
// mounted (can't be addressed from here), so the request goes
// out as a pulse the mounted view answers via a changed probe.
KioskNavState.activate-seq = KioskNavState.activate-seq + 1;
}
}
// Reset the nav model on every view switch: focus lands on the new
// view's content, parked at the first item.
property <ContentView> nav-view-probe: NavState.view;
changed nav-view-probe => {
KioskNavState.zone = "content";
KioskNavState.index = 0;
KioskNavState.dir = 0;
KioskNavState.hmove = 0;
}
// Kiosk chrome heights. The back bar + transport are one header-row tall
// each; the NavRail is a fat touch target.
@@ -180,6 +305,9 @@ export component KioskShell inherits Rectangle {
// on-screen keyboard (squeekboard) closes. Otherwise the
// search field keeps focus forever and the OSK never hides.
search-field.clear-focus();
// Hand keyboard focus back to the nav scope so arrows
// keep working after a search (PR 2).
keys.focus();
}
}
}
@@ -203,7 +331,11 @@ export component KioskShell inherits Rectangle {
// BEHIND the views (which handle their own taps); it only catches
// empty-area taps.
dismiss-osk-ta := TouchArea {
clicked => { search-field.clear-focus(); }
clicked => {
search-field.clear-focus();
// Same hand-back as the accepted path (PR 2).
keys.focus();
}
}
VerticalLayout {
vertical-stretch: 1;
@@ -500,7 +632,7 @@ export component KioskShell inherits Rectangle {
// --- Bottom NavRail (net-new). Each tile maps to the real route the
// Sidebar/Rust dispatcher already uses. ------------------------------
NavRail {
rail := NavRail {
height: root.navrail-height;
discover => {
root.header-menu-navigate("discover-home");
@@ -526,6 +658,82 @@ export component KioskShell inherits Rectangle {
}
}
// --- Player-zone focus ring (PR 2). The transport bar is ONE focusable
// unit (Enter toggles play/pause, handled in nav-activate). Drawn as an
// overlay on the bar's exact slot (above the NavRail); the Now Playing
// view hides PlayerBarSmall and draws its own ring on its in-page
// transport row instead.
if NavState.view != ContentView.now-playing
&& KioskNavState.nav-active
&& KioskNavState.zone == "player": Rectangle {
x: 0;
y: root.height - root.navrail-height - Layout.header-height;
width: root.width;
height: Layout.header-height;
border-width: 3px;
border-color: Theme.accent;
border-radius: Radius.sm;
background: Theme.accent.with-alpha(0.08);
}
// --- Root nav FocusScope (PR 2). Holds keyboard focus for the whole
// kiosk; TouchAreas never take focus, so it survives card/tile taps and
// only yields to the search LineEdit (which then sets
// UiFocusState.text-input-focused and every key is rejected here — typing
// must never navigate). Rejected keys keep their normal path.
keys := FocusScope {
key-pressed(event) => {
if (UiFocusState.text-input-focused) {
return reject;
}
// The immersive visualizer overlay owns its own keys while open.
if (ImmersiveState.open) {
return reject;
}
if (event.text == Key.LeftArrow) {
root.nav-left();
return accept;
}
if (event.text == Key.RightArrow) {
root.nav-right();
return accept;
}
if (event.text == Key.UpArrow) {
root.nav-up();
return accept;
}
if (event.text == Key.DownArrow) {
root.nav-down();
return accept;
}
if (event.text == Key.Return) {
root.activate-focused();
root.nav-activate();
return accept;
}
if (event.text == Key.Escape) {
if (NavState.can-back) {
NavState.request-back();
return accept;
}
return reject;
}
return reject;
}
}
// Deferred mount focus — synchronous focus during the initial layout
// races it; a one-shot timer grabs it a tick later (the desktop modal
// idiom, e.g. KeyboardShortcutsModal).
focus-timer := Timer {
interval: 30ms;
running: true;
triggered => {
self.running = false;
keys.focus();
}
}
// Immersive / visualizer overlay — the same app-level view the desktop
// mounts, but the desktop hangs it inside AppShell, so the kiosk shell must
// mount its own copy. Reached from the NavRail visualizer tile (which fires
+5 -1
View File
@@ -8,11 +8,15 @@ import { TrackItem } from "../state.slint";
export component KioskTrackRow inherits Rectangle {
in property <TrackItem> track;
// Keyboard/gamepad nav (PR 2) — same ring idiom as KioskCard.
in property <bool> nav-focused: false;
callback play(string);
height: 62px;
border-radius: Radius.sm;
background: ta.has-hover ? Theme.alpha-6 : transparent;
background: root.nav-focused ? Theme.accent.with-alpha(0.18) : ta.has-hover ? Theme.alpha-6 : transparent;
border-width: root.nav-focused ? 2px : 0px;
border-color: Theme.accent;
HorizontalLayout {
padding-left: 8px;
+29 -4
View File
@@ -8,20 +8,24 @@
// panel).
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { NavState, ContentView, ImmersiveState, MyQbzBrandingState } from "../state.slint";
import { NavState, ContentView, ImmersiveState, MyQbzBrandingState, KioskNavState } from "../state.slint";
component NavTile inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <bool> active;
// Keyboard/gamepad nav (PR 2): this tile holds the shell focus ring.
in property <bool> nav-focused: false;
callback clicked;
border-radius: Radius.sm;
background: root.active
background: root.nav-focused
? Theme.accent.with-alpha(0.22)
: root.active
? Theme.accent.with-alpha(0.12)
: ta.has-hover ? Theme.alpha-6 : transparent;
border-width: root.active ? 1px : 0px;
border-color: Theme.accent.with-alpha(0.30);
border-width: root.nav-focused ? 2px : root.active ? 1px : 0px;
border-color: root.nav-focused ? Theme.accent : Theme.accent.with-alpha(0.30);
VerticalLayout {
alignment: center;
@@ -65,6 +69,20 @@ export component NavRail inherits Rectangle {
background: Theme.surface-main;
// Keyboard/gamepad nav (PR 2): tile count for the shell's rail zone and
// the activation seam for Enter (the rail is always mounted, so the
// shell calls this directly instead of using the activate-seq pulse).
out property <int> tile-count: 7;
public function activate-index(i: int) {
if (i == 0) { root.discover(); }
else if (i == 1) { root.library(); }
else if (i == 2) { root.local-library(); }
else if (i == 3) { root.myqbz(); }
else if (i == 4) { root.playing(); }
else if (i == 5) { root.visualizer(); }
else if (i == 6) { root.menu(); }
}
// 7 tiles, 8px outer padding, 6px gaps (6 gaps). Search is intentionally
// absent — the back-bar carries a persistent search field, so a footer
// Search tile is redundant.
@@ -87,6 +105,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/compass.svg");
label: @tr("Discover");
active: NavState.view == ContentView.home;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 0;
clicked => {
root.discover();
}
@@ -99,6 +118,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/library-big.svg");
label: @tr("Library");
active: NavState.view == ContentView.favorites;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 1;
clicked => {
root.library();
}
@@ -111,6 +131,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/hard-drive.svg");
label: @tr("Local Library");
active: NavState.view == ContentView.local-library || NavState.view == ContentView.local-album;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 2;
clicked => {
root.local-library();
}
@@ -125,6 +146,7 @@ export component NavRail inherits Rectangle {
active: NavState.view == ContentView.mixtapes
|| NavState.view == ContentView.collections
|| NavState.view == ContentView.mixtape-detail;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 3;
clicked => {
root.myqbz();
}
@@ -137,6 +159,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/play-fill.svg");
label: @tr("Now Playing");
active: NavState.view == ContentView.now-playing;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 4;
clicked => {
root.playing();
}
@@ -149,6 +172,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/sliders-horizontal.svg");
label: @tr("Visualizer");
active: false;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 5;
clicked => {
root.visualizer();
}
@@ -161,6 +185,7 @@ export component NavRail inherits Rectangle {
icon: @image-url("../assets/icons/settings.svg");
label: @tr("Settings");
active: NavState.view == ContentView.settings;
nav-focused: KioskNavState.nav-active && KioskNavState.zone == "rail" && KioskNavState.index == 6;
clicked => {
root.menu();
}
+190
View File
@@ -0,0 +1,190 @@
//! Native gamepad input — Steam Deck plan PR 3
//! (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5).
//!
//! Steam Input's default template for non-Steam shortcuts exposes a VIRTUAL
//! Xbox 360 pad (uinput), not keyboard events — and QBZ reads neither by
//! default. `gilrs` (pure Rust, evdev) reads that pad and translates
//! D-pad / left stick / A / B into synthetic Slint key events through the
//! same `dispatch_event` pipeline as the winit synthetic-key hook
//! (main.rs install_browser_mouse_nav). PR 2's kiosk FocusScope consumes
//! them as ordinary arrow/Enter/Escape presses; any USB/BT pad on a HTPC
//! gets the same ride.
//!
//! The thread is spawned only when the kiosk profile is active or
//! `QBZ_GAMEPAD=1` forces it (see the call site in main.rs).
use std::time::{Duration, Instant};
use gilrs::{Axis, Button, EventType, Gilrs};
use slint::platform::{Key, WindowEvent};
use slint::ComponentHandle;
use crate::AppWindow;
/// Axis→direction hysteresis: engage when |axis| crosses the high threshold,
/// release below the low one (classic 10-foot anti-jitter; stick drift near
/// center must cause zero ghost navigation).
const AXIS_ENGAGE: f32 = 0.6;
const AXIS_RELEASE: f32 = 0.4;
/// Key repeat while a direction is held: initial delay, then interval.
const REPEAT_DELAY: Duration = Duration::from_millis(250);
const REPEAT_INTERVAL: Duration = Duration::from_millis(80);
/// Double-input guard for a user-selected keyboard-EMULATING Steam layout:
/// the same nav action can then arrive from Steam's kbd hook AND from gilrs —
/// collapse repeats of the same key inside this window.
const DEBOUNCE: Duration = Duration::from_millis(30);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Dir {
Up,
Down,
Left,
Right,
}
impl Dir {
fn key(self) -> Key {
match self {
Dir::Up => Key::UpArrow,
Dir::Down => Key::DownArrow,
Dir::Left => Key::LeftArrow,
Dir::Right => Key::RightArrow,
}
}
}
/// Spawn the gamepad listener thread (no-op log on failure — gamepad input
/// is a convenience, never fatal).
pub fn spawn(weak: slint::Weak<AppWindow>) {
if let Err(e) = std::thread::Builder::new()
.name("qbz-gamepad".into())
.spawn(move || run(weak))
{
log::warn!("[gamepad] could not spawn thread: {e}");
}
}
/// Press+release `key` as one synthetic tap on the UI thread, debounced.
/// A Return/Escape tap is a single action; arrows are also emitted by the
/// repeat engine. While a text field is focused, Escape is SUPPRESSED (B
/// on the pad must close Steam's OSK without also navigating back — plan
/// §4.5) and Return passes through (the OSK's own accept).
fn emit_tap(weak: &slint::Weak<AppWindow>, key: Key, last: &mut Option<(Key, Instant)>) {
let now = Instant::now();
if let Some((prev, at)) = last {
if *prev == key && now.duration_since(*at) < DEBOUNCE {
return;
}
}
*last = Some((key, now));
let _ = weak.upgrade_in_event_loop(move |w| {
if key == Key::Escape
&& w.global::<crate::UiFocusState>().get_text_input_focused()
{
return;
}
let text: slint::SharedString = key.into();
w.window().dispatch_event(WindowEvent::KeyPressed { text: text.clone() });
w.window().dispatch_event(WindowEvent::KeyReleased { text });
});
}
fn run(weak: slint::Weak<AppWindow>) {
let mut gilrs = match Gilrs::new() {
Ok(g) => g,
Err(e) => {
log::warn!("[gamepad] gilrs init failed (no evdev access?): {e}");
return;
}
};
let pads = gilrs.gamepads().count();
log::info!("[gamepad] listening ({pads} pad(s) connected)");
// Held direction for repeat: (dir, engaged_at, last_emit). One at a
// time — the newest direction wins, like any 10-foot UI.
let mut held: Option<(Dir, Instant, Instant)> = None;
let mut last_emit: Option<(Key, Instant)> = None;
loop {
// Block until the next pad event, waking at most by the repeat tick
// so a held direction keeps firing with no pad traffic.
let wake = held.map(|(_, _, last_emit_at)| {
REPEAT_INTERVAL.saturating_sub(last_emit_at.elapsed())
});
let event = match wake {
Some(t) => gilrs.next_event_blocking(Some(t)),
None => gilrs.next_event_blocking(None),
};
let now = Instant::now();
match event.map(|e| e.event) {
Some(EventType::ButtonPressed(Button::South, _)) => {
emit_tap(&weak, Key::Return, &mut last_emit);
}
Some(EventType::ButtonPressed(Button::East, _)) => {
emit_tap(&weak, Key::Escape, &mut last_emit);
}
Some(EventType::ButtonPressed(btn, _)) => {
if let Some(dir) = dpad_dir(btn) {
emit_tap(&weak, dir.key(), &mut last_emit);
held = Some((dir, now, now));
}
}
Some(EventType::ButtonReleased(btn, _)) => {
if let Some(dir) = dpad_dir(btn) {
if held.map(|(d, _, _)| d) == Some(dir) {
held = None;
}
}
}
Some(EventType::AxisChanged(axis, value, _)) => {
let (neg, pos) = match axis {
Axis::LeftStickX => (Dir::Left, Dir::Right),
Axis::LeftStickY => (Dir::Up, Dir::Down),
_ => {
continue;
}
};
if value.abs() >= AXIS_ENGAGE {
let dir = if value < 0.0 { neg } else { pos };
if held.map(|(d, _, _)| d) != Some(dir) {
emit_tap(&weak, dir.key(), &mut last_emit);
held = Some((dir, now, now));
}
} else if value.abs() < AXIS_RELEASE {
// Only disengage when the held direction lives on THIS axis.
if let Some((d, _, _)) = held {
if d == neg || d == pos {
held = None;
}
}
}
}
Some(_) => {}
// Timeout wake (or a spurious None): fire the repeat engine.
None => {}
}
// Repeat: a held direction re-emits after the initial delay, then on
// every interval.
if let Some((dir, engaged_at, last_emit_at)) = held.as_mut() {
if engaged_at.elapsed() >= REPEAT_DELAY
&& last_emit_at.elapsed() >= REPEAT_INTERVAL
{
let key = dir.key();
emit_tap(&weak, key, &mut last_emit);
*last_emit_at = Instant::now();
}
}
}
}
fn dpad_dir(btn: Button) -> Option<Dir> {
match btn {
Button::DPadUp => Some(Dir::Up),
Button::DPadDown => Some(Dir::Down),
Button::DPadLeft => Some(Dir::Left),
Button::DPadRight => Some(Dir::Right),
_ => None,
}
}
+55
View File
@@ -0,0 +1,55 @@
//! Steam on-screen keyboard via the deeplink — Steam Deck plan PR 4
//! (qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md §5).
//!
//! The Deck OSK only opens when something asks the Steam client for it. For
//! a non-Steam shortcut the only programmatic path is the deeplink
//! `steam://open/keyboard?...&Mode=N` (and `steam://close/keyboard`) — the
//! same call Valve's own SDL fires on the Deck, proven for non-Steam apps.
//! Entered text arrives as ordinary key events, so no extra plumbing: a text
//! field gains focus → we fire open; it loses focus → we fire close. Mode 0
//! (single-line) covers every field in the app today (login is browser-OAuth,
//! so there is no in-app email field to flag Mode 2 for).
//!
//! Activation gate: `SteamDeck=1` env (set by SteamOS Game Mode) or
//! `SDL_ENABLE_STEAM_SCREEN_KEYBOARD=1`, with a manual override
//! `QBZ_STEAM_OSK=0/1` (0 wins over everything — diagnostics).
/// True when the Steam OSK integration should fire.
fn enabled() -> bool {
match std::env::var("QBZ_STEAM_OSK") {
Ok(v) if v == "1" => return true,
Ok(v) if v == "0" => return false,
_ => {}
}
std::env::var("SteamDeck").map(|v| v == "1").unwrap_or(false)
|| std::env::var_os("SDL_ENABLE_STEAM_SCREEN_KEYBOARD").is_some()
}
/// A text field's focus flipped (wired to `UiFocusState.osk` in main.rs).
/// Fire-and-forget: the deeplink has no dismissal callback, so ALWAYS send
/// close on blur — a stranded OSK is worse than a redundant close.
pub fn focus_changed(focused: bool, mode: i32) {
if !enabled() {
return;
}
let url = if focused {
format!(
"steam://open/keyboard?XPosition=0&YPosition=0&Width=0&Height=0&Mode={mode}"
)
} else {
"steam://close/keyboard".to_string()
};
// xdg-open routes the steam:// scheme to the running Steam client; the
// fallback pokes Steam directly (only if already running).
if std::process::Command::new("xdg-open")
.arg(&url)
.spawn()
.is_err()
{
let _ = std::process::Command::new("steam")
.arg("-ifrunning")
.arg(&url)
.spawn();
}
log::info!("[steam-osk] {} (mode {mode})", if focused { "open" } else { "close" });
}
+546
View File
@@ -0,0 +1,546 @@
#!/usr/bin/env python3
"""qbz-add-to-steam — register QBZ as a non-Steam shortcut (Steam Deck / Game Mode).
Adds (or updates, idempotently) a "QBZ" entry in the user's shortcuts.vdf,
copies QBZ grid artwork, and seeds a pure-gamepad Steam Input controller
layout for the built-in Deck controller (neptune).
Stdlib only. Must run while Steam is CLOSED. See
qbz-nix-docs/steam-deck/STEAM_DECK_GAME_MODE_PLAN.md (PR 5).
"""
import argparse
import os
import re
import shutil
import struct
import sys
import time
import zlib
from pathlib import Path
APP_NAME = "QBZ"
DEFAULT_EXE = "/usr/bin/qbz"
ICON_PATH = "/usr/share/icons/hicolor/512x512/apps/com.blitzfc.qbz.png"
LAYOUT_NAME = "qbz_controller_neptune.vdf"
GAMESCOPE_OPTS = "env -u WAYLAND_DISPLAY WINIT_X11_SCALE_FACTOR=1"
KIOSK_OPTS = "QBZ_PROFILE=kiosk QBZ_KIOSK_FULLSCREEN=1"
ARTWORK = [ # (source name in assets/steam, grid name suffix)
("portrait.png", "p.png"), # 600x900 -> <appid>p.png
("landscape.png", ".png"), # 920x430 -> <appid>.png
("hero.png", "_hero.png"), # 1920x620 -> <appid>_hero.png
("logo.png", "_logo.png"), # square -> <appid>_logo.png
]
# --------------------------------------------------------------------------
# Binary VDF (shortcuts.vdf) round-trip parser/serializer.
# Field types: 0x00 nested map, 0x01 string, 0x02 little-endian u32, 0x08 end.
# Keys and values are kept as raw bytes so unknown fields round-trip
# byte-identically.
# --------------------------------------------------------------------------
T_MAP, T_STR, T_U32, T_END = 0x00, 0x01, 0x02, 0x08
def _read_cstr(data, pos):
end = data.index(b"\x00", pos)
return data[pos:end], end + 1
def _parse_fields(data, pos, is_root=False):
fields = []
while pos < len(data):
ftype = data[pos]
if ftype == T_END:
return fields, pos + 1, True
key, pos = _read_cstr(data, pos + 1)
if ftype == T_MAP:
value, pos, _ = _parse_fields(data, pos)
elif ftype == T_STR:
value, pos = _read_cstr(data, pos)
elif ftype == T_U32:
if pos + 4 > len(data):
raise ValueError("truncated u32 field %r" % key)
value = data[pos:pos + 4]
pos += 4
else:
raise ValueError("unknown VDF field type 0x%02x at %d" % (ftype, pos))
fields.append((ftype, key, value))
if not is_root:
raise ValueError("unterminated map in VDF")
return fields, pos, False
def parse_vdf(data):
"""Parse a binary VDF document -> (fields, root_end_marker)."""
fields, _, ended = _parse_fields(data, 0, is_root=True)
return fields, ended
def _serialize_fields(fields, out):
for ftype, key, value in fields:
out.append(bytes([ftype]) + key + b"\x00")
if ftype == T_MAP:
_serialize_fields(value, out)
out.append(b"\x08")
elif ftype == T_STR:
out.append(value + b"\x00")
else:
out.append(value)
def serialize_vdf(fields, root_end_marker=True):
out = []
_serialize_fields(fields, out)
if root_end_marker:
out.append(b"\x08")
return b"".join(out)
def fld_str(key, value):
return (T_STR, key.encode("utf-8"), value.encode("utf-8"))
def fld_u32(key, value):
return (T_U32, key.encode("utf-8"), struct.pack("<I", value & 0xFFFFFFFF))
def fld_map(key, children):
return (T_MAP, key.encode("utf-8"), children)
def field_get(fields, name):
"""First field whose key matches `name` (case-insensitive), or None."""
want = name.lower().encode("utf-8")
for f in fields:
if f[1].lower() == want:
return f
return None
def shortcut_appid(exe, name=APP_NAME):
"""Classic non-Steam shortcut appid: crc32('"'+exe+'"'+name) | 0x80000000."""
key = ('"' + exe + '"' + name).encode("utf-8")
return (zlib.crc32(key) | 0x80000000) & 0xFFFFFFFF
def launch_url(appid):
return "steam://rungameid/%d" % ((appid << 32) | 0x02000000)
def build_entry(exe, launch_options, appid):
"""Shortcut fields in the order a current Steam client writes them."""
return [
fld_u32("appid", appid),
fld_str("appname", APP_NAME),
fld_str("Exe", '"%s"' % exe),
fld_str("StartDir", '"%s/"' % os.path.dirname(exe)),
fld_str("icon", ICON_PATH),
fld_str("ShortcutPath", ""),
fld_str("LaunchOptions", launch_options),
fld_u32("IsHidden", 0),
fld_u32("AllowDesktopConfig", 1),
fld_u32("AllowOverlay", 1),
fld_u32("OpenVR", 0),
fld_u32("Devkit", 0),
fld_str("DevkitGameID", ""),
fld_u32("DevkitOverrideAppID", 0),
fld_u32("LastPlayTime", 0),
fld_str("FlatpakAppID", ""),
fld_map("tags", []),
]
def _entry_matches(entry_fields, exe, name=APP_NAME):
exe_f = field_get(entry_fields, "Exe")
name_f = field_get(entry_fields, "appname")
return (exe_f and name_f and exe_f[2] == ('"%s"' % exe).encode("utf-8")
and name_f[2] == name.encode("utf-8"))
def upsert_entry(shortcut_fields, exe, launch_options, appid):
"""Insert or replace the QBZ entry (dedupe by Exe+appname). Returns action."""
entry = build_entry(exe, launch_options, appid)
top = field_get(shortcut_fields, "shortcuts")
if top is None or top[0] != T_MAP:
shortcut_fields = [fld_map("shortcuts", [])]
top = shortcut_fields[0]
entries = top[2]
for i, (ftype, key, value) in enumerate(entries):
if ftype == T_MAP and _entry_matches(value, exe):
entries[i] = (ftype, key, entry)
return shortcut_fields, "updated"
indices = [int(k) for t, k, v in entries
if t == T_MAP and k.decode("utf-8", "replace").isdigit()]
entries.append(fld_map(str(max(indices) + 1 if indices else 0), entry))
return shortcut_fields, "added"
def remove_entry(shortcut_fields, exe):
"""Remove the QBZ entry. Returns True if an entry was removed."""
top = field_get(shortcut_fields, "shortcuts")
if top is None or top[0] != T_MAP:
return False
before = len(top[2])
top[2][:] = [e for e in top[2]
if not (e[0] == T_MAP and _entry_matches(e[2], exe))]
return len(top[2]) != before
# --------------------------------------------------------------------------
# Steam environment
# --------------------------------------------------------------------------
def find_steam_root(home):
for cand in (home / ".steam/steam", home / ".local/share/Steam"):
if cand.is_dir():
return cand
return None
def is_flatpak_steam(root, home):
fp = home / ".var/app/com.valvesoftware.Steam"
try:
return str(root.resolve()).startswith(str(fp))
except OSError:
return False
def steam_pid(home, root):
for pidfile in (home / ".steam/steam.pid", root / "steam.pid"):
try:
pid = int(pidfile.read_text().strip())
os.kill(pid, 0)
return pid
except (ValueError, OSError):
continue
return None
def shutdown_steam(pid):
print("Steam is running (pid %d); asking it to shut down..." % pid)
if os.system("steam -shutdown >/dev/null 2>&1") != 0:
print("warning: 'steam -shutdown' failed; waiting for exit anyway")
for _ in range(60):
try:
os.kill(pid, 0)
except OSError:
print("Steam exited.")
return True
time.sleep(1)
return False
def pick_account(root, user_override):
loginusers = root / "userdata/config/loginusers.vdf"
if not loginusers.is_file():
raise RuntimeError("not found: %s" % loginusers)
text = loginusers.read_text(encoding="utf-8", errors="replace")
accounts = []
for m in re.finditer(r'"(\d{15,20})"\s*\{([^{}]*)\}', text, re.S):
sid, body = m.group(1), m.group(2)
def val(key):
fm = re.search(r'"%s"\s+"([^"]*)"' % key, body)
return fm.group(1) if fm else ""
accounts.append({
"steamid64": sid,
"accountid": str(int(sid) & 0xFFFFFFFF),
"name": val("AccountName"),
"recent": val("MostRecent") == "1",
})
if not accounts:
raise RuntimeError("no accounts in %s" % loginusers)
if user_override:
for a in accounts:
if user_override.lower() in (a["accountid"].lower(),
a["steamid64"].lower(),
a["name"].lower()):
return a
raise RuntimeError("no account matching --user %r (have: %s)"
% (user_override,
", ".join(a["name"] or a["accountid"]
for a in accounts)))
for a in accounts:
if a["recent"]:
return a
return accounts[0]
def deck_serial(root):
cfg = root / "config/config.vdf"
if cfg.is_file():
m = re.search(r'"SteamDeckRegisteredSerialNumber"\s+"([^"]+)"',
cfg.read_text(encoding="utf-8", errors="replace"))
if m:
return m.group(1)
return None
def find_assets_dir():
here = Path(__file__).resolve().parent
for cand in (here.parent / "assets/steam",
Path("/usr/share/qbz/steam"),
Path("/usr/local/share/qbz/steam")):
if cand.is_dir():
return cand
return here.parent / "assets/steam" # default for error messages
# --------------------------------------------------------------------------
# File operations (all honour dry-run; all mutations make timestamped backups)
# --------------------------------------------------------------------------
def _backup(path):
if path.exists():
bak = path.with_name(path.name + time.strftime(".bak-%Y%m%d-%H%M%S"))
print(" backup %s -> %s" % (path, bak))
shutil.copy2(path, bak)
def write_atomic(path, data):
"""Write bytes to `path` atomically, keeping a timestamped .bak."""
_backup(path)
tmp = path.with_name("%s.tmp-%d" % (path.name, os.getpid()))
with open(tmp, "wb") as f:
f.write(data)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
print(" wrote %s (%d bytes)" % (path, len(data)))
def copy_file(src, dst, dry_run):
print(" %scopy %s -> %s" % ("[dry-run] would " if dry_run else "", src, dst))
if not dry_run:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def update_shortcuts(path, exe, launch_options, appid, remove, dry_run):
if path.is_file():
fields, ended = parse_vdf(path.read_bytes())
else:
fields, ended = [], True
if remove:
changed = remove_entry(fields, exe)
action = "removed" if changed else "not present"
else:
fields, action = upsert_entry(fields, exe, launch_options, appid)
print("shortcut entry: %s in %s" % (action, path))
if action == "not present":
return
if dry_run:
print(" [dry-run] would write %s (backup first)" % path)
return
path.parent.mkdir(parents=True, exist_ok=True)
write_atomic(path, serialize_vdf(fields, ended))
def install_artwork(grid_dir, assets, appid, remove, dry_run):
for src_name, suffix in ARTWORK:
dst = grid_dir / ("%d%s" % (appid, suffix))
if remove:
if dst.exists():
print(" %sremove %s" % ("[dry-run] would " if dry_run else "", dst))
if not dry_run:
_backup(dst)
dst.unlink()
continue
src = assets / src_name
if not src.is_file():
print(" warning: artwork source missing, skipping: %s" % src)
continue
copy_file(src, dst, dry_run)
CONFIGSET_HEADER = '"controller_config"\n{\n'
def _configset_entry(appid):
return ('\t"%d"\n\t{\n\t\t"template"\t\t"%s"\n\t}\n'
% (appid, LAYOUT_NAME))
def _patch_configset_text(content, appid, remove):
"""Return patched configset text, or None if the format is unrecognized."""
text = content.lstrip("\ufeff")
if not text.lstrip().startswith('"controller_config"'):
return None
if text.count("{") != text.count("}"):
return None
pattern = re.compile(r'"%d"\s*\{[^{}]*\}\n?' % appid)
if remove:
return pattern.sub("", text)
entry = _configset_entry(appid)
if pattern.search(text):
return pattern.sub(entry, text)
stripped = text.rstrip()
if not stripped.endswith("}"):
return None
return stripped[:-1].rstrip() + "\n" + entry + "}\n"
def patch_configset(path, appid, remove, dry_run):
if path.exists():
new_text = _patch_configset_text(
path.read_text(encoding="utf-8", errors="replace"), appid, remove)
if new_text is None:
print(" warning: unrecognized format, leaving untouched: %s" % path)
return
print(" %spatch %s (%s)" % ("[dry-run] would " if dry_run else "",
path, "remove entry" if remove else
"set template " + LAYOUT_NAME))
if not dry_run:
_backup(path)
path.write_text(new_text, encoding="utf-8")
elif not remove:
print(" %screate %s" % ("[dry-run] would " if dry_run else "", path))
if not dry_run:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(CONFIGSET_HEADER + _configset_entry(appid) + "}\n",
encoding="utf-8")
def controller_config_roots(root, accountid):
"""Directories that hold per-controller configsets + <appid>/ layout dirs.
Canonical location (DeckOps prior art, verified on a live install):
steamapps/common/Steam Controller Configs/<accountid>/config.
Some clients keep a userdata mirror; patch it too if it already exists.
"""
roots = [root / "steamapps/common/Steam Controller Configs"
/ accountid / "config"]
legacy = (root / "userdata" / accountid / "config"
/ "Steam Controller Configs" / accountid / "config")
if legacy.is_dir() and legacy not in roots:
roots.append(legacy)
return roots
def install_layout(root, assets, accountid, appid, remove, dry_run):
src = assets / LAYOUT_NAME
serial = deck_serial(root)
if not serial:
print(" note: no SteamDeckRegisteredSerialNumber in config.vdf; "
"only configset_controller_neptune.vdf will be patched")
for cfg_root in controller_config_roots(root, accountid):
app_dir = cfg_root / str(appid)
if remove:
if app_dir.is_dir():
print(" %sremove seeded layout %s"
% ("[dry-run] would " if dry_run else "", app_dir))
if not dry_run:
bak = app_dir.with_name(
app_dir.name + time.strftime(".bak-%Y%m%d-%H%M%S"))
shutil.copytree(app_dir, bak)
shutil.rmtree(app_dir)
else:
if not src.is_file():
print(" warning: layout source missing, skipping: %s" % src)
return
copy_file(src, app_dir / LAYOUT_NAME, dry_run)
patch_configset(cfg_root / "configset_controller_neptune.vdf",
appid, remove, dry_run)
if serial:
patch_configset(cfg_root / ("configset_%s.vdf" % serial),
appid, remove, dry_run)
# --------------------------------------------------------------------------
def main(argv=None):
ap = argparse.ArgumentParser(
description="Register QBZ as a non-Steam shortcut (artwork, launch "
"options, seeded controller layout). Steam must be closed.")
ap.add_argument("--dry-run", action="store_true",
help="print every action without writing anything")
ap.add_argument("--remove", action="store_true",
help="remove the QBZ shortcut, artwork and seeded layout")
ap.add_argument("--exe", default=DEFAULT_EXE, metavar="PATH",
help="path to the qbz binary (default %(default)s)")
ap.add_argument("--user", metavar="ACCOUNT",
help="Steam account: accountid, steamid64 or account name "
"(default: MostRecent in loginusers.vdf)")
ap.add_argument("--no-kiosk", action="store_true",
help="omit the kiosk-profile env vars from LaunchOptions")
ap.add_argument("--shutdown-steam", action="store_true",
help="run 'steam -shutdown' and wait instead of refusing "
"when Steam is running")
args = ap.parse_args(argv)
exe = os.path.abspath(args.exe)
appid = shortcut_appid(exe)
launch_options = GAMESCOPE_OPTS + " %command%"
if not args.no_kiosk:
launch_options = GAMESCOPE_OPTS + " " + KIOSK_OPTS + " %command%"
print("QBZ add-to-Steam%s" % (" (DRY RUN)" if args.dry_run else ""))
print(" exe=%s appid=%d" % (exe, appid))
home = Path.home()
root = find_steam_root(home)
if root is None:
print("error: no Steam installation found (~/.steam/steam or "
"~/.local/share/Steam)", file=sys.stderr)
return 1
print(" steam root: %s" % root)
if is_flatpak_steam(root, home):
print("warning: Flatpak Steam detected (%s). A host-path Exe is "
"broken inside the sandbox; not writing anything."
% root.resolve(), file=sys.stderr)
return 1
pid = steam_pid(home, root)
if pid is not None:
if not args.shutdown_steam:
print("error: Steam is running (pid %d). Close it first, or "
"re-run with --shutdown-steam." % pid, file=sys.stderr)
return 1
if args.dry_run:
print(" [dry-run] would run 'steam -shutdown' and wait")
elif not shutdown_steam(pid):
print("error: Steam did not exit within 60s", file=sys.stderr)
return 1
try:
account = pick_account(root, args.user)
except RuntimeError as e:
print("error: %s" % e, file=sys.stderr)
return 1
accountid = account["accountid"]
print(" account: %s (accountid %s)" % (account["name"] or "?", accountid))
user_cfg = root / "userdata" / accountid / "config"
assets = find_assets_dir()
if not assets.is_dir():
print(" warning: assets dir not found: %s "
"(artwork/layout will be skipped)" % assets)
try:
update_shortcuts(user_cfg / "shortcuts.vdf", exe, launch_options,
appid, args.remove, args.dry_run)
except ValueError as e:
print("error: cannot parse %s: %s — leaving it untouched"
% (user_cfg / "shortcuts.vdf", e), file=sys.stderr)
return 1
install_artwork(user_cfg / "grid", assets, appid, args.remove, args.dry_run)
install_layout(root, assets, accountid, appid, args.remove, args.dry_run)
if args.remove:
print("Done. QBZ was removed from Steam.")
else:
print("Done. Start Steam, then launch QBZ from the library or with:")
print(" %s" % launch_url(appid))
print("Tip: log in to QBZ once in Desktop Mode first (login needs a "
"browser); Game Mode will then open the kiosk directly.")
return 0
if __name__ == "__main__":
sys.exit(main())
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Unit tests for scripts/qbz-add-to-steam (binary VDF parser/serializer,
shortcut dedupe, appid formula, configset patching). Stdlib unittest only."""
import importlib.util
import struct
import unittest
import zlib
from importlib.machinery import SourceFileLoader
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent / "qbz-add-to-steam"
_loader = SourceFileLoader("qbz_add_to_steam", str(SCRIPT))
spec = importlib.util.spec_from_loader("qbz_add_to_steam", _loader)
qats = importlib.util.module_from_spec(spec)
spec.loader.exec_module(qats)
def make_fixture():
"""A real-world-shaped shortcuts.vdf: two entries (one non-QBZ with
quirky/unknown fields), nested tags map, non-ASCII strings."""
other_entry = [
qats.fld_u32("appid", 0x81234567),
qats.fld_str("appname", "Cool Game — déjà vu"),
qats.fld_str("Exe", '"/opt/cool game/run.sh"'),
qats.fld_str("StartDir", '"/opt/cool game/"'),
qats.fld_str("icon", ""),
qats.fld_str("ShortcutPath", ""),
qats.fld_str("LaunchOptions", "gamemoderun %command%"),
qats.fld_u32("IsHidden", 0),
qats.fld_u32("AllowDesktopConfig", 1),
qats.fld_u32("AllowOverlay", 1),
qats.fld_u32("OpenVR", 0),
qats.fld_u32("Devkit", 0),
qats.fld_str("DevkitGameID", ""),
qats.fld_u32("DevkitOverrideAppID", 0),
qats.fld_u32("LastPlayTime", 1753000000),
qats.fld_str("FlatpakAppID", ""),
qats.fld_str("sortas", "cool game"), # newer Steam field
qats.fld_map("tags", [qats.fld_str("0", "favorite"),
qats.fld_str("1", "deck")]),
qats.fld_str("SomeFutureField", "w\xe9ird\xff"), # unknown field
]
qbz_entry = qats.build_entry("/usr/bin/qbz", "env X=1 %command%",
qats.shortcut_appid("/usr/bin/qbz"))
fields = [qats.fld_map("shortcuts", [
qats.fld_map("0", other_entry),
qats.fld_map("1", qbz_entry),
])]
return fields
class TestRoundTrip(unittest.TestCase):
def test_roundtrip_byte_identical(self):
fields = make_fixture()
blob = qats.serialize_vdf(fields, True)
parsed, ended = qats.parse_vdf(blob)
self.assertTrue(ended)
self.assertEqual(qats.serialize_vdf(parsed, ended), blob)
def test_roundtrip_preserves_trailing_end_marker(self):
fields = make_fixture()
with_root_end = qats.serialize_vdf(fields, True)
without_root_end = qats.serialize_vdf(fields, False)
self.assertEqual(with_root_end, without_root_end + b"\x08")
p1, e1 = qats.parse_vdf(with_root_end)
p2, e2 = qats.parse_vdf(without_root_end)
self.assertTrue(e1)
self.assertFalse(e2)
self.assertEqual(p1, p2)
def test_real_file_shape(self):
# header is \x00shortcuts\x00, file ends with \x08\x08
blob = qats.serialize_vdf(make_fixture(), True)
self.assertTrue(blob.startswith(b"\x00shortcuts\x00"))
self.assertTrue(blob.endswith(b"\x08\x08"))
def test_parse_types(self):
fields, _ = qats.parse_vdf(qats.serialize_vdf(make_fixture(), True))
top = qats.field_get(fields, "shortcuts")
self.assertIsNotNone(top)
self.assertEqual(top[0], qats.T_MAP)
e0 = top[2][0][2]
appid = qats.field_get(e0, "appid")
self.assertEqual(appid[0], qats.T_U32)
self.assertEqual(struct.unpack("<I", appid[2])[0], 0x81234567)
tags = qats.field_get(e0, "tags")
self.assertEqual(tags[0], qats.T_MAP)
self.assertEqual(qats.field_get(tags[2], "0")[2], b"favorite")
def test_garbage_rejected(self):
with self.assertRaises(ValueError):
qats.parse_vdf(b"\x00shortcuts\x00\x07bogus\x00")
class TestDedupe(unittest.TestCase):
def test_update_in_place_never_duplicate(self):
fields = make_fixture()
blob0 = qats.serialize_vdf(fields, True)
parsed, ended = qats.parse_vdf(blob0)
new_fields, action = qats.upsert_entry(
parsed, "/usr/bin/qbz", "env NEW=1 %command%",
qats.shortcut_appid("/usr/bin/qbz"))
self.assertEqual(action, "updated")
top = qats.field_get(new_fields, "shortcuts")
# still exactly two entries, QBZ still at key "1"
self.assertEqual(len(top[2]), 2)
self.assertEqual(top[2][1][1], b"1")
lo = qats.field_get(top[2][1][2], "LaunchOptions")
self.assertEqual(lo[2], b"env NEW=1 %command%")
# the non-QBZ entry survives byte-identically: re-serialize and diff
blob1 = qats.serialize_vdf(new_fields, ended)
entry0_start = blob0.index(b"\x000\x00")
entry0_end = blob0.index(b"\x001\x00")
self.assertEqual(blob0[entry0_start:entry0_end],
blob1[entry0_start:entry0_end])
def test_add_when_missing(self):
fields = make_fixture()
parsed, ended = qats.parse_vdf(qats.serialize_vdf(fields, True))
removed = qats.remove_entry(parsed, "/usr/bin/qbz")
self.assertTrue(removed)
new_fields, action = qats.upsert_entry(
parsed, "/usr/bin/qbz", "env X=1 %command%",
qats.shortcut_appid("/usr/bin/qbz"))
self.assertEqual(action, "added")
top = qats.field_get(new_fields, "shortcuts")
self.assertEqual(len(top[2]), 2)
# appended reusing the freed index (indices stay contiguous)
self.assertEqual(top[2][-1][1], b"1")
def test_dedupe_on_exe_and_name(self):
# an entry with the same Exe but a different appname must NOT be
# replaced — a new entry is appended instead
fields = make_fixture()
top = qats.field_get(fields, "shortcuts")
# drop the real QBZ entry, keep only a "twin": same Exe, other name
top[2][:] = [e for e in top[2] if e[1] != b"1"]
twin = qats.build_entry("/usr/bin/qbz", "%command%", 0x81234568)
for i, f in enumerate(twin):
if f[1] == b"appname":
twin[i] = qats.fld_str("appname", "QBZ (alt)")
top[2].append(qats.fld_map("1", twin))
parsed, _ = qats.parse_vdf(qats.serialize_vdf(fields, True))
new_fields, action = qats.upsert_entry(
parsed, "/usr/bin/qbz", "env X=1 %command%",
qats.shortcut_appid("/usr/bin/qbz"))
self.assertEqual(action, "added")
top = qats.field_get(new_fields, "shortcuts")
self.assertEqual(len(top[2]), 3)
# the twin entry is untouched
self.assertEqual(qats.field_get(top[2][1][2], "appname")[2],
"QBZ (alt)".encode("utf-8"))
def test_remove_missing_is_noop(self):
fields = make_fixture()
blob0 = qats.serialize_vdf(fields, True)
parsed, _ = qats.parse_vdf(blob0)
self.assertFalse(qats.remove_entry(parsed, "/opt/other"))
self.assertEqual(qats.serialize_vdf(parsed, True), blob0)
class TestAppId(unittest.TestCase):
def test_formula(self):
exe = "/usr/bin/qbz"
expected = (zlib.crc32(('"' + exe + '"QBZ').encode("utf-8"))
| 0x80000000) & 0xFFFFFFFF
self.assertEqual(qats.shortcut_appid(exe), expected)
def test_known_vector(self):
# pinned so a regression in quoting/encoding is caught
self.assertEqual(qats.shortcut_appid("/usr/bin/qbz"), 3124044104)
def test_fits_u32_and_top_bit(self):
appid = qats.shortcut_appid("/usr/bin/qbz")
self.assertTrue(0 <= appid <= 0xFFFFFFFF)
self.assertTrue(appid & 0x80000000)
struct.pack("<I", appid) # must not raise
def test_launch_url(self):
appid = 3124044104
url = qats.launch_url(appid)
self.assertEqual(url, "steam://rungameid/%d"
% ((appid << 32) | 0x02000000))
class TestEntryFields(unittest.TestCase):
def test_standard_field_order(self):
entry = qats.build_entry("/usr/bin/qbz", "env X=1 %command%", 123)
keys = [k.decode() for _, k, _ in entry]
self.assertEqual(keys, [
"appid", "appname", "Exe", "StartDir", "icon", "ShortcutPath",
"LaunchOptions", "IsHidden", "AllowDesktopConfig", "AllowOverlay",
"OpenVR", "Devkit", "DevkitGameID", "DevkitOverrideAppID",
"LastPlayTime", "FlatpakAppID", "tags",
])
def test_entry_values(self):
appid = qats.shortcut_appid("/usr/bin/qbz")
entry = qats.build_entry("/usr/bin/qbz", "env X=1 %command%", appid)
self.assertEqual(qats.field_get(entry, "appname")[2], b"QBZ")
self.assertEqual(qats.field_get(entry, "Exe")[2], b'"/usr/bin/qbz"')
self.assertEqual(qats.field_get(entry, "StartDir")[2], b'"/usr/bin/"')
self.assertEqual(qats.field_get(entry, "icon")[2],
b"/usr/share/icons/hicolor/512x512/apps/"
b"com.blitzfc.qbz.png")
self.assertEqual(struct.unpack(
"<I", qats.field_get(entry, "appid")[2])[0], appid)
self.assertEqual(struct.unpack(
"<I", qats.field_get(entry, "AllowDesktopConfig")[2])[0], 1)
self.assertEqual(struct.unpack(
"<I", qats.field_get(entry, "AllowOverlay")[2])[0], 1)
self.assertEqual(struct.unpack(
"<I", qats.field_get(entry, "IsHidden")[2])[0], 0)
self.assertEqual(struct.unpack(
"<I", qats.field_get(entry, "OpenVR")[2])[0], 0)
class TestConfigsetPatch(unittest.TestCase):
EXISTING = ('"controller_config"\n{\n\t"12345"\n\t{\n'
'\t\t"template"\t\t"other.vdf"\n\t}\n}\n')
def test_insert_into_existing(self):
out = qats._patch_configset_text(self.EXISTING, 3124044104, False)
self.assertIn('"3124044104"', out)
self.assertIn(qats.LAYOUT_NAME, out)
self.assertIn('"12345"', out) # pre-existing entry preserved
self.assertEqual(out.count("{"), out.count("}"))
def test_replace_existing_entry(self):
first = qats._patch_configset_text(self.EXISTING, 3124044104, False)
second = qats._patch_configset_text(first, 3124044104, False)
self.assertEqual(second.count('"3124044104"'), 1) # idempotent
self.assertIn('"12345"', second)
def test_remove_entry(self):
first = qats._patch_configset_text(self.EXISTING, 3124044104, False)
out = qats._patch_configset_text(first, 3124044104, True)
self.assertNotIn('"3124044104"', out)
self.assertIn('"12345"', out)
def test_bad_format_returns_none(self):
self.assertIsNone(qats._patch_configset_text(
'"something_else"\n{\n}\n', 1, False))
self.assertIsNone(qats._patch_configset_text(
'"controller_config"\n{\n', 1, False)) # unbalanced braces
if __name__ == "__main__":
unittest.main()