ui.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  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; tapping anywhere while
  4. forwarding stops and returns control to the Deck. Sized for the Deck's 1280x800 touchscreen but scales.
  5. """
  6. import time
  7. import pygame
  8. BG = (16, 18, 24)
  9. FG = (230, 232, 238)
  10. DIM = (120, 126, 140)
  11. ACCENT = (90, 170, 255)
  12. LIVE = (70, 200, 120)
  13. WARN = (235, 180, 70)
  14. STOP = (235, 90, 90)
  15. TILE = (32, 36, 46)
  16. TILE_LIVE = (28, 54, 40)
  17. TILE_SEL = (40, 70, 110)
  18. def _font(sz, bold=False):
  19. f = pygame.font.SysFont("noto sans,dejavusans,sans", sz, bold=bold)
  20. return f
  21. def run(app):
  22. pygame.init()
  23. pygame.display.set_caption("OpenController · Steam Deck")
  24. screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
  25. W, H = screen.get_size()
  26. clock = pygame.time.Clock()
  27. f_title = _font(int(H * 0.05), bold=True)
  28. f_big = _font(int(H * 0.045), bold=True)
  29. f_med = _font(int(H * 0.03))
  30. f_small = _font(int(H * 0.022))
  31. tile_rects = [] # (rect, slot, tappable)
  32. del_rects = [] # (rect, slot) -- the per-tile "remove bond" ✕ target
  33. def draw():
  34. nonlocal tile_rects, del_rects
  35. screen.fill(BG)
  36. s = app.status
  37. connected = app.dongle_connected
  38. # compact status line (no big title banner): dongle presence first, then RF link
  39. if not connected:
  40. badge, col = "⨯ no dongle", WARN
  41. elif s["link_up"]:
  42. badge, col = "● linked ch%d" % s["sess_ch"], LIVE
  43. else:
  44. badge, col = "○ searching…", DIM
  45. t = f_med.render(badge, True, col)
  46. screen.blit(t, (W - t.get_width() - 40, 24))
  47. # tiles -- live pucks float to the top, then by slot index
  48. tile_rects = []
  49. del_rects = []
  50. bonds = sorted(s["bonds"], key=lambda b: (not b["alive"], b["slot"]))
  51. top = int(H * 0.16)
  52. gap = int(H * 0.025)
  53. th = int((H * 0.42 - gap * max(0, len(bonds) - 1)) / max(1, len(bonds)))
  54. th = min(th, int(H * 0.20))
  55. if not connected:
  56. msg = f_big.render("Plug in the OpenController dongle", True, WARN)
  57. screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
  58. sub = f_small.render("Waiting for the nRF (Valve 28DE:1302) on USB… it'll appear here automatically.",
  59. True, DIM)
  60. screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
  61. elif not bonds:
  62. msg = f_big.render("No paired pucks", True, DIM)
  63. screen.blit(msg, (W // 2 - msg.get_width() // 2, H // 2 - 40))
  64. sub = f_small.render("Pair on a computer with Steam or PairTUI (scpair.py), then plug the puck into your host.",
  65. True, DIM)
  66. screen.blit(sub, (W // 2 - sub.get_width() // 2, H // 2 + 20))
  67. for n, b in enumerate(bonds):
  68. slot = b["slot"]
  69. y = top + n * (th + gap)
  70. rect = pygame.Rect(40, y, W - 80, th)
  71. sel = app.forwarding and app.fwd_slot == slot
  72. tappable = b["alive"] and not app.forwarding
  73. color = TILE_SEL if sel else (TILE_LIVE if b["alive"] else TILE)
  74. pygame.draw.rect(screen, color, rect, border_radius=18)
  75. pygame.draw.rect(screen, (ACCENT if (tappable or sel) else DIM), rect, width=2, border_radius=18)
  76. # glyph + serial
  77. glyph = "●" if b["alive"] else "○"
  78. gcol = LIVE if b["alive"] else DIM
  79. screen.blit(f_big.render(glyph, True, gcol), (rect.x + 30, rect.y + th // 2 - 22))
  80. screen.blit(f_big.render(b["serial"] or "puck %d" % slot, True, FG),
  81. (rect.x + 90, rect.y + 20))
  82. state = ("FORWARDING — tap to stop" if sel
  83. else "available — tap to forward" if tappable
  84. else "live (forwarding elsewhere)" if b["alive"]
  85. else "paired · offline")
  86. screen.blit(f_small.render("slot %d · %s" % (slot, state), True, DIM),
  87. (rect.x + 90, rect.y + th - 38))
  88. tile_rects.append((rect, slot, tappable or sel))
  89. # remove-bond ✕ on the far right (only when idle -- can't un-bond while forwarding)
  90. if not app.forwarding:
  91. dsz = min(th - 24, 64)
  92. drect = pygame.Rect(rect.right - dsz - 18, rect.y + (th - dsz) // 2, dsz, dsz)
  93. pygame.draw.rect(screen, TILE, drect, border_radius=12)
  94. pygame.draw.rect(screen, STOP, drect, width=2, border_radius=12)
  95. x = f_big.render("✕", True, STOP)
  96. screen.blit(x, (drect.centerx - x.get_width() // 2,
  97. drect.centery - x.get_height() // 2))
  98. del_rects.append((drect, slot))
  99. # firmware log panel (the in-app serial monitor) — fills the area below the tiles
  100. log_top = int(H * 0.60)
  101. log_bot = H - int(H * (0.12 if app.forwarding else 0.02))
  102. lh = f_small.get_height() + 2
  103. nlines = max(1, (log_bot - log_top - 24) // lh)
  104. screen.blit(f_small.render("firmware log", True, DIM), (40, log_top))
  105. lines = app.log[-nlines:]
  106. for i, ln in enumerate(lines):
  107. # colorize the lines that matter for connecting
  108. c = DIM
  109. if "SESSION live" in ln or "CONNECTED" in ln:
  110. c = LIVE
  111. elif "adopted" in ln or "E1 heard" in ln:
  112. c = ACCENT
  113. elif "lost" in ln or "no bond" in ln or "fail" in ln:
  114. c = WARN
  115. screen.blit(f_small.render(ln[:120], True, c), (40, log_top + 24 + i * lh))
  116. # forwarding banner (drawn last so it sits on top)
  117. if app.forwarding:
  118. bar = pygame.Rect(0, H - int(H * 0.1), W, int(H * 0.1))
  119. pygame.draw.rect(screen, STOP, bar)
  120. msg = f_big.render("FORWARDING — Deck controls detached · tap anywhere to stop", True, (255, 255, 255))
  121. screen.blit(msg, (W // 2 - msg.get_width() // 2, bar.y + bar.height // 2 - 22))
  122. pygame.display.flip()
  123. def handle_tap(pos):
  124. if app.forwarding:
  125. app.stop_forwarding()
  126. return
  127. # the remove-bond ✕ takes priority over the tile body it sits on
  128. for drect, slot in del_rects:
  129. if drect.collidepoint(pos):
  130. app.remove_bond(slot)
  131. return
  132. for rect, slot, tappable in tile_rects:
  133. if rect.collidepoint(pos) and tappable:
  134. app.toggle(slot)
  135. return
  136. running = True
  137. while running:
  138. for ev in pygame.event.get():
  139. if ev.type == pygame.QUIT:
  140. running = False
  141. elif ev.type == pygame.KEYDOWN and ev.key in (pygame.K_ESCAPE, pygame.K_q):
  142. running = False
  143. elif ev.type == pygame.MOUSEBUTTONDOWN:
  144. handle_tap(ev.pos)
  145. elif ev.type == pygame.FINGERDOWN:
  146. handle_tap((int(ev.x * W), int(ev.y * H)))
  147. app.pump_serial()
  148. app.pump_input()
  149. app.auto_release_on_drop()
  150. draw()
  151. # high tick so forwarding input stays smooth; UI redraw is cheap
  152. clock.tick(120)
  153. pygame.quit()