|
|
@@ -1,12 +1,22 @@
|
|
|
"""ui.py -- fullscreen touchscreen UI for the Steam Deck forwarder.
|
|
|
|
|
|
Big tappable tiles, one per bonded puck. A tile is tappable when its puck is LIVE (the RF link is up).
|
|
|
-Tapping a live tile detaches the Deck controls and forwards to that puck; tapping anywhere while
|
|
|
-forwarding stops and returns control to the Deck. Sized for the Deck's 1280x800 touchscreen but scales.
|
|
|
+Tapping a live tile detaches the Deck controls and forwards to that puck (a single tap -- connecting is
|
|
|
+cheap and reversible). The two DESTRUCTIVE actions -- stopping/disconnecting a forward, and removing a
|
|
|
+bond (unpair) -- instead require a HOLD (HOLD_SECS): an errant screen tap mid-game used to drop the forward
|
|
|
+(which on the puck side tears down a USB device and could reboot the MCU), so a brief touch must no longer
|
|
|
+trigger it. A progress fill shows the hold; lifting before it completes cancels. Sized for the Deck's
|
|
|
+1280x800 touchscreen but scales.
|
|
|
+
|
|
|
+Status icons (dots, the remove ✕) are drawn with primitives, not font glyphs -- the Deck's available fonts
|
|
|
+don't carry ● ○ ✕ etc., so rendering them as text just showed blank/boxes.
|
|
|
"""
|
|
|
import time
|
|
|
import pygame
|
|
|
|
|
|
+# Destructive actions (stop forwarding, remove bond) require holding this long; a tap no longer triggers them.
|
|
|
+HOLD_SECS = 2.0
|
|
|
+
|
|
|
BG = (16, 18, 24)
|
|
|
FG = (230, 232, 238)
|
|
|
DIM = (120, 126, 140)
|
|
|
@@ -24,6 +34,23 @@ def _font(sz, bold=False):
|
|
|
return f
|
|
|
|
|
|
|
|
|
+def _draw_dot(screen, center, r, color, filled=True):
|
|
|
+ """Status dot drawn with primitives -- font glyphs (● ○) don't render on the Deck's fallback font."""
|
|
|
+ if filled:
|
|
|
+ pygame.draw.circle(screen, color, center, r)
|
|
|
+ else:
|
|
|
+ pygame.draw.circle(screen, color, center, r, max(2, r // 3))
|
|
|
+
|
|
|
+
|
|
|
+def _draw_cross(screen, rect, color, width):
|
|
|
+ """Remove-bond ✕ drawn as two diagonals (the font glyph doesn't render)."""
|
|
|
+ pad = max(6, rect.width // 4)
|
|
|
+ pygame.draw.line(screen, color, (rect.x + pad, rect.y + pad),
|
|
|
+ (rect.right - pad, rect.bottom - pad), width)
|
|
|
+ pygame.draw.line(screen, color, (rect.right - pad, rect.y + pad),
|
|
|
+ (rect.x + pad, rect.bottom - pad), width)
|
|
|
+
|
|
|
+
|
|
|
def run(app):
|
|
|
pygame.init()
|
|
|
pygame.display.set_caption("ReversePuck · Steam Deck")
|
|
|
@@ -37,21 +64,37 @@ def run(app):
|
|
|
|
|
|
tile_rects = [] # (rect, slot, tappable)
|
|
|
del_rects = [] # (rect, slot) -- the per-tile "remove bond" ✕ target
|
|
|
+ # Active hold-to-confirm gesture, or None. dict(kind="stop"|"unpair", slot, start). Read by draw() for
|
|
|
+ # the progress fill; set in handle_down()/cleared in handle_up() and on completion.
|
|
|
+ hold = None
|
|
|
+
|
|
|
+ def hold_progress():
|
|
|
+ """0.0..1.0 of the current hold, or None if no hold is active."""
|
|
|
+ if hold is None:
|
|
|
+ return None
|
|
|
+ return max(0.0, min(1.0, (time.monotonic() - hold["start"]) / HOLD_SECS))
|
|
|
|
|
|
def draw():
|
|
|
nonlocal tile_rects, del_rects
|
|
|
screen.fill(BG)
|
|
|
s = app.status
|
|
|
connected = app.dongle_connected
|
|
|
- # compact status line (no big title banner): dongle presence first, then RF link
|
|
|
+ # compact status line (no big title banner): dongle presence first, then RF link. The leading status
|
|
|
+ # dot is drawn (font glyphs don't render); filled = linked, ring = no-dongle / searching.
|
|
|
if not connected:
|
|
|
- badge, col = "⨯ no dongle", WARN
|
|
|
+ badge, col = "no dongle", WARN
|
|
|
elif s["link_up"]:
|
|
|
- badge, col = "● linked ch%d" % s["sess_ch"], LIVE
|
|
|
+ badge, col = "linked ch%d" % s["sess_ch"], LIVE
|
|
|
else:
|
|
|
- badge, col = "○ searching…", DIM
|
|
|
+ badge, col = "searching...", DIM
|
|
|
t = f_med.render(badge, True, col)
|
|
|
- screen.blit(t, (W - t.get_width() - 40, 24))
|
|
|
+ bx = W - t.get_width() - 40
|
|
|
+ screen.blit(t, (bx, 24))
|
|
|
+ _draw_dot(screen, (bx - 18, 24 + t.get_height() // 2), 8, col,
|
|
|
+ filled=connected and s["link_up"])
|
|
|
+ # legend (top-left): how the gestures work
|
|
|
+ screen.blit(f_small.render("Tap to connect, hold to disconnect or remove", True, DIM),
|
|
|
+ (40, 30))
|
|
|
|
|
|
# tiles -- live pucks float to the top, then by slot index
|
|
|
tile_rects = []
|
|
|
@@ -64,7 +107,7 @@ def run(app):
|
|
|
if not connected:
|
|
|
msg = f_big.render("Plug in the ReversePuck dongle", True, WARN)
|
|
|
screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
|
|
|
- sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB… it'll appear here automatically.",
|
|
|
+ sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB... it'll appear here automatically.",
|
|
|
True, DIM)
|
|
|
screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
|
|
|
elif not bonds:
|
|
|
@@ -82,17 +125,16 @@ def run(app):
|
|
|
color = TILE_SEL if sel else (TILE_LIVE if b["alive"] else TILE)
|
|
|
pygame.draw.rect(screen, color, rect, border_radius=18)
|
|
|
pygame.draw.rect(screen, (ACCENT if (tappable or sel) else DIM), rect, width=2, border_radius=18)
|
|
|
- # glyph + serial
|
|
|
- glyph = "●" if b["alive"] else "○"
|
|
|
+ # status dot (drawn -- font ● ○ don't render) + serial
|
|
|
gcol = LIVE if b["alive"] else DIM
|
|
|
- screen.blit(f_big.render(glyph, True, gcol), (rect.x + 30, rect.y + th // 2 - 22))
|
|
|
+ _draw_dot(screen, (rect.x + 46, rect.y + th // 2), 13, gcol, filled=b["alive"])
|
|
|
screen.blit(f_big.render(b["serial"] or "puck %d" % slot, True, FG),
|
|
|
(rect.x + 90, rect.y + 20))
|
|
|
- state = ("FORWARDING — tap to stop" if sel
|
|
|
- else "available — tap to forward" if tappable
|
|
|
+ state = ("FORWARDING - hold anywhere to stop" if sel
|
|
|
+ else "available - tap to forward" if tappable
|
|
|
else "live (forwarding elsewhere)" if b["alive"]
|
|
|
- else "paired · offline")
|
|
|
- screen.blit(f_small.render("slot %d · %s" % (slot, state), True, DIM),
|
|
|
+ else "paired - offline")
|
|
|
+ screen.blit(f_small.render("slot %d - %s" % (slot, state), True, DIM),
|
|
|
(rect.x + 90, rect.y + th - 38))
|
|
|
tile_rects.append((rect, slot, tappable or sel))
|
|
|
# remove-bond ✕ on the far right (only when idle -- can't un-bond while forwarding)
|
|
|
@@ -100,10 +142,16 @@ def run(app):
|
|
|
dsz = min(th - 24, 64)
|
|
|
drect = pygame.Rect(rect.right - dsz - 18, rect.y + (th - dsz) // 2, dsz, dsz)
|
|
|
pygame.draw.rect(screen, TILE, drect, border_radius=12)
|
|
|
+ # while this ✕ is being held, fill it from the bottom up as a hold-to-unpair progress meter
|
|
|
+ held = (hold is not None and hold["kind"] == "unpair"
|
|
|
+ and hold["slot"] == slot)
|
|
|
+ hp = hold_progress()
|
|
|
+ if held and hp:
|
|
|
+ fh = int(dsz * hp)
|
|
|
+ fill = pygame.Rect(drect.x, drect.bottom - fh, dsz, fh)
|
|
|
+ pygame.draw.rect(screen, STOP, fill, border_radius=12)
|
|
|
pygame.draw.rect(screen, STOP, drect, width=2, border_radius=12)
|
|
|
- x = f_big.render("✕", True, STOP)
|
|
|
- screen.blit(x, (drect.centerx - x.get_width() // 2,
|
|
|
- drect.centery - x.get_height() // 2))
|
|
|
+ _draw_cross(screen, drect, FG if held else STOP, 4)
|
|
|
del_rects.append((drect, slot))
|
|
|
|
|
|
# firmware log panel (the in-app serial monitor) — fills the area below the tiles
|
|
|
@@ -128,26 +176,67 @@ def run(app):
|
|
|
if app.forwarding:
|
|
|
bar = pygame.Rect(0, H - int(H * 0.1), W, int(H * 0.1))
|
|
|
pygame.draw.rect(screen, STOP, bar)
|
|
|
- msg = f_big.render("FORWARDING — Deck controls detached · tap anywhere to stop", True, (255, 255, 255))
|
|
|
+ hp = hold_progress()
|
|
|
+ if hold is not None and hold["kind"] == "stop" and hp:
|
|
|
+ # fill the banner left->right as the stop hold progresses; lighten so the fill reads as a meter
|
|
|
+ pygame.draw.rect(screen, (255, 150, 150),
|
|
|
+ pygame.Rect(bar.x, bar.y, int(bar.width * hp), bar.height))
|
|
|
+ left = max(0.0, HOLD_SECS * (1.0 - hp))
|
|
|
+ msg = f_big.render("KEEP HOLDING TO STOP - %.0fs" % (left + 0.99),
|
|
|
+ True, (255, 255, 255))
|
|
|
+ else:
|
|
|
+ msg = f_big.render("FORWARDING - Deck controls detached - hold to stop",
|
|
|
+ True, (255, 255, 255))
|
|
|
screen.blit(msg, (W // 2 - msg.get_width() // 2, bar.y + bar.height // 2 - 22))
|
|
|
pygame.display.flip()
|
|
|
|
|
|
- def handle_tap(pos):
|
|
|
- # Taps only ENQUEUE intents; the IO thread runs the actual grab()/send() so they never race the
|
|
|
- # forwarding thread's serial/libusb access.
|
|
|
+ def handle_down(pos):
|
|
|
+ # Press handler. CONNECT (toggle a live tile) fires immediately -- it's cheap and reversible. The
|
|
|
+ # destructive actions (STOP a forward, REMOVE a bond) instead ARM a hold; they only fire from
|
|
|
+ # check_hold() once HOLD_SECS elapses, so an errant tap can't trigger them. Actions still only
|
|
|
+ # ENQUEUE intents so they never race the IO thread's serial/libusb access.
|
|
|
+ nonlocal hold
|
|
|
+ if hold is not None:
|
|
|
+ return # a hold is already in progress; ignore re-entrant/duplicate down events
|
|
|
if app.forwarding:
|
|
|
- app.request_stop()
|
|
|
+ # hold anywhere on the screen to stop forwarding
|
|
|
+ hold = {"kind": "stop", "slot": None, "start": time.monotonic()}
|
|
|
return
|
|
|
- # the remove-bond ✕ takes priority over the tile body it sits on
|
|
|
+ # the remove-bond ✕ takes priority over the tile body it sits on -- hold it to unpair
|
|
|
for drect, slot in del_rects:
|
|
|
if drect.collidepoint(pos):
|
|
|
- app.request_remove(slot)
|
|
|
+ hold = {"kind": "unpair", "slot": slot, "start": time.monotonic()}
|
|
|
return
|
|
|
+ # live tile: connecting/forwarding stays a single tap
|
|
|
for rect, slot, tappable in tile_rects:
|
|
|
if rect.collidepoint(pos) and tappable:
|
|
|
app.request_toggle(slot)
|
|
|
return
|
|
|
|
|
|
+ def handle_up():
|
|
|
+ # Lifting (or a stray up) before HOLD_SECS cancels the pending destructive action.
|
|
|
+ nonlocal hold
|
|
|
+ hold = None
|
|
|
+
|
|
|
+ def check_hold():
|
|
|
+ # Fire the held action once it has been sustained long enough, then disarm. Also cancel a hold whose
|
|
|
+ # target went away (the forward auto-released, or forwarding started so a bond can't be removed).
|
|
|
+ nonlocal hold
|
|
|
+ if hold is None:
|
|
|
+ return
|
|
|
+ if hold["kind"] == "stop" and not app.forwarding:
|
|
|
+ hold = None
|
|
|
+ return
|
|
|
+ if hold["kind"] == "unpair" and app.forwarding:
|
|
|
+ hold = None
|
|
|
+ return
|
|
|
+ if time.monotonic() - hold["start"] >= HOLD_SECS:
|
|
|
+ if hold["kind"] == "stop":
|
|
|
+ app.request_stop()
|
|
|
+ elif hold["kind"] == "unpair":
|
|
|
+ app.request_remove(hold["slot"])
|
|
|
+ hold = None
|
|
|
+
|
|
|
running = True
|
|
|
while running:
|
|
|
for ev in pygame.event.get():
|
|
|
@@ -156,9 +245,13 @@ def run(app):
|
|
|
elif ev.type == pygame.KEYDOWN and ev.key in (pygame.K_ESCAPE, pygame.K_q):
|
|
|
running = False
|
|
|
elif ev.type == pygame.MOUSEBUTTONDOWN:
|
|
|
- handle_tap(ev.pos)
|
|
|
+ handle_down(ev.pos)
|
|
|
elif ev.type == pygame.FINGERDOWN:
|
|
|
- handle_tap((int(ev.x * W), int(ev.y * H)))
|
|
|
+ handle_down((int(ev.x * W), int(ev.y * H)))
|
|
|
+ elif ev.type in (pygame.MOUSEBUTTONUP, pygame.FINGERUP):
|
|
|
+ handle_up()
|
|
|
+
|
|
|
+ check_hold() # promote a sustained hold into the actual stop/unpair intent
|
|
|
|
|
|
# Render only -- serial I/O + input forwarding run on app._io_loop (a separate thread) so the
|
|
|
# vsync'd flip() below can't throttle forwarding to 60Hz. A bad draw must never kill the app.
|