aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md2
-rw-r--r--README.md1
-rw-r--r--games/Games.qml276
-rw-r--r--games/README.md39
l---------games/Theme.qml1
-rwxr-xr-xgames/games.py190
-rw-r--r--games/shell.qml23
7 files changed, 532 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
index a50a617..e7fa924 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,6 +28,8 @@ modules.
bookmarks/ Firefox's bookmarks by folder, with favicons and
dead-link badges, on SUPER+b
notifications/ balloons for the notification daemon's published files
+ games/ the Steam library as an endless strip with a large
+ preview, on the Search key (XF86Search)
They are started from `~/.config/hypr/sections/autostart.lua` and keep running
for the whole session.
diff --git a/README.md b/README.md
index 71f3387..a915d94 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ repos stay independent, this one has no build-time dependency on that one.
window-switcher/ open windows as live previews in a grid, on ALT+TAB
keybinds/ live keybind reminder, on SUPER+k
bookmarks/ Firefox bookmarks with favicons and dead links, on SUPER+b
+ games/ Steam library as an endless strip, on the Search key
Each directory has its own README covering what it does and how to run it.
diff --git a/games/Games.qml b/games/Games.qml
new file mode 100644
index 0000000..2d22460
--- /dev/null
+++ b/games/Games.qml
@@ -0,0 +1,276 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import QtQuick
+
+Scope {
+ id: root
+
+ property bool open: false
+ property string monitor: "DP-1"
+ property var items: []
+ property string query: ""
+
+ // Horizontal shear of the strip's tiles, as a fraction of their height.
+ readonly property real slant: -0.25
+ readonly property int step: 210
+
+ readonly property var screenObj:
+ Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+
+ readonly property var shown: {
+ const q = root.query.toLowerCase();
+ return root.items.filter(g => g.name.toLowerCase().includes(q));
+ }
+
+ // The strip is a loop, so the list repeats until it fills the screen
+ // width: a short library would otherwise leave gaps in the circle.
+ readonly property var reel: {
+ if (!root.shown.length) return [];
+ const slots = Math.ceil(root.screenObj.width / root.step) + 2;
+ let out = [];
+ while (out.length < slots) out = out.concat(root.shown);
+ return out;
+ }
+
+ function launch(g) {
+ if (!g) return;
+ // -silent keeps the Steam window down whether or not Steam is running.
+ // Ids are strings: a shortcut's game id does not fit a JS number.
+ Quickshell.execDetached(["steam", "-silent",
+ g.installed ? `steam://rungameid/${g.id}` : `steam://install/${g.appid}`]);
+ root.open = false;
+ }
+
+ // The list is read on every opening; the owned games come from a cache
+ // that a Web API call refreshes in the background, then the list is
+ // read again.
+ onOpenChanged: if (open) {
+ root.query = "";
+ list.running = true;
+ refresh.running = true;
+ }
+
+ Process {
+ id: list
+ command: ["/usr/bin/python3", `${Quickshell.shellDir}/games.py`]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try { root.items = JSON.parse(text); } catch (e) { root.items = []; }
+ }
+ }
+ }
+
+ Process {
+ id: refresh
+ command: ["/usr/bin/python3", `${Quickshell.shellDir}/games.py`, "--refresh"]
+ onExited: code => { if (code === 0 && root.open) list.running = true; }
+ }
+
+ // A config with no visible window exits, reporting nothing. This 1x1
+ // click-through window holds the shell open while the overlay is hidden.
+ PanelWindow {
+ anchors { top: true; left: true }
+ implicitWidth: 1
+ implicitHeight: 1
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ mask: Region {}
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ }
+
+ LazyLoader {
+ active: root.open
+
+ PanelWindow {
+ id: win
+ readonly property var game: root.reel[strip.currentIndex]
+ readonly property color bg: Qt.rgba(Theme.crust.r, Theme.crust.g, Theme.crust.b, 0.94)
+
+ screen: root.screenObj
+ anchors { top: true; left: true; right: true; bottom: true }
+ color: win.bg
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "keystone-games"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root.open = false
+ }
+
+ // The selected game, large: Steam's wide hero art, or the cover
+ // when there is none, fading into the background at the bottom.
+ Item {
+ anchors { top: parent.top; left: parent.left; right: parent.right; bottom: strip.top }
+
+ Image {
+ anchors.fill: parent
+ source: win.game ? (win.game.hero || win.game.cover ? `file://${win.game.hero || win.game.cover}` : "") : ""
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ }
+
+ Rectangle {
+ anchors.fill: parent
+ gradient: Gradient {
+ GradientStop { position: 0.45; color: "transparent" }
+ GradientStop { position: 1.0; color: win.bg }
+ }
+ }
+
+ Column {
+ anchors { left: parent.left; bottom: parent.bottom; margins: 80 }
+ spacing: 8
+
+ Text {
+ text: win.game?.name ?? (root.items.length ? "No match" : "")
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 56
+ font.bold: true
+ }
+ Text {
+ visible: !!win.game
+ text: !win.game ? ""
+ : !win.game.installed ? "Not installed · Enter installs"
+ : win.game.last ? `Last played ${new Date(win.game.last * 1000).toLocaleDateString(Qt.locale(), "d MMMM yyyy")}`
+ : "Never played"
+ color: win.game?.installed ? Theme.subtext : Theme.peach
+ font.family: Theme.fontFamily
+ font.pixelSize: 20
+ }
+ }
+ }
+
+ // Keys reach a focused item, not a window: the search field
+ // takes them all and steers the strip. It shows only what is typed.
+ TextInput {
+ id: field
+ anchors { right: parent.right; top: parent.top; margins: 60 }
+ focus: true
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 28
+ onTextEdited: root.query = text
+
+ Keys.onEscapePressed: root.open = false
+ Keys.onLeftPressed: strip.decrementCurrentIndex()
+ Keys.onRightPressed: strip.incrementCurrentIndex()
+ Keys.onReturnPressed: root.launch(win.game)
+ Keys.onEnterPressed: root.launch(win.game)
+ }
+
+ // An endless strip: PathView wraps, and the tile at the centre
+ // is the selection.
+ PathView {
+ id: strip
+ anchors { left: parent.left; right: parent.right; bottom: parent.bottom; bottomMargin: 120 }
+ height: 300
+ model: root.reel
+ pathItemCount: Math.min(count, Math.ceil((width + 2 * root.step) / root.step))
+ preferredHighlightBegin: 0.5
+ preferredHighlightEnd: 0.5
+ highlightRangeMode: PathView.StrictlyEnforceRange
+ highlightMoveDuration: 220
+ onModelChanged: currentIndex = 0
+
+ path: Path {
+ startX: -root.step
+ startY: strip.height / 2
+ PathLine { x: strip.width + root.step; y: strip.height / 2 }
+ }
+
+ delegate: Item {
+ id: tile
+ required property var modelData
+ required property int index
+ readonly property bool current: PathView.isCurrentItem
+ width: 160
+ height: 160
+ z: current ? 1 : 0
+ scale: current ? 1.5 : 1
+ Behavior on scale { NumberAnimation { duration: 160 } }
+
+ // A parallelogram: the tile is sheared about its middle
+ // and clips to that shape; the art inside is sheared back
+ // so it stands straight, and widened to reach both edges.
+ Item {
+ anchors.fill: parent
+ clip: true
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, root.slant, 0, -root.slant * tile.height / 2,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+
+ Rectangle { anchors.fill: parent; color: Theme.surface }
+
+ Image {
+ x: -tile.height * Math.abs(root.slant) / 2
+ width: parent.width + tile.height * Math.abs(root.slant)
+ height: parent.height
+ transform: Matrix4x4 {
+ matrix: Qt.matrix4x4(1, -root.slant, 0, root.slant * tile.height / 2,
+ 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)
+ }
+ source: tile.modelData.cover ? `file://${tile.modelData.cover}` : ""
+ sourceSize.width: 480
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ // Not installed: greyed until selected.
+ opacity: tile.modelData.installed || tile.current ? 1 : 0.35
+ }
+
+ // No cover: the initial stands in for it.
+ Text {
+ anchors.centerIn: parent
+ visible: !tile.modelData.cover
+ text: tile.modelData.name.charAt(0)
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 64
+ font.bold: true
+ }
+
+ // Not installed: a peach frame, the colour of the
+ // preview's "Not installed" line.
+ Rectangle {
+ anchors.fill: parent
+ color: "transparent"
+ border.width: tile.current ? 3 : tile.modelData.installed ? 0 : 2
+ border.color: tile.modelData.installed ? Theme.accent : Theme.peach
+ }
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: tile.current ? root.launch(tile.modelData) : strip.currentIndex = tile.index
+ }
+ }
+
+ // The wheel steps one tile a notch.
+ MouseArea {
+ anchors.fill: parent
+ acceptedButtons: Qt.NoButton
+ onWheel: wheel => {
+ const d = wheel.angleDelta.y || wheel.angleDelta.x;
+ if (d < 0) strip.incrementCurrentIndex();
+ else if (d > 0) strip.decrementCurrentIndex();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/games/README.md b/games/README.md
new file mode 100644
index 0000000..26e592e
--- /dev/null
+++ b/games/README.md
@@ -0,0 +1,39 @@
+# games
+
+A game launcher on DP-1: an endless horizontal strip of small slanted
+tiles along the bottom, and the selected game, the one at the centre,
+shown large above it in Steam's wide hero art with its title. Installed
+games come first, most recently played first; owned games that are not
+installed follow, greyed and framed in peach. Non-Steam shortcuts added to
+Steam count as installed. A short library repeats to fill the loop.
+
+Left and Right or the wheel move, Enter or a click on the centre tile
+launches, a click on another tile selects it. Typing filters by name.
+Escape or a click on the background closes. Everything goes through
+`steam -silent steam://...`, so the Steam window stays down: `rungameid`
+for an installed game or shortcut (keeping its Proton and launch options),
+`install` for one that is not.
+
+`games.py` reads, on every opening:
+
+- installed games from the `appmanifest_*.acf` files of every library in
+ `steamapps/libraryfolders.vdf`, leaving out Proton and the runtimes;
+- shortcuts from the binary `userdata/<account>/config/shortcuts.vdf`,
+ whose game id is `appid << 32 | 0x02000000`;
+- covers and hero art from Steam's own `appcache/librarycache`, so
+ nothing is fetched for them. A tile with no cover shows the initial.
+
+Owned games need the Steam Web API (`IPlayerService/GetOwnedGames`), so
+they come from `~/.cache/keystone/games/owned.json`, which
+`games.py --refresh` rewrites in the background on every opening. The API
+key is read with `pass show "$GAMES_PASS_ENTRY"`; the SteamID is derived
+from the userdata account directory.
+
+## Running
+
+ qs -p ./games
+ qs -p ./games ipc call games toggle # bound to XF86Search
+
+## Check
+
+ /usr/bin/python3 games/games.py --check
diff --git a/games/Theme.qml b/games/Theme.qml
new file mode 120000
index 0000000..3d2e40f
--- /dev/null
+++ b/games/Theme.qml
@@ -0,0 +1 @@
+../shared/Theme.qml \ No newline at end of file
diff --git a/games/games.py b/games/games.py
new file mode 100755
index 0000000..eaf7d33
--- /dev/null
+++ b/games/games.py
@@ -0,0 +1,190 @@
+#!/usr/bin/python3
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""The Steam library as JSON: installed games, non-Steam shortcuts, and the
+owned games that are not installed.
+
+ games.py print the list (owned games from the cache)
+ games.py --refresh fetch the owned games from the Steam Web API
+ games.py --check run the self-check
+
+Installed games and shortcuts are read from Steam's own files on every run.
+Owned games need the Web API, so they come from a cache that --refresh
+rewrites; the key is read from `pass` (GAMES_PASS_ENTRY).
+"""
+
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.request
+from pathlib import Path
+
+STEAM = Path(os.environ.get("GAMES_STEAM", "~/.local/share/Steam")).expanduser()
+CACHE = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "keystone/games"
+OWNED = CACHE / "owned.json"
+PASS_ENTRY = os.environ.get("GAMES_PASS_ENTRY", "store.steampowered.com/qs-launcher")
+API = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/"
+# Installed like games, but runtimes Steam pulls in on its own.
+TOOLS = re.compile(r"^(Proton\b|Steam Linux Runtime|Steamworks Common)")
+STEAMID_BASE = 76561197960265728
+
+
+def kv(text):
+ """The "key" "value" pairs of a text VDF, flattened. Enough for an
+ appmanifest, whose wanted keys are all unique."""
+ return dict(re.findall(r'"([^"]+)"\s+"([^"]*)"', text))
+
+
+def binvdf(data, i=0):
+ """Parse a binary VDF map starting at i; return (dict, next index).
+ Keys are lowercased: Steam has written both `appid` and `AppName`."""
+ out = {}
+ while True:
+ t = data[i]
+ i += 1
+ if t == 8:
+ return out, i
+ end = data.index(b"\0", i)
+ key = data[i:end].decode(errors="replace").lower()
+ i = end + 1
+ if t == 0:
+ out[key], i = binvdf(data, i)
+ elif t == 1:
+ end = data.index(b"\0", i)
+ out[key] = data[i:end].decode(errors="replace")
+ i = end + 1
+ elif t == 2:
+ out[key] = int.from_bytes(data[i:i + 4], "little")
+ i += 4
+ else:
+ raise ValueError(f"unknown binary VDF type {t}")
+
+
+def shortcut_gameid(appid):
+ """The id steam://rungameid/ takes for a non-Steam shortcut."""
+ return str((appid & 0xFFFFFFFF) << 32 | 0x02000000)
+
+
+def user():
+ """The Steam account's userdata dir: the most recently touched one."""
+ dirs = [d for d in (STEAM / "userdata").glob("*") if d.name.isdigit() and d.name != "0"]
+ return max(dirs, key=lambda d: d.stat().st_mtime) if dirs else None
+
+
+def art(appid):
+ """The portrait cover and the wide hero image from Steam's library
+ cache. Older entries keep them at the top of the app's directory, newer
+ ones in hashed subdirectories, and the cover there is library_capsule."""
+ d = STEAM / "appcache/librarycache" / str(appid)
+ found = {}
+ for key, names in (("cover", ("library_600x900*.jpg", "library_capsule*.jpg")),
+ ("hero", ("library_hero.jpg",))):
+ hits = [h for n in names for h in sorted(d.glob(f"**/{n}"))]
+ found[key] = str(hits[0]) if hits else ""
+ return found
+
+
+def installed():
+ libs = re.findall(r'"path"\s+"([^"]+)"', (STEAM / "steamapps/libraryfolders.vdf").read_text())
+ out = {}
+ for lib in libs:
+ for acf in Path(lib, "steamapps").glob("appmanifest_*.acf"):
+ m = kv(acf.read_text(errors="replace"))
+ name = m.get("name", "").strip()
+ if name and not TOOLS.match(name):
+ out[m["appid"]] = {"name": name, "last": int(m.get("LastPlayed", 0))}
+ return out
+
+
+def shortcuts(u):
+ path = u / "config/shortcuts.vdf" if u else None
+ if not path or not path.exists():
+ return []
+ try:
+ root, _ = binvdf(path.read_bytes())
+ except (ValueError, IndexError):
+ return []
+ out = []
+ for s in root.get("shortcuts", {}).values():
+ # Custom artwork set in Steam: <appid>p for the cover, <appid>_hero.
+ grid = lambda suffix: next(iter((u / "config/grid").glob(f"{s.get('appid', 0)}{suffix}.*")), "")
+ out.append({"name": s.get("appname", "?"), "id": shortcut_gameid(s.get("appid", 0)),
+ "appid": "", "installed": True, "cover": str(grid("p")),
+ "hero": str(grid("_hero")), "last": s.get("lastplaytime", 0)})
+ return out
+
+
+def games():
+ u = user()
+ lib = installed()
+ out = [{"name": g["name"], "id": a, "appid": a, "installed": True,
+ "last": g["last"], **art(a)} for a, g in lib.items()]
+ try:
+ owned = json.loads(OWNED.read_text())
+ except (OSError, ValueError):
+ owned = []
+ for g in owned:
+ a = str(g["appid"])
+ if a not in lib:
+ out.append({"name": g["name"], "id": a, "appid": a, "installed": False,
+ "last": g.get("rtime_last_played", 0), **art(a)})
+ out += shortcuts(u)
+ # Installed first, most recently played first, then by name.
+ out.sort(key=lambda g: (not g["installed"], -g["last"], g["name"].lower()))
+ return out
+
+
+def refresh():
+ u = user()
+ if not u:
+ sys.exit("no Steam account in userdata")
+ key = subprocess.run(["pass", "show", PASS_ENTRY], capture_output=True, text=True,
+ timeout=30, check=True).stdout.split("\n")[0]
+ url = (f"{API}?key={key}&steamid={STEAMID_BASE + int(u.name)}"
+ "&include_appinfo=1&include_played_free_games=1")
+ with urllib.request.urlopen(url, timeout=15) as r:
+ owned = json.load(r)["response"].get("games", [])
+ CACHE.mkdir(parents=True, exist_ok=True)
+ tmp = OWNED.with_suffix(".tmp")
+ tmp.write_text(json.dumps(owned))
+ tmp.replace(OWNED)
+ print(f"{len(owned)} owned")
+
+
+def check():
+ data = (b"\x00shortcuts\x00"
+ b"\x000\x00"
+ b"\x02appid\x00" + (0xE716847D).to_bytes(4, "little") +
+ b"\x01AppName\x00Example Game\x00"
+ b"\x00tags\x00\x08"
+ b"\x08\x08\x08")
+ root, end = binvdf(data)
+ assert end == len(data), end
+ s = root["shortcuts"]["0"]
+ assert s == {"appid": 0xE716847D, "appname": "Example Game", "tags": {}}, s
+ assert shortcut_gameid(0xE716847D) == str(0xE716847D << 32 | 0x02000000)
+ # Signed and unsigned readings of the same id give the same game id.
+ assert shortcut_gameid(0xE716847D - (1 << 32)) == shortcut_gameid(0xE716847D)
+ assert kv('"AppState"\n{\n\t"appid"\t\t"42"\n\t"name"\t\t"X "\n}') == {"appid": "42", "name": "X "}
+ assert TOOLS.match("Proton 9.0") and not TOOLS.match("Protonaut")
+ print("ok")
+
+
+if __name__ == "__main__":
+ if "--check" in sys.argv:
+ check()
+ elif "--refresh" in sys.argv:
+ refresh()
+ else:
+ print(json.dumps(games()))
diff --git a/games/shell.qml b/games/shell.qml
new file mode 100644
index 0000000..c667ec4
--- /dev/null
+++ b/games/shell.qml
@@ -0,0 +1,23 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+
+ShellRoot {
+ Games { id: games }
+
+ // `qs -p <this dir> ipc call games toggle`
+ IpcHandler {
+ target: "games"
+ function toggle() { games.open = !games.open; }
+ }
+}