aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-27 13:22:43 +0100
committerDanilo M. <danix@danix.xyz>2026-10-27 13:22:43 +0100
commit6b68cae8e0c4ec43c4a9a8a812a644b7bea746e7 (patch)
treea8fdf6542fc8b84adcaa90ed9b1f9990da924046
parent583ac2e98c3ff4fa379194b2eb4e7530c07b275d (diff)
downloadquickshell-6b68cae8e0c4ec43c4a9a8a812a644b7bea746e7.tar.gz
quickshell-6b68cae8e0c4ec43c4a9a8a812a644b7bea746e7.zip
games: search the Steam store from the launcher
Down switches the strip and preview from the library to store results, searched as you type with a 350ms debounce through the store's keyless api/storesearch endpoint, using QML's own XMLHttpRequest rather than a process. Results already in the library read Owned or Installed in green, the rest their price; Enter opens steam://store/<appid> in the client, Up or Escape returns to the library with the query kept, so a name that matches nothing locally is one keypress from the store. The endpoint answers {"total":0} for every term when no cc is given, despite looking like it geolocates, and the locale here is en_US, so the country is fixed to Italy. Store art comes from Steam's CDN; newer apps keep theirs under hashed paths that 404, so the preview layers the search's capsule under the hero and the tile shows the initial until its cover is actually ready. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--games/Games.qml89
-rw-r--r--games/README.md9
2 files changed, 92 insertions, 6 deletions
diff --git a/games/Games.qml b/games/Games.qml
index 2d22460..ef5576e 100644
--- a/games/Games.qml
+++ b/games/Games.qml
@@ -21,6 +21,10 @@ Scope {
property string monitor: "DP-1"
property var items: []
property string query: ""
+ // Down switches the strip to the Steam store, searched as you type.
+ property bool store: false
+ property var results: []
+ property bool searching: false
// Horizontal shear of the strip's tiles, as a fraction of their height.
readonly property real slant: -0.25
@@ -30,6 +34,7 @@ Scope {
Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
readonly property var shown: {
+ if (root.store) return root.results;
const q = root.query.toLowerCase();
return root.items.filter(g => g.name.toLowerCase().includes(q));
}
@@ -46,6 +51,11 @@ Scope {
function launch(g) {
if (!g) return;
+ if (root.store) {
+ Quickshell.execDetached(["steam", `steam://store/${g.appid}`]);
+ root.open = false;
+ 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",
@@ -58,10 +68,58 @@ Scope {
// read again.
onOpenChanged: if (open) {
root.query = "";
+ root.store = false;
+ root.results = [];
list.running = true;
refresh.running = true;
}
+ // Local art is a path, store art a URL.
+ function src(p) { return p ? (p.startsWith("/") ? `file://${p}` : p) : ""; }
+
+ onQueryChanged: if (store) search.restart()
+ onStoreChanged: if (store) search.restart()
+
+ Timer {
+ id: search
+ interval: 350
+ onTriggered: root.storeSearch(root.query.trim())
+ }
+
+ // The store's own search endpoint needs no key, but answers nothing
+ // without a country, which also sets the prices. The locale is en_US
+ // here, so it cannot be derived from that.
+ // ponytail: fixed to Italy; a setting when another country matters.
+ readonly property string country: "it"
+
+ function storeSearch(term) {
+ if (!term) { root.results = []; root.searching = false; return; }
+ root.searching = true;
+ const xhr = new XMLHttpRequest();
+ xhr.onreadystatechange = () => {
+ if (xhr.readyState !== XMLHttpRequest.DONE || term !== root.query.trim()) return;
+ root.searching = false;
+ let found = [];
+ try { found = JSON.parse(xhr.responseText).items ?? []; } catch (e) {}
+ const mine = {};
+ for (const g of root.items) if (g.appid) mine[g.appid] = g;
+ const cdn = "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/";
+ root.results = found.map(r => {
+ const a = String(r.id);
+ // Newer apps keep their art under hashed paths, so these
+ // can 404; the capsule search returns always exists.
+ // installed: drawn like a library game, not greyed.
+ return { name: r.name, id: a, appid: a, mine: mine[a],
+ installed: true, last: 0, price: r.price,
+ cover: `${cdn}${a}/library_600x900.jpg`,
+ hero: `${cdn}${a}/library_hero.jpg`, capsule: r.tiny_image };
+ });
+ };
+ xhr.open("GET", `https://store.steampowered.com/api/storesearch/?l=english&cc=${root.country}&term=`
+ + encodeURIComponent(term));
+ xhr.send();
+ }
+
Process {
id: list
command: ["/usr/bin/python3", `${Quickshell.shellDir}/games.py`]
@@ -116,9 +174,16 @@ Scope {
Item {
anchors { top: parent.top; left: parent.left; right: parent.right; bottom: strip.top }
+ // A store result's capsule, under its hero in case that 404s.
+ Image {
+ anchors.fill: parent
+ source: root.src(win.game?.capsule)
+ fillMode: Image.PreserveAspectCrop
+ asynchronous: true
+ }
Image {
anchors.fill: parent
- source: win.game ? (win.game.hero || win.game.cover ? `file://${win.game.hero || win.game.cover}` : "") : ""
+ source: win.game ? root.src(win.game.hero || win.game.cover) : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
}
@@ -136,7 +201,11 @@ Scope {
spacing: 8
Text {
- text: win.game?.name ?? (root.items.length ? "No match" : "")
+ text: win.game?.name
+ ?? (!root.store ? (root.items.length ? "No match · Down searches the store" : "")
+ : root.searching ? "Searching the store…"
+ : root.query.trim() ? "Nothing on the store"
+ : "Type to search the Steam store")
color: Theme.text
font.family: Theme.fontFamily
font.pixelSize: 56
@@ -145,10 +214,15 @@ Scope {
Text {
visible: !!win.game
text: !win.game ? ""
+ : root.store ? [win.game.mine ? (win.game.mine.installed ? "Installed" : "Owned")
+ : win.game.price ? `${(win.game.price.final / 100).toFixed(2)} ${win.game.price.currency}`
+ : "Free or unpriced",
+ "Enter opens the store page"].join(" · ")
: !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
+ color: root.store && win.game?.mine ? Theme.green
+ : win.game?.installed ? Theme.subtext : Theme.peach
font.family: Theme.fontFamily
font.pixelSize: 20
}
@@ -166,7 +240,9 @@ Scope {
font.pixelSize: 28
onTextEdited: root.query = text
- Keys.onEscapePressed: root.open = false
+ Keys.onEscapePressed: { if (root.store) root.store = false; else root.open = false; }
+ Keys.onDownPressed: root.store = true
+ Keys.onUpPressed: root.store = false
Keys.onLeftPressed: strip.decrementCurrentIndex()
Keys.onRightPressed: strip.incrementCurrentIndex()
Keys.onReturnPressed: root.launch(win.game)
@@ -218,6 +294,7 @@ Scope {
Rectangle { anchors.fill: parent; color: Theme.surface }
Image {
+ id: cover
x: -tile.height * Math.abs(root.slant) / 2
width: parent.width + tile.height * Math.abs(root.slant)
height: parent.height
@@ -225,7 +302,7 @@ Scope {
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}` : ""
+ source: root.src(tile.modelData.cover)
sourceSize.width: 480
fillMode: Image.PreserveAspectCrop
asynchronous: true
@@ -236,7 +313,7 @@ Scope {
// No cover: the initial stands in for it.
Text {
anchors.centerIn: parent
- visible: !tile.modelData.cover
+ visible: cover.status !== Image.Ready
text: tile.modelData.name.charAt(0)
color: Theme.text
font.family: Theme.fontFamily
diff --git a/games/README.md b/games/README.md
index b0b6f5b..46f0359 100644
--- a/games/README.md
+++ b/games/README.md
@@ -14,6 +14,15 @@ Escape or a click on the background closes. Everything goes through
for an installed game or shortcut (keeping its Proton and launch options),
`install` for one that is not.
+Down switches the strip to the Steam store: typing searches it through
+the store's keyless `api/storesearch` endpoint (country fixed to
+Italy, without one it answers nothing), and results show the
+price, or Owned / Installed in green for games already in the library.
+Enter opens the store page in the Steam client; Up or Escape goes back to
+the library, keeping what was typed. Art comes from Steam's CDN; newer
+apps keep theirs under hashed paths that 404, so the preview falls back
+to the search's capsule image and the tile to the initial.
+
`games.py` reads, on every opening:
- installed games from the `appmanifest_*.acf` files of every library in