058630f542
- The "Punktfunk" shortcut is no longer hidden: it now ships committed
artwork (grid/wide/hero/logo/icon, generated by scripts/gen-steam-art.py
— a pure-stdlib SDF renderer drawing the lens mark + a monoline
"punktfunk" wordmark) applied via SetCustomArtworkForApp /
SetShortcutIcon. Existing installs are unhidden and re-arted once per
ART_VERSION; relaunching the library entry streams to the last host.
- Updates cover the flatpak CLIENT too: check_update compares the
user-scope installed commit against its remote, applyUpdate runs
`flatpak update --user` first (awaited) and the plugin reinstall —
which reloads the panel — last; docs spell out the sudo-less --user
update ("sudo flatpak update" silently skips per-user installs).
- Fullscreen page: DialogButton stretches to 100% width in the gamepad
UI, so the Stream/Pair/Refresh/… actions filled whole rows — sized to
content + right-aligned now; the header drops its Update button (About
tab + QAM banner keep the flow) and the back button gets a real 40px
hit target.
- Settings: the disable-Steam-Input note also shows for Automatic — on a
Deck that now forwards the built-in controller as a Steam Deck pad
(paddles/trackpads/gyro), which needs Steam Input off for the shortcut.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
298 lines
12 KiB
Python
298 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the Steam-shortcut artwork for the Decky plugin (committed, like the tray icons).
|
|
|
|
The plugin registers a non-Steam shortcut ("Punktfunk") whose grid/hero/logo/icon Steam
|
|
would otherwise render as a gray placeholder tile. These assets brand it: the lens mark
|
|
(same geometry as scripts/gen-tray-icons.py / web's brand-mark.tsx) over the brand-navy
|
|
gradient, plus a monoline "punktfunk" wordmark built from stroke segments ("punktfunk"
|
|
needs only p·u·n·k·t·f). The frontend applies them via
|
|
SteamClient.Apps.SetCustomArtworkForApp / SetShortcutIcon (src/steam.ts).
|
|
|
|
Outputs (checked in; re-run only when the brand changes):
|
|
clients/decky/assets/grid.png 600 x 900 library capsule (portrait)
|
|
clients/decky/assets/gridwide.png 920 x 430 wide capsule (recent games / search)
|
|
clients/decky/assets/hero.png 1920 x 620 game-page banner
|
|
clients/decky/assets/logo.png transparent overlaid on the hero by Steam
|
|
clients/decky/assets/icon.png 256 x 256 list icon (SetShortcutIcon)
|
|
|
|
Pure stdlib. Unlike the tiny tray icons this rasterizes big surfaces, so edges are
|
|
antialiased analytically from signed distances (one sample per pixel) instead of 4x4
|
|
supersampling.
|
|
"""
|
|
|
|
import math
|
|
import struct
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent.parent # clients/decky
|
|
OUT = HERE / "assets"
|
|
|
|
# Brand-mark geometry in its 1000-unit viewbox (identical to gen-tray-icons.py).
|
|
R = 194.41
|
|
C1 = (403.037, 597.262) # light circle, behind
|
|
C2 = (597.8075, 402.8525) # deep circle, in front
|
|
BB_MIN = (C1[0] - R, C2[1] - R)
|
|
BB_MAX = (C2[0] + R, C1[1] + R)
|
|
MARK_CENTER = ((BB_MIN[0] + BB_MAX[0]) / 2, (BB_MIN[1] + BB_MAX[1]) / 2)
|
|
MARK_SPAN = BB_MAX[0] - BB_MIN[0]
|
|
|
|
COL_LIGHT = (0xA7, 0x9F, 0xF8)
|
|
COL_DEEP = (0x6C, 0x5B, 0xF3)
|
|
COL_HI = (0xD2, 0xC9, 0xFB)
|
|
WORD = (0xEF, 0xEC, 0xFD) # wordmark: near-white lavender
|
|
BG_TOP = (0x28, 0x1E, 0x46)
|
|
BG_BOT = (0x12, 0x0D, 0x22)
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# Wordmark: monoline glyphs as polylines in a unit box (y down; x-height top y=0, baseline
|
|
# y=1, ascender to -0.5, descender to +1.5). Arcs are sampled into the polylines, so the
|
|
# rasterizer only ever measures distance-to-segment; round caps/joins fall out of that.
|
|
# ------------------------------------------------------------------------------------------
|
|
def _arc(cx, cy, r, a0, a1, n=24):
|
|
"""Polyline along a circle arc; degrees, 0 = +x, angles grow clockwise on screen."""
|
|
pts = []
|
|
for i in range(n + 1):
|
|
a = math.radians(a0 + (a1 - a0) * i / n)
|
|
pts.append((cx + r * math.cos(a), cy + r * math.sin(a)))
|
|
return pts
|
|
|
|
|
|
GLYPHS = {
|
|
# letter: (advance, [polyline, ...])
|
|
"p": (1.05, [[(0, 0), (0, 1.5)], _arc(0.5, 0.5, 0.5, 0, 360)]),
|
|
"u": (1.05, [[(0, 0), (0, 0.5)], _arc(0.5, 0.5, 0.5, 0, 180), [(1, 0), (1, 0.5)]]),
|
|
"n": (1.05, [[(0, 0), (0, 1)], _arc(0.5, 0.5, 0.5, 180, 360), [(1, 0.5), (1, 1)]]),
|
|
"k": (1.0, [[(0, -0.5), (0, 1)], [(0, 0.62), (0.78, 0)], [(0.30, 0.38), (0.85, 1)]]),
|
|
"t": (0.85, [[(0.42, -0.42), (0.42, 1)], [(0, 0), (0.84, 0)]]),
|
|
"f": (
|
|
0.85,
|
|
[[(0.42, 1), (0.42, -0.15)] + _arc(0.75, -0.15, 0.33, 180, 270, 12), [(0, 0), (0.78, 0)]],
|
|
),
|
|
}
|
|
GAP = 0.34 # inter-letter gap, in glyph units
|
|
STROKE = 0.26 # stroke thickness, in glyph units
|
|
ASCENT, DESCENT = -0.5, 1.5 # glyph-space vertical extent
|
|
|
|
|
|
def word_segments(text):
|
|
"""The word's stroke segments [(x1,y1,x2,y2)] in glyph units, plus its unit width."""
|
|
segs = []
|
|
x = 0.0
|
|
for ch in text:
|
|
adv, lines = GLYPHS[ch]
|
|
for line in lines:
|
|
for (x1, y1), (x2, y2) in zip(line, line[1:]):
|
|
segs.append((x + x1, y1, x + x2, y2))
|
|
x += adv + GAP
|
|
return segs, x - GAP
|
|
|
|
|
|
def render_word_alpha(text, unit_px):
|
|
"""Coverage (0..255) buffer of the word at `unit_px` pixels per glyph unit."""
|
|
segs, width_u = word_segments(text)
|
|
half = STROKE / 2 * unit_px
|
|
pad = half + 1.5
|
|
w = math.ceil(width_u * unit_px + 2 * pad)
|
|
h = math.ceil((DESCENT - ASCENT) * unit_px + 2 * pad)
|
|
ox, oy = pad, pad - ASCENT * unit_px
|
|
px_segs = [(ox + a * unit_px, oy + b * unit_px, ox + c * unit_px, oy + d * unit_px) for a, b, c, d in segs]
|
|
# Bucket segments per pixel column range so each pixel tests only nearby strokes.
|
|
buf = bytearray(w * h)
|
|
for x1, y1, x2, y2 in px_segs:
|
|
lo_x = max(0, math.floor(min(x1, x2) - pad))
|
|
hi_x = min(w, math.ceil(max(x1, x2) + pad))
|
|
lo_y = max(0, math.floor(min(y1, y2) - pad))
|
|
hi_y = min(h, math.ceil(max(y1, y2) + pad))
|
|
dx, dy = x2 - x1, y2 - y1
|
|
len2 = dx * dx + dy * dy
|
|
for py in range(lo_y, hi_y):
|
|
row = py * w
|
|
fy = py + 0.5
|
|
for px in range(lo_x, hi_x):
|
|
fx = px + 0.5
|
|
if len2 > 0:
|
|
t = max(0.0, min(1.0, ((fx - x1) * dx + (fy - y1) * dy) / len2))
|
|
else:
|
|
t = 0.0
|
|
d = math.hypot(fx - (x1 + t * dx), fy - (y1 + t * dy))
|
|
cov = 0.5 + (half - d)
|
|
if cov > 0:
|
|
v = min(255, round(min(1.0, cov) * 255))
|
|
if v > buf[row + px]:
|
|
buf[row + px] = v
|
|
return buf, w, h
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------
|
|
# Canvas: RGBA bytearray, straight alpha, painted back to front.
|
|
# ------------------------------------------------------------------------------------------
|
|
class Canvas:
|
|
def __init__(self, w, h):
|
|
self.w, self.h = w, h
|
|
self.buf = bytearray(w * h * 4)
|
|
|
|
def fill_gradient(self, top, bottom):
|
|
for y in range(self.h):
|
|
t = y / max(1, self.h - 1)
|
|
c = bytes(
|
|
(
|
|
round(top[0] + (bottom[0] - top[0]) * t),
|
|
round(top[1] + (bottom[1] - top[1]) * t),
|
|
round(top[2] + (bottom[2] - top[2]) * t),
|
|
255,
|
|
)
|
|
)
|
|
self.buf[y * self.w * 4 : (y + 1) * self.w * 4] = c * self.w
|
|
|
|
def _blend(self, i, rgb, a):
|
|
"""`rgb` over the pixel at byte offset i with coverage a (0..1)."""
|
|
if a <= 0:
|
|
return
|
|
b = self.buf
|
|
ia = 1.0 - a
|
|
da = b[i + 3] / 255.0
|
|
oa = a + da * ia
|
|
if oa <= 0:
|
|
return
|
|
for k in range(3):
|
|
b[i + k] = round((rgb[k] * a + b[i + k] * da * ia) / oa)
|
|
b[i + 3] = round(oa * 255)
|
|
|
|
def glow(self, cx, cy, radius, rgb, strength):
|
|
"""Soft gaussian-ish radial glow (for the mark's halo on the big surfaces)."""
|
|
lo_x = max(0, math.floor(cx - 2.2 * radius))
|
|
hi_x = min(self.w, math.ceil(cx + 2.2 * radius))
|
|
lo_y = max(0, math.floor(cy - 2.2 * radius))
|
|
hi_y = min(self.h, math.ceil(cy + 2.2 * radius))
|
|
for y in range(lo_y, hi_y):
|
|
for x in range(lo_x, hi_x):
|
|
d2 = ((x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2) / (radius * radius)
|
|
a = strength * math.exp(-2.5 * d2)
|
|
if a > 1 / 255:
|
|
self._blend((y * self.w + x) * 4, rgb, a)
|
|
|
|
def mark(self, cx, cy, span):
|
|
"""The lens mark centered at (cx, cy) with the given pixel span."""
|
|
scale = span / MARK_SPAN
|
|
c1 = (cx + (C1[0] - MARK_CENTER[0]) * scale, cy + (C1[1] - MARK_CENTER[1]) * scale)
|
|
c2 = (cx + (C2[0] - MARK_CENTER[0]) * scale, cy + (C2[1] - MARK_CENTER[1]) * scale)
|
|
r = R * scale
|
|
lo_x = max(0, math.floor(min(c1[0], c2[0]) - r - 2))
|
|
hi_x = min(self.w, math.ceil(max(c1[0], c2[0]) + r + 2))
|
|
lo_y = max(0, math.floor(min(c1[1], c2[1]) - r - 2))
|
|
hi_y = min(self.h, math.ceil(max(c1[1], c2[1]) + r + 2))
|
|
for y in range(lo_y, hi_y):
|
|
for x in range(lo_x, hi_x):
|
|
fx, fy = x + 0.5, y + 0.5
|
|
cov1 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c1[0], fy - c1[1])))
|
|
cov2 = min(1.0, max(0.0, 0.5 + r - math.hypot(fx - c2[0], fy - c2[1])))
|
|
if cov1 <= 0 and cov2 <= 0:
|
|
continue
|
|
i = (y * self.w + x) * 4
|
|
self._blend(i, COL_LIGHT, cov1)
|
|
self._blend(i, COL_DEEP, cov2)
|
|
self._blend(i, COL_HI, min(cov1, cov2))
|
|
|
|
def word(self, text, unit_px, cx, cy):
|
|
"""The wordmark centered at (cx, cy); `unit_px` = pixels per glyph unit."""
|
|
alpha, w, h = render_word_alpha(text, unit_px)
|
|
ox = round(cx - w / 2)
|
|
# Optical vertical centering on the x-height band (0..1 in glyph units), not the
|
|
# ascender/descender box — the word reads centered that way.
|
|
pad = STROKE / 2 * unit_px + 1.5
|
|
band_mid = pad - ASCENT * unit_px + 0.5 * unit_px
|
|
oy = round(cy - band_mid)
|
|
for y in range(h):
|
|
ty = y + oy
|
|
if not 0 <= ty < self.h:
|
|
continue
|
|
for x in range(w):
|
|
a = alpha[y * w + x]
|
|
if a:
|
|
tx = x + ox
|
|
if 0 <= tx < self.w:
|
|
self._blend((ty * self.w + tx) * 4, WORD, a / 255.0)
|
|
|
|
def round_corners(self, radius):
|
|
"""Multiply alpha with a rounded-rect mask (icon)."""
|
|
for y in range(self.h):
|
|
for x in range(self.w):
|
|
dx = max(0.0, max(radius - (x + 0.5), (x + 0.5) - (self.w - radius)))
|
|
dy = max(0.0, max(radius - (y + 0.5), (y + 0.5) - (self.h - radius)))
|
|
if dx > 0 and dy > 0:
|
|
cov = min(1.0, max(0.0, 0.5 + radius - math.hypot(dx, dy)))
|
|
i = (y * self.w + x) * 4
|
|
self.buf[i + 3] = round(self.buf[i + 3] * cov)
|
|
|
|
def png(self):
|
|
def chunk(tag, data):
|
|
return (
|
|
struct.pack(">I", len(data))
|
|
+ tag
|
|
+ data
|
|
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
)
|
|
|
|
ihdr = struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)
|
|
raw = b"".join(
|
|
b"\x00" + bytes(self.buf[y * self.w * 4 : (y + 1) * self.w * 4]) for y in range(self.h)
|
|
)
|
|
return (
|
|
b"\x89PNG\r\n\x1a\n"
|
|
+ chunk(b"IHDR", ihdr)
|
|
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
|
+ chunk(b"IEND", b"")
|
|
)
|
|
|
|
|
|
def save(name, canvas):
|
|
OUT.mkdir(parents=True, exist_ok=True)
|
|
out = OUT / name
|
|
out.write_bytes(canvas.png())
|
|
print(f"wrote {out.relative_to(HERE.parent.parent)} ({canvas.w}x{canvas.h})")
|
|
|
|
|
|
def main():
|
|
# Portrait capsule: mark in the upper half, wordmark beneath.
|
|
c = Canvas(600, 900)
|
|
c.fill_gradient(BG_TOP, BG_BOT)
|
|
c.glow(300, 340, 260, COL_DEEP, 0.35)
|
|
c.mark(300, 340, 320)
|
|
c.word("punktfunk", 44, 300, 640)
|
|
save("grid.png", c)
|
|
|
|
# Wide capsule: mark left, wordmark right of it.
|
|
c = Canvas(920, 430)
|
|
c.fill_gradient(BG_TOP, BG_BOT)
|
|
c.glow(230, 215, 200, COL_DEEP, 0.35)
|
|
c.mark(230, 215, 240)
|
|
c.word("punktfunk", 40, 620, 220)
|
|
save("gridwide.png", c)
|
|
|
|
# Hero: ambient banner — the mark rides the right third; Steam overlays logo.png itself.
|
|
c = Canvas(1920, 620)
|
|
c.fill_gradient(BG_TOP, BG_BOT)
|
|
c.glow(1500, 310, 330, COL_DEEP, 0.4)
|
|
c.mark(1500, 310, 400)
|
|
save("hero.png", c)
|
|
|
|
# Logo (transparent): mark + wordmark side by side, overlaid on the hero by Steam.
|
|
c = Canvas(1120, 300)
|
|
c.mark(150, 150, 240)
|
|
c.word("punktfunk", 62, 660, 155)
|
|
save("logo.png", c)
|
|
|
|
# Icon: brand tile, rounded corners, mark only.
|
|
c = Canvas(256, 256)
|
|
c.fill_gradient(BG_TOP, BG_BOT)
|
|
c.glow(128, 128, 110, COL_DEEP, 0.3)
|
|
c.mark(128, 128, 190)
|
|
c.round_corners(36)
|
|
save("icon.png", c)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|