ui.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. """ui.py -- fullscreen touchscreen UI for the Steam Deck forwarder.
  2. Big tappable tiles, one per bonded puck. A tile is tappable when its puck is LIVE (the RF link is up).
  3. Tapping a live tile detaches the Deck controls and forwards to that puck (a single tap -- connecting is
  4. cheap and reversible). The two DESTRUCTIVE actions -- stopping/disconnecting a forward, and removing a
  5. bond (unpair) -- instead require a HOLD (HOLD_SECS): an errant screen tap mid-game used to drop the forward
  6. (which on the puck side tears down a USB device and could reboot the MCU), so a brief touch must no longer
  7. trigger it. A progress fill shows the hold; lifting before it completes cancels. Sized for the Deck's
  8. 1280x800 touchscreen but scales.
  9. Status icons (dots, the remove ✕) are drawn with primitives, not font glyphs -- the Deck's available fonts
  10. don't carry ● ○ ✕ etc., so rendering them as text just showed blank/boxes.
  11. """
  12. import time
  13. import pygame
  14. # Destructive actions (stop forwarding, remove bond) require holding this long; a tap no longer triggers them.
  15. HOLD_SECS = 2.0
  16. BG = (16, 18, 24)
  17. FG = (230, 232, 238)
  18. DIM = (120, 126, 140)
  19. ACCENT = (90, 170, 255)
  20. LIVE = (70, 200, 120)
  21. WARN = (235, 180, 70)
  22. STOP = (235, 90, 90)
  23. TILE = (32, 36, 46)
  24. TILE_LIVE = (28, 54, 40)
  25. TILE_SEL = (40, 70, 110)
  26. def _font(sz, bold=False):
  27. f = pygame.font.SysFont("noto sans,dejavusans,sans", sz, bold=bold)
  28. return f
  29. def _draw_dot(screen, center, r, color, filled=True):
  30. """Status dot drawn with primitives -- font glyphs (● ○) don't render on the Deck's fallback font."""
  31. if filled:
  32. pygame.draw.circle(screen, color, center, r)
  33. else:
  34. pygame.draw.circle(screen, color, center, r, max(2, r // 3))
  35. def _draw_cross(screen, rect, color, width):
  36. """Remove-bond ✕ drawn as two diagonals (the font glyph doesn't render)."""
  37. pad = max(6, rect.width // 4)
  38. pygame.draw.line(screen, color, (rect.x + pad, rect.y + pad),
  39. (rect.right - pad, rect.bottom - pad), width)
  40. pygame.draw.line(screen, color, (rect.right - pad, rect.y + pad),
  41. (rect.x + pad, rect.bottom - pad), width)
  42. def run(app):
  43. pygame.init()
  44. pygame.display.set_caption("ReversePuck · Steam Deck")
  45. screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
  46. W, H = screen.get_size()
  47. clock = pygame.time.Clock()
  48. f_title = _font(int(H * 0.05), bold=True)
  49. f_big = _font(int(H * 0.045), bold=True)
  50. f_med = _font(int(H * 0.03))
  51. f_small = _font(int(H * 0.022))
  52. tile_rects = [] # (rect, slot, tappable)
  53. del_rects = [] # (rect, slot) -- the per-tile "remove bond" ✕ target
  54. # Active hold-to-confirm gesture, or None. dict(kind="stop"|"unpair", slot, start). Read by draw() for
  55. # the progress fill; set in handle_down()/cleared in handle_up() and on completion.
  56. hold = None
  57. def hold_progress():
  58. """0.0..1.0 of the current hold, or None if no hold is active."""
  59. if hold is None:
  60. return None
  61. return max(0.0, min(1.0, (time.monotonic() - hold["start"]) / HOLD_SECS))
  62. def draw():
  63. nonlocal tile_rects, del_rects
  64. screen.fill(BG)
  65. s = app.status
  66. connected = app.dongle_connected
  67. # compact status line (no big title banner): dongle presence first, then RF link. The leading status
  68. # dot is drawn (font glyphs don't render); filled = linked, ring = no-dongle / searching.
  69. if not connected:
  70. badge, col = "no dongle", WARN
  71. elif s["link_up"]:
  72. badge, col = "linked ch%d" % s["sess_ch"], LIVE
  73. else:
  74. badge, col = "searching...", DIM
  75. t = f_med.render(badge, True, col)
  76. bx = W - t.get_width() - 40
  77. screen.blit(t, (bx, 24))
  78. _draw_dot(screen, (bx - 18, 24 + t.get_height() // 2), 8, col,
  79. filled=connected and s["link_up"])
  80. # legend (top-left): how the gestures work
  81. screen.blit(f_small.render("Tap to connect, hold to disconnect or remove", True, DIM),
  82. (40, 30))
  83. # tiles -- live pucks float to the top, then by slot index
  84. tile_rects = []
  85. del_rects = []
  86. bonds = sorted(s["bonds"], key=lambda b: (not b["alive"], b["slot"]))
  87. top = int(H * 0.16)
  88. gap = int(H * 0.025)
  89. th = int((H * 0.42 - gap * max(0, len(bonds) - 1)) / max(1, len(bonds)))
  90. th = min(th, int(H * 0.20))
  91. if not connected:
  92. msg = f_big.render("Plug in the ReversePuck dongle", True, WARN)
  93. screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
  94. sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB... it'll appear here automatically.",
  95. True, DIM)
  96. screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
  97. elif not bonds:
  98. msg = f_big.render("No paired pucks", True, DIM)
  99. screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
  100. sub = f_small.render("Pair on a computer with Steam or PairTUI (scpair.py), then plug the puck into your host.",
  101. True, DIM)
  102. screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
  103. for n, b in enumerate(bonds):
  104. slot = b["slot"]
  105. y = top + n * (th + gap)
  106. rect = pygame.Rect(40, y, W - 80, th)
  107. sel = app.forwarding and app.fwd_slot == slot
  108. tappable = b["alive"] and not app.forwarding
  109. color = TILE_SEL if sel else (TILE_LIVE if b["alive"] else TILE)
  110. pygame.draw.rect(screen, color, rect, border_radius=18)
  111. pygame.draw.rect(screen, (ACCENT if (tappable or sel) else DIM), rect, width=2, border_radius=18)
  112. # status dot (drawn -- font ● ○ don't render) + serial
  113. gcol = LIVE if b["alive"] else DIM
  114. _draw_dot(screen, (rect.x + 46, rect.y + th // 2), 13, gcol, filled=b["alive"])
  115. screen.blit(f_big.render(b["serial"] or "puck %d" % slot, True, FG),
  116. (rect.x + 90, rect.y + 20))
  117. state = ("FORWARDING - hold anywhere to stop" if sel
  118. else "available - tap to forward" if tappable
  119. else "live (forwarding elsewhere)" if b["alive"]
  120. else "paired - offline")
  121. screen.blit(f_small.render("slot %d - %s" % (slot, state), True, DIM),
  122. (rect.x + 90, rect.y + th - 38))
  123. tile_rects.append((rect, slot, tappable or sel))
  124. # remove-bond ✕ on the far right (only when idle -- can't un-bond while forwarding)
  125. if not app.forwarding:
  126. dsz = min(th - 24, 64)
  127. drect = pygame.Rect(rect.right - dsz - 18, rect.y + (th - dsz) // 2, dsz, dsz)
  128. pygame.draw.rect(screen, TILE, drect, border_radius=12)
  129. # while this ✕ is being held, fill it from the bottom up as a hold-to-unpair progress meter
  130. held = (hold is not None and hold["kind"] == "unpair"
  131. and hold["slot"] == slot)
  132. hp = hold_progress()
  133. if held and hp:
  134. fh = int(dsz * hp)
  135. fill = pygame.Rect(drect.x, drect.bottom - fh, dsz, fh)
  136. pygame.draw.rect(screen, STOP, fill, border_radius=12)
  137. pygame.draw.rect(screen, STOP, drect, width=2, border_radius=12)
  138. _draw_cross(screen, drect, FG if held else STOP, 4)
  139. del_rects.append((drect, slot))
  140. # firmware log panel (the in-app serial monitor) — fills the area below the tiles
  141. log_top = int(H * 0.60)
  142. log_bot = H - int(H * (0.12 if app.forwarding else 0.02))
  143. lh = f_small.get_height() + 2
  144. nlines = max(1, (log_bot - log_top - 24) // lh)
  145. screen.blit(f_small.render("firmware log", True, DIM), (40, log_top))
  146. lines = app.log[-nlines:]
  147. for i, ln in enumerate(lines):
  148. # colorize the lines that matter for connecting
  149. c = DIM
  150. if "SESSION live" in ln or "CONNECTED" in ln:
  151. c = LIVE
  152. elif "adopted" in ln or "E1 heard" in ln:
  153. c = ACCENT
  154. elif "lost" in ln or "no bond" in ln or "fail" in ln:
  155. c = WARN
  156. screen.blit(f_small.render(ln[:120], True, c), (40, log_top + 24 + i * lh))
  157. # forwarding banner (drawn last so it sits on top)
  158. if app.forwarding:
  159. bar = pygame.Rect(0, H - int(H * 0.1), W, int(H * 0.1))
  160. pygame.draw.rect(screen, STOP, bar)
  161. hp = hold_progress()
  162. if hold is not None and hold["kind"] == "stop" and hp:
  163. # fill the banner left->right as the stop hold progresses; lighten so the fill reads as a meter
  164. pygame.draw.rect(screen, (255, 150, 150),
  165. pygame.Rect(bar.x, bar.y, int(bar.width * hp), bar.height))
  166. left = max(0.0, HOLD_SECS * (1.0 - hp))
  167. msg = f_big.render("KEEP HOLDING TO STOP - %.0fs" % (left + 0.99),
  168. True, (255, 255, 255))
  169. else:
  170. msg = f_big.render("FORWARDING - Deck controls detached - hold to stop",
  171. True, (255, 255, 255))
  172. screen.blit(msg, (W // 2 - msg.get_width() // 2, bar.y + bar.height // 2 - 22))
  173. pygame.display.flip()
  174. def handle_down(pos):
  175. # Press handler. CONNECT (toggle a live tile) fires immediately -- it's cheap and reversible. The
  176. # destructive actions (STOP a forward, REMOVE a bond) instead ARM a hold; they only fire from
  177. # check_hold() once HOLD_SECS elapses, so an errant tap can't trigger them. Actions still only
  178. # ENQUEUE intents so they never race the IO thread's serial/libusb access.
  179. nonlocal hold
  180. if hold is not None:
  181. return # a hold is already in progress; ignore re-entrant/duplicate down events
  182. if app.forwarding:
  183. # hold anywhere on the screen to stop forwarding
  184. hold = {"kind": "stop", "slot": None, "start": time.monotonic()}
  185. return
  186. # the remove-bond ✕ takes priority over the tile body it sits on -- hold it to unpair
  187. for drect, slot in del_rects:
  188. if drect.collidepoint(pos):
  189. hold = {"kind": "unpair", "slot": slot, "start": time.monotonic()}
  190. return
  191. # live tile: connecting/forwarding stays a single tap
  192. for rect, slot, tappable in tile_rects:
  193. if rect.collidepoint(pos) and tappable:
  194. app.request_toggle(slot)
  195. return
  196. def handle_up():
  197. # Lifting (or a stray up) before HOLD_SECS cancels the pending destructive action.
  198. nonlocal hold
  199. hold = None
  200. def check_hold():
  201. # Fire the held action once it has been sustained long enough, then disarm. Also cancel a hold whose
  202. # target went away (the forward auto-released, or forwarding started so a bond can't be removed).
  203. nonlocal hold
  204. if hold is None:
  205. return
  206. if hold["kind"] == "stop" and not app.forwarding:
  207. hold = None
  208. return
  209. if hold["kind"] == "unpair" and app.forwarding:
  210. hold = None
  211. return
  212. if time.monotonic() - hold["start"] >= HOLD_SECS:
  213. if hold["kind"] == "stop":
  214. app.request_stop()
  215. elif hold["kind"] == "unpair":
  216. app.request_remove(hold["slot"])
  217. hold = None
  218. running = True
  219. while running:
  220. for ev in pygame.event.get():
  221. if ev.type == pygame.QUIT:
  222. running = False
  223. elif ev.type == pygame.KEYDOWN and ev.key in (pygame.K_ESCAPE, pygame.K_q):
  224. running = False
  225. elif ev.type == pygame.MOUSEBUTTONDOWN:
  226. handle_down(ev.pos)
  227. elif ev.type == pygame.FINGERDOWN:
  228. handle_down((int(ev.x * W), int(ev.y * H)))
  229. elif ev.type in (pygame.MOUSEBUTTONUP, pygame.FINGERUP):
  230. handle_up()
  231. check_hold() # promote a sustained hold into the actual stop/unpair intent
  232. # Render only -- serial I/O + input forwarding run on app._io_loop (a separate thread) so the
  233. # vsync'd flip() below can't throttle forwarding to 60Hz. A bad draw must never kill the app.
  234. try:
  235. draw()
  236. except Exception as ex:
  237. app._flog("UI draw error: %r" % ex)
  238. app.note = "recovered from error: %s" % ex
  239. clock.tick(60) # UI render rate; forwarding is decoupled on the IO thread now
  240. pygame.quit()