webpygame 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
webpy/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ # engine first: it sets SDL_VIDEODRIVER=dummy before pygame is initialised
2
+ from .engine import start_server, get_window_size, separate_tabs, share, settings, URLCHANGED
3
+
4
+ # Re-export everything from pygame, so `webpy.Rect`, `webpy.draw`, `webpy.K_a`,
5
+ # `webpy.MOUSEBUTTONDOWN` etc. all work. Also gives `webpy.pygame` itself.
6
+ import pygame
7
+ from pygame import *
webpy/engine.py ADDED
@@ -0,0 +1,847 @@
1
+ import os
2
+ os.environ.setdefault("SDL_VIDEODRIVER", "dummy") # no real window needed
3
+
4
+ import asyncio
5
+ import atexit
6
+ import io
7
+ import json
8
+ import platform
9
+ import re
10
+ import shutil
11
+ import socket
12
+ import subprocess
13
+ import sys
14
+ import threading
15
+ import time
16
+ import urllib.request
17
+ import webbrowser
18
+ from http import HTTPStatus
19
+
20
+ import pygame
21
+ import websockets
22
+
23
+ try:
24
+ from websockets.asyncio.server import serve as _ws_serve
25
+ from websockets.asyncio.client import connect as _ws_connect
26
+ except ImportError: # old websockets (before version 14)
27
+ raise ImportError("webpy needs websockets 14 or newer. Run: pip install -U websockets")
28
+
29
+
30
+ class _Settings:
31
+ """webpy.settings: change these at the top of your script, before display.set_mode()."""
32
+ port = 8766 # the one port for the page AND the live connection
33
+ fps = 30 # frames per second sent to the browser
34
+ share = True # True: other computers on your network can open the site
35
+ separate_tabs = True # True: every tab gets its own copy of your script
36
+ new_terminal = False # Windows: give each tab's copy its own console window
37
+ max_tabs = 10 # most separate copies allowed at once (protects your PC)
38
+ tunnel = "cloudflare" # how display.public() makes a public link: "cloudflare" or "off"
39
+ public = True # True: make a public link (works from anywhere) when the site starts
40
+
41
+
42
+ settings = _Settings()
43
+ WS_PATH = "/__webpy_ws" # the websocket lives on the same port
44
+ _CHILD_PORT = os.environ.get("WEBPY_CHILD_PORT") # set only inside a spawned tab process
45
+
46
+
47
+ def share(enabled=True):
48
+ """Same as settings.share. True (default): other computers on your network can open the
49
+ site. False: only this computer can."""
50
+ settings.share = bool(enabled)
51
+
52
+
53
+ def separate_tabs(enabled=True, new_terminal=False):
54
+ """Same as settings.separate_tabs / settings.new_terminal."""
55
+ settings.separate_tabs = bool(enabled)
56
+ settings.new_terminal = bool(new_terminal)
57
+
58
+
59
+ def _bind_host():
60
+ return None if settings.share else "127.0.0.1" # None = all network interfaces
61
+
62
+
63
+ def _lan_ip():
64
+ try:
65
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
66
+ sock.connect(("10.255.255.255", 1)) # no packet is sent; just picks the right NIC
67
+ return sock.getsockname()[0]
68
+ except OSError:
69
+ try:
70
+ return socket.gethostbyname(socket.gethostname())
71
+ except OSError:
72
+ return "localhost"
73
+
74
+
75
+ def url():
76
+ """The address for other computers on your network, e.g. 'http://192.168.1.23:8766'."""
77
+ return f"http://{_lan_ip() if settings.share else 'localhost'}:{settings.port}"
78
+
79
+
80
+ # ---- public link (Cloudflare quick tunnel; free, no account) -----------------
81
+ _tunnel = {"proc": None, "url": None}
82
+
83
+
84
+ _CF_DIR = os.path.join(os.path.expanduser("~"), ".webpy")
85
+ _CF_FILE = os.path.join(_CF_DIR, "cloudflared" + (".exe" if os.name == "nt" else ""))
86
+ _CF_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download/"
87
+
88
+
89
+ def _find_cloudflared():
90
+ exe = shutil.which("cloudflared") or shutil.which("cloudflared.exe")
91
+ return exe or (_CF_FILE if os.path.exists(_CF_FILE) else None)
92
+
93
+
94
+ def _download_cloudflared():
95
+ """Download Cloudflare's official program into ~/.webpy. Returns its path, or None."""
96
+ machine = platform.machine().lower()
97
+ names = {("Windows", "amd64"): "cloudflared-windows-amd64.exe",
98
+ ("Windows", "x86_64"): "cloudflared-windows-amd64.exe",
99
+ ("Linux", "x86_64"): "cloudflared-linux-amd64",
100
+ ("Linux", "amd64"): "cloudflared-linux-amd64",
101
+ ("Linux", "aarch64"): "cloudflared-linux-arm64",
102
+ ("Linux", "arm64"): "cloudflared-linux-arm64"}
103
+ name = names.get((platform.system(), machine))
104
+ if not name:
105
+ return None
106
+ try:
107
+ os.makedirs(_CF_DIR, exist_ok=True)
108
+ tmp = _CF_FILE + ".part"
109
+ print("webpy: downloading cloudflared (about 40 MB)...")
110
+ urllib.request.urlretrieve(_CF_URL + name, tmp)
111
+ os.replace(tmp, _CF_FILE)
112
+ os.chmod(_CF_FILE, 0o755)
113
+ print("webpy: cloudflared saved to", _CF_FILE)
114
+ return _CF_FILE
115
+ except Exception as e:
116
+ print("webpy: could not download cloudflared:", e)
117
+ return None
118
+
119
+
120
+ def _offer_cloudflared():
121
+ """Ask once (in the terminal) before downloading anything."""
122
+ marker = os.path.join(_CF_DIR, "declined")
123
+ if os.path.exists(marker) or not sys.stdin or not sys.stdin.isatty():
124
+ return None
125
+ try:
126
+ ans = input("webpy: a public link needs Cloudflare's free 'cloudflared' program "
127
+ "(about 40 MB, from github.com/cloudflare/cloudflared).\n"
128
+ " Download it now? [Y/n] ").strip().lower()
129
+ except EOFError:
130
+ return None
131
+ if ans in ("", "y", "yes"):
132
+ return _download_cloudflared()
133
+ try:
134
+ os.makedirs(_CF_DIR, exist_ok=True)
135
+ open(marker, "w").close()
136
+ print(f"webpy: OK, I won't ask again. (Delete {marker} to be asked again.)")
137
+ except OSError:
138
+ pass
139
+ return None
140
+
141
+
142
+ def public():
143
+ """Make the site reachable from ANYWHERE (not just your network) and return the link,
144
+ e.g. 'https://random-words.trycloudflare.com'. Needs the free `cloudflared` program."""
145
+ if _CHILD_PORT is not None: # a tab's copy: the launcher owns the tunnel
146
+ link = os.environ.get("WEBPY_PUBLIC_URL") or None
147
+ if not link:
148
+ print("webpy: to get a public link set webpy.settings.public = True at the top of your script.")
149
+ return link
150
+ if _tunnel["url"]:
151
+ return _tunnel["url"]
152
+ if settings.tunnel in (None, False, "", "off"):
153
+ print("webpy: public links are turned off (settings.tunnel = 'off').")
154
+ return None
155
+ if settings.tunnel != "cloudflare":
156
+ print(f"webpy: unknown tunnel {settings.tunnel!r}. Use 'cloudflare' or 'off'.")
157
+ return None
158
+ exe = _find_cloudflared()
159
+ if not exe:
160
+ exe = _offer_cloudflared()
161
+ if not exe:
162
+ print("webpy: public links need the free 'cloudflared' program. To install it yourself:\n"
163
+ " Windows: winget install --id Cloudflare.cloudflared (then restart the terminal)\n"
164
+ " Mac: brew install cloudflared\n"
165
+ " Linux: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/\n"
166
+ " (or set webpy.settings.public = False to stop looking for a public link)")
167
+ return None
168
+ proc = subprocess.Popen([exe, "tunnel", "--url", f"http://127.0.0.1:{settings.port}", "--no-autoupdate"],
169
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
170
+ text=True, encoding="utf-8", errors="replace")
171
+ atexit.register(lambda: proc.poll() is None and proc.terminate())
172
+ found = threading.Event()
173
+ tail = []
174
+
175
+ def reader():
176
+ for line in proc.stdout: # keep draining so cloudflared never blocks
177
+ tail[:] = (tail + [line.strip()])[-3:]
178
+ m = re.search(r"https://(?!api\.)[a-z0-9-]+\.trycloudflare\.com", line)
179
+ if m and not _tunnel["url"]:
180
+ _tunnel["url"] = m.group(0)
181
+ found.set()
182
+ threading.Thread(target=reader, daemon=True).start()
183
+ _tunnel["proc"] = proc
184
+ if not found.wait(30):
185
+ print("webpy: could not get a public link from Cloudflare (is the network blocking it?).")
186
+ for line in tail[-2:]:
187
+ print(" cloudflared said:", line[:200])
188
+ proc.terminate()
189
+ return None
190
+ print(f"-> Public link (works from anywhere while this is running): {_tunnel['url']}")
191
+ return _tunnel["url"]
192
+
193
+
194
+ pygame.display.url = url
195
+ pygame.display.public = public
196
+
197
+
198
+ # --- JS key code -> pygame key -------------------------------------------
199
+ KEY_MAP = {
200
+ 'Space': pygame.K_SPACE, 'Enter': pygame.K_RETURN, 'Backspace': pygame.K_BACKSPACE,
201
+ 'Escape': pygame.K_ESCAPE, 'Tab': pygame.K_TAB, 'Delete': pygame.K_DELETE,
202
+ 'ArrowUp': pygame.K_UP, 'ArrowDown': pygame.K_DOWN,
203
+ 'ArrowLeft': pygame.K_LEFT, 'ArrowRight': pygame.K_RIGHT,
204
+ 'ShiftLeft': pygame.K_LSHIFT, 'ShiftRight': pygame.K_RSHIFT,
205
+ 'ControlLeft': pygame.K_LCTRL, 'ControlRight': pygame.K_RCTRL,
206
+ 'AltLeft': pygame.K_LALT, 'AltRight': pygame.K_RALT,
207
+ 'Home': pygame.K_HOME, 'End': pygame.K_END,
208
+ 'PageUp': pygame.K_PAGEUP, 'PageDown': pygame.K_PAGEDOWN,
209
+ }
210
+ for c in "abcdefghijklmnopqrstuvwxyz":
211
+ KEY_MAP['Key' + c.upper()] = getattr(pygame, 'K_' + c)
212
+ for d in "0123456789":
213
+ KEY_MAP['Digit' + d] = getattr(pygame, 'K_' + d)
214
+
215
+
216
+ # Posted when the person presses the browser's Back/Forward buttons: event.path is the new URL path.
217
+ URLCHANGED = pygame.event.custom_type()
218
+
219
+
220
+ class KeyState(dict):
221
+ """pygame key constants are huge ints (K_UP == 1073741906), so a fixed
222
+ list of 512 booleans raises IndexError. A dict that defaults to False works."""
223
+ def __missing__(self, key):
224
+ return False
225
+
226
+
227
+ class TabInput:
228
+ """All input state for ONE browser tab."""
229
+ def __init__(self, size=(800, 600)):
230
+ self.surface = pygame.Surface(size) # this tab's canvas (what gets streamed)
231
+ self.resizable = False # set by set_mode(..., pygame.RESIZABLE)
232
+ self.caption = "WebPy Viewport"
233
+ self.path = "/" # the browser's current URL path
234
+ self.url = None # class mode: URL path the site asked for
235
+ self.pos = (0, 0)
236
+ self.pressed = [False, False, False]
237
+ self.keys = KeyState()
238
+ self.events = []
239
+ self.window_size = (0, 0) # browser viewport size, filled in by the page
240
+ self.script_mode = False # True for the shared input used by plain scripts
241
+ self.pending_resize = None # script mode: applied on the main thread
242
+
243
+ def feed(self, data):
244
+ t = data.get('type')
245
+ # BUG FIX: original used data.get('x', tab_mouse_pos) -> a tuple as default,
246
+ # which corrupted the position on key events.
247
+ if 'x' in data and 'y' in data:
248
+ self.pos = (int(data['x']), int(data['y']))
249
+
250
+ if t == 'resize':
251
+ self.window_size = (int(data.get('w', 0)), int(data.get('h', 0)))
252
+ if data.get('path'):
253
+ self.path = str(data['path'])
254
+ w, h = self.window_size
255
+ # RESIZABLE sites follow the browser window, like a real pygame window
256
+ if self.script_mode:
257
+ if self.resizable and w > 0 and h > 0:
258
+ self.pending_resize = (w, h)
259
+ elif self.resizable and w > 0 and h > 0 and (w, h) != self.surface.get_size():
260
+ self.surface = pygame.Surface((w, h))
261
+ self.events.append(pygame.event.Event(pygame.VIDEORESIZE, size=(w, h), w=w, h=h))
262
+ elif t == 'path': # Back / Forward in the browser
263
+ self.path = str(data.get('path') or '/')
264
+ self.events.append(pygame.event.Event(URLCHANGED, path=self.path))
265
+ elif t in ('mousedown', 'mouseup'):
266
+ b = data.get('button', 0)
267
+ if b in (0, 1, 2):
268
+ down = t == 'mousedown'
269
+ self.pressed[b] = down
270
+ self.events.append(pygame.event.Event(
271
+ pygame.MOUSEBUTTONDOWN if down else pygame.MOUSEBUTTONUP,
272
+ button=b + 1, pos=self.pos))
273
+ elif t == 'scroll':
274
+ btn = 5 if data.get('delta', 0) > 0 else 4
275
+ for typ in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP):
276
+ self.events.append(pygame.event.Event(typ, button=btn, pos=self.pos))
277
+ elif t in ('keydown', 'keyup'):
278
+ k = KEY_MAP.get(data.get('code', ''))
279
+ if k is not None:
280
+ down = t == 'keydown'
281
+ self.keys[k] = down
282
+ if down:
283
+ ch = data.get('key', '')
284
+ self.events.append(pygame.event.Event(
285
+ pygame.KEYDOWN, key=k, unicode=ch if len(ch) == 1 else ''))
286
+ else:
287
+ self.events.append(pygame.event.Event(pygame.KEYUP, key=k))
288
+
289
+ def drain(self):
290
+ ev, self.events = self.events, []
291
+ return ev
292
+
293
+
294
+ # =========================================================================
295
+ # Pygame patching. Two ways to use webpy:
296
+ # * SCRIPT mode (default): a normal pygame loop that calls display.flip().
297
+ # One shared session; every open tab shows (and controls) the same site.
298
+ # * CLASS mode (start_server(MyClass)): one isolated instance per tab.
299
+ # =========================================================================
300
+ _shared = TabInput() # the input state used in script mode
301
+ _shared.script_mode = True
302
+ _current = _shared # whichever input state the running code should see
303
+ _class_mode = False
304
+
305
+ class _Real: # the genuine pygame functions, kept for script mode
306
+ set_mode = staticmethod(pygame.display.set_mode)
307
+ get_surface = staticmethod(pygame.display.get_surface)
308
+ set_caption = staticmethod(pygame.display.set_caption)
309
+
310
+ pygame.mouse.get_pos = lambda: _current.pos
311
+ pygame.mouse.get_pressed = lambda *a, **k: tuple(_current.pressed)
312
+ pygame.key.get_pressed = lambda: _current.keys
313
+
314
+
315
+ # ---- script-mode server (runs in a background thread) --------------------
316
+ class _Hub:
317
+ def __init__(self):
318
+ self.loop = None
319
+ self.clients = set() # one asyncio.Event per connected tab
320
+ self.frame = None # latest JPEG bytes
321
+ self.title = "WebPy Viewport"
322
+ self.url = None
323
+ self.started = False
324
+ self.ready = threading.Event()
325
+ self.got_size = threading.Event() # set once a browser reported its size
326
+ self.last_flip = 0.0
327
+ self.ever_connected = False
328
+
329
+ hub = _Hub()
330
+
331
+
332
+ def _wake():
333
+ if hub.loop:
334
+ hub.loop.call_soon_threadsafe(lambda: [e.set() for e in list(hub.clients)])
335
+
336
+
337
+ async def _script_handler(ws):
338
+ print("Tab connected")
339
+ hub.ever_connected = True
340
+ ev = asyncio.Event()
341
+ ev.set() # send the current frame straight away
342
+ hub.clients.add(ev)
343
+
344
+ async def reader():
345
+ async for msg in ws:
346
+ try:
347
+ _shared.feed(json.loads(msg))
348
+ if _shared.window_size[0] > 0:
349
+ hub.got_size.set()
350
+ except Exception as e:
351
+ print("Bad input message:", e)
352
+
353
+ rt = asyncio.create_task(reader())
354
+ sent_title = sent_frame = sent_url = None
355
+ try:
356
+ while not rt.done():
357
+ try:
358
+ await asyncio.wait_for(ev.wait(), 0.5)
359
+ except asyncio.TimeoutError:
360
+ continue
361
+ ev.clear()
362
+ if hub.title != sent_title:
363
+ sent_title = hub.title
364
+ await ws.send(json.dumps({"type": "title", "title": sent_title}))
365
+ if hub.url is not None and hub.url != sent_url:
366
+ sent_url = hub.url
367
+ await ws.send(json.dumps({"type": "url", "url": sent_url}))
368
+ frame = hub.frame
369
+ if frame is not None and frame is not sent_frame:
370
+ sent_frame = frame
371
+ await ws.send(frame)
372
+ except websockets.exceptions.ConnectionClosed:
373
+ pass
374
+ finally:
375
+ rt.cancel()
376
+ hub.clients.discard(ev)
377
+ print("Tab closed")
378
+ if _CHILD_PORT is not None: # this process only exists for that one tab
379
+ sys.stdout.flush()
380
+ os._exit(0)
381
+
382
+
383
+ # ---- separate-tabs master: serves no site itself, just starts one process per tab ----
384
+ def _free_port():
385
+ with socket.socket() as sock:
386
+ sock.bind(("localhost", 0))
387
+ return sock.getsockname()[1]
388
+
389
+
390
+ def _spawn_child(port):
391
+ env = dict(os.environ, WEBPY_CHILD_PORT=str(port),
392
+ WEBPY_NEW_TERMINAL="1" if settings.new_terminal else "",
393
+ WEBPY_PUBLIC_URL=_tunnel["url"] or "")
394
+ kw = {}
395
+ if os.name == "nt" and settings.new_terminal:
396
+ kw["creationflags"] = subprocess.CREATE_NEW_CONSOLE # a fresh terminal window
397
+ return subprocess.Popen([sys.executable] + sys.argv, env=env, **kw)
398
+
399
+
400
+ async def _connect_local(port):
401
+ url_ = f"ws://127.0.0.1:{port}"
402
+ try:
403
+ return await _ws_connect(url_, max_size=None, proxy=None) # never go via a proxy
404
+ except TypeError: # older websockets
405
+ return await _ws_connect(url_, max_size=None)
406
+
407
+
408
+ def _process_request(connection, request):
409
+ """Same port serves the web page (any path, so /about works) and the websocket."""
410
+ path = request.path.split("?")[0]
411
+ if path == WS_PATH:
412
+ return None # carry on with the websocket handshake
413
+ if path.startswith("/favicon"):
414
+ return connection.respond(HTTPStatus.NO_CONTENT, "")
415
+ resp = connection.respond(HTTPStatus.OK, HTML_TEMPLATE.replace("__PORT__", str(settings.port)).replace("__WSPATH__", WS_PATH))
416
+ del resp.headers["Content-Type"]
417
+ resp.headers["Content-Type"] = "text/html; charset=utf-8"
418
+ resp.headers["Cache-Control"] = "no-store"
419
+ return resp
420
+
421
+
422
+ def _serve(handler):
423
+ return _ws_serve(handler, _bind_host(), settings.port,
424
+ process_request=_process_request, max_size=None)
425
+
426
+
427
+ def _announce():
428
+ """Open the browser here and print the addresses for other computers."""
429
+ webbrowser.open(f"http://localhost:{settings.port}")
430
+ if settings.share:
431
+ print(f"-> On another computer on your network, open: {url()}")
432
+ print(" (if Windows asks, allow Python through the firewall on private networks)")
433
+ if settings.public:
434
+ public()
435
+
436
+
437
+ def _run_master():
438
+ """Never returns. Each browser tab that connects gets its own copy of your script."""
439
+ children = []
440
+
441
+ def cleanup():
442
+ for p in children:
443
+ if p.poll() is None:
444
+ p.terminate()
445
+ atexit.register(cleanup)
446
+
447
+ async def handler(ws):
448
+ if len([p for p in children if p.poll() is None]) >= settings.max_tabs:
449
+ await ws.send(json.dumps({"type": "busy"}))
450
+ return
451
+ port = _free_port()
452
+ children.append(_spawn_child(port))
453
+ print(f"Tab connected -> started a new instance (#{len(children)})")
454
+ up = None
455
+ for _ in range(150): # the new copy takes a moment to start
456
+ try:
457
+ up = await _connect_local(port)
458
+ break
459
+ except OSError:
460
+ await asyncio.sleep(0.2)
461
+ if up is None:
462
+ return
463
+
464
+ async def pipe(src, dst):
465
+ try:
466
+ async for msg in src:
467
+ await dst.send(msg)
468
+ except websockets.exceptions.ConnectionClosed:
469
+ pass
470
+
471
+ tasks = [asyncio.create_task(pipe(ws, up)), asyncio.create_task(pipe(up, ws))]
472
+ await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
473
+ for t in tasks:
474
+ t.cancel()
475
+ await up.close()
476
+
477
+ async def main():
478
+ async with _serve(handler):
479
+ _announce()
480
+ print("-> webpy running; every tab you open starts its own copy of this script "
481
+ "(Ctrl+C to stop)")
482
+ await asyncio.Future()
483
+
484
+ try:
485
+ asyncio.run(main())
486
+ except KeyboardInterrupt:
487
+ pass
488
+ except OSError as e:
489
+ print(f"webpy: could not start server on port {settings.port}: {e}")
490
+ sys.exit(0)
491
+
492
+
493
+ def _ensure_server():
494
+ if hub.started:
495
+ return
496
+ hub.started = True
497
+ if _CHILD_PORT is None and settings.separate_tabs:
498
+ _run_master() # never returns
499
+ child = _CHILD_PORT is not None
500
+ pygame.init()
501
+ if child:
502
+ import traceback
503
+
504
+ def hook(t, v, tb): # keep the window open so you can read the error
505
+ traceback.print_exception(t, v, tb)
506
+ print("\nwebpy: this tab's copy of your site crashed (other tabs are unaffected).")
507
+ if os.environ.get("WEBPY_NEW_TERMINAL"): # own window: keep it open to read
508
+ try:
509
+ input("Press Enter to close this window...")
510
+ except EOFError:
511
+ pass
512
+ sys.stdout.flush()
513
+ os._exit(1)
514
+ sys.excepthook = hook
515
+ threading.Timer(30, lambda: None if hub.ever_connected else os._exit(0)).start()
516
+
517
+ def run():
518
+ async def main():
519
+ hub.loop = asyncio.get_running_loop()
520
+ if child: # only the launcher talks to us, from this same computer
521
+ server = _ws_serve(_script_handler, "127.0.0.1", int(_CHILD_PORT), max_size=None)
522
+ else:
523
+ server = _serve(_script_handler)
524
+ async with server:
525
+ hub.ready.set()
526
+ await asyncio.Future()
527
+ try:
528
+ asyncio.run(main())
529
+ except OSError as e:
530
+ print(f"webpy: could not start server on port {settings.port}: {e}")
531
+ hub.ready.set()
532
+
533
+ threading.Thread(target=run, daemon=True).start()
534
+ hub.ready.wait(5)
535
+ if not child:
536
+ print("-> webpy running; open as many tabs as you like (Ctrl+C to stop)")
537
+ _announce()
538
+
539
+
540
+ def _apply_pending_resize():
541
+ """Main thread only: follow the browser window for RESIZABLE sites."""
542
+ p, _shared.pending_resize = _shared.pending_resize, None
543
+ if p and _Real.get_surface() is not None and p != _Real.get_surface().get_size():
544
+ _Real.set_mode(p)
545
+ _shared.events.append(pygame.event.Event(pygame.VIDEORESIZE, size=p, w=p[0], h=p[1]))
546
+
547
+
548
+ def _script_flip():
549
+ _apply_pending_resize()
550
+ surf = _Real.get_surface()
551
+ if surf is None:
552
+ return
553
+ # frame limiter (also stops a busy loop from spinning the CPU)
554
+ rest = 1 / settings.fps - (time.perf_counter() - hub.last_flip)
555
+ if rest > 0:
556
+ time.sleep(rest)
557
+ hub.last_flip = time.perf_counter()
558
+ buf = io.BytesIO()
559
+ pygame.image.save(surf, buf, "frame.jpg")
560
+ frame = buf.getvalue()
561
+ if frame != hub.frame:
562
+ hub.frame = frame
563
+ _wake()
564
+
565
+
566
+ # ---- display.* replacements ----------------------------------------------
567
+ def _set_mode(size=(0, 0), flags=0, *args, **kwargs):
568
+ """Replacement for pygame.display.set_mode."""
569
+ w, h = size
570
+ resizable = bool(flags & pygame.RESIZABLE)
571
+
572
+ if _class_mode: # per-tab canvas
573
+ t = _current
574
+ t.resizable = resizable
575
+ ww, wh = t.window_size
576
+ if resizable and ww > 0 and wh > 0:
577
+ w, h = ww, wh # start filled to the browser window
578
+ elif not w or not h: # (0, 0) means "use the window size"
579
+ w, h = (ww, wh) if (ww > 0 and wh > 0) else t.surface.get_size()
580
+ if (w, h) != t.surface.get_size():
581
+ t.surface = pygame.Surface((int(w), int(h)))
582
+ return t.surface
583
+
584
+ _ensure_server()
585
+ _shared.resizable = resizable
586
+ if resizable or not w or not h: # need the browser's window size
587
+ hub.got_size.wait(15)
588
+ ww, wh = _shared.window_size
589
+ if (resizable or not w or not h) and ww > 0 and wh > 0:
590
+ w, h = ww, wh
591
+ if not w or not h:
592
+ w, h = 800, 600
593
+ return _Real.set_mode((int(w), int(h)))
594
+
595
+
596
+ def _set_caption(title, icontitle=None):
597
+ if _class_mode:
598
+ _current.caption = str(title)
599
+ else:
600
+ hub.title = str(title)
601
+ _Real.set_caption(str(title))
602
+ _wake()
603
+
604
+ def _clean_url(path):
605
+ path = str(path).strip() or "/"
606
+ if not path.startswith("/"):
607
+ path = "/" + path
608
+ return "/" + path.lstrip("/") # never "//host" (that would be another site)
609
+
610
+
611
+ def _set_url(path):
612
+ """Change the address bar to this path, e.g. set_url("/about"), without reloading."""
613
+ if "://" in str(path):
614
+ print("webpy: set_url() only takes a path like '/about'. A browser can't switch a page "
615
+ "to a different domain without reloading, so full URLs are ignored.")
616
+ return
617
+ path = _clean_url(path)
618
+ if _class_mode:
619
+ _current.path = path
620
+ _current.url = path
621
+ elif _shared.path != path or hub.url != path:
622
+ _shared.path = path
623
+ hub.url = path
624
+ _wake()
625
+
626
+ def _get_url():
627
+ """The URL path the browser is at, e.g. "/about" (so a site can open on the right page)."""
628
+ return _current.path
629
+
630
+ def _get_surface():
631
+ return _current.surface if _class_mode else _Real.get_surface()
632
+
633
+ def _get_size():
634
+ s = _get_surface()
635
+ return s.get_size() if s is not None else (0, 0)
636
+
637
+ def _flip():
638
+ if not _class_mode:
639
+ _script_flip() # class mode: frames are streamed automatically
640
+
641
+ pygame.display.set_mode = _set_mode
642
+ pygame.display.get_surface = _get_surface
643
+ pygame.display.set_caption = _set_caption
644
+ pygame.display.set_url = _set_url
645
+ pygame.display.get_url = _get_url
646
+ pygame.display.get_caption = lambda: ((_current.caption if _class_mode else hub.title),) * 2
647
+ pygame.display.get_window_size = _get_size
648
+ pygame.display.flip = _flip
649
+ pygame.display.update = lambda *a, **k: _flip()
650
+
651
+
652
+ # ---- event.* replacements --------------------------------------------------
653
+ def _event_get(*a, **k):
654
+ _apply_pending_resize()
655
+ return _current.drain()
656
+
657
+ def _event_poll():
658
+ _apply_pending_resize()
659
+ t = _current
660
+ return t.events.pop(0) if t.events else pygame.event.Event(pygame.NOEVENT)
661
+
662
+ def _event_wait(*a, **k):
663
+ while True:
664
+ _apply_pending_resize()
665
+ if _current.events:
666
+ return _current.events.pop(0)
667
+ time.sleep(0.005)
668
+
669
+ pygame.event.get = _event_get
670
+ pygame.event.poll = _event_poll
671
+ pygame.event.wait = _event_wait
672
+ pygame.event.clear = lambda *a, **k: _current.drain() and None
673
+
674
+
675
+ def get_window_size():
676
+ """Size (width, height) in pixels of THIS tab's browser window (the viewport).
677
+ Call it from inside update(). Returns (0, 0) for the first frame or so,
678
+ until the browser has reported its size."""
679
+ return _current.window_size
680
+
681
+
682
+ HTML_TEMPLATE = """<!DOCTYPE html>
683
+ <html lang="en">
684
+ <head>
685
+ <meta charset="UTF-8">
686
+ <title>WebPy Viewport</title>
687
+ <style>
688
+ body { background:#111; margin:0; display:flex; justify-content:center; align-items:center; height:100vh; overflow:hidden; }
689
+ #v { display:block; max-width:100vw; max-height:100vh; cursor:default; user-select:none; -webkit-user-drag:none; outline:none; }
690
+ </style>
691
+ </head>
692
+ <body>
693
+ <img id="v" draggable="false">
694
+ <script>
695
+ const img = document.getElementById('v');
696
+ let ws;
697
+ function curPath() { return location.pathname + location.search; }
698
+ function sendSize() { send({type:'resize', w:window.innerWidth, h:window.innerHeight, path:curPath()}); }
699
+ window.addEventListener('popstate', () => send({type:'path', path:curPath()}));
700
+ window.addEventListener('resize', sendSize);
701
+ function send(o) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(o)); }
702
+
703
+ function overlay(text) {
704
+ const d = document.createElement('div');
705
+ d.textContent = text;
706
+ d.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;color:#ccc;font:20px sans-serif;background:#000a;cursor:pointer;text-align:center;padding:20px';
707
+ d.onclick = () => location.reload();
708
+ document.body.appendChild(d);
709
+ }
710
+
711
+ function connect() {
712
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
713
+ const host = location.host || 'localhost:__PORT__'; // (opened as a file: local only)
714
+ const sock = new WebSocket(proto + '://' + host + '__WSPATH__');
715
+ let opened = false, busy = false;
716
+ ws = sock;
717
+ sock.onopen = () => { opened = true; sendSize(); };
718
+ sock.onmessage = e => {
719
+ if (typeof e.data === 'string') {
720
+ const m = JSON.parse(e.data);
721
+ if (m.type === 'title') document.title = m.title;
722
+ else if (m.type === 'url') { try { if (m.url !== curPath()) history.pushState(null, '', m.url); } catch (err) {} }
723
+ else if (m.type === 'busy') busy = true;
724
+ } else if (e.data instanceof Blob) {
725
+ const url = URL.createObjectURL(e.data);
726
+ img.onload = () => URL.revokeObjectURL(url);
727
+ img.src = url;
728
+ }
729
+ };
730
+ sock.onclose = () => {
731
+ if (busy) { document.title = 'Busy'; overlay('Too many visitors right now - click to try again'); }
732
+ else if (opened) { document.title = 'Session ended'; overlay('Session ended - click to start a new one'); }
733
+ else setTimeout(connect, 1000); // server not up yet: keep trying
734
+ };
735
+ }
736
+ connect();
737
+
738
+ function coords(e) {
739
+ const r = img.getBoundingClientRect();
740
+ const nw = img.naturalWidth || r.width, nh = img.naturalHeight || r.height; // canvas size
741
+ return { x: Math.round((e.clientX - r.left) * nw / r.width),
742
+ y: Math.round((e.clientY - r.top) * nh / r.height) };
743
+ }
744
+ img.addEventListener('mousemove', e => send({type:'move', ...coords(e)}));
745
+ img.addEventListener('mousedown', e => send({type:'mousedown', ...coords(e), button:e.button}));
746
+ window.addEventListener('mouseup', e => send({type:'mouseup', ...coords(e), button:e.button}));
747
+ img.addEventListener('contextmenu', e => e.preventDefault());
748
+ img.addEventListener('wheel', e => { e.preventDefault(); send({type:'scroll', delta: e.deltaY > 0 ? 1 : -1}); }, {passive:false});
749
+ window.addEventListener('keydown', e => {
750
+ if (["Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Tab"].includes(e.code)) e.preventDefault();
751
+ if (!e.repeat) send({type:'keydown', code:e.code, key:e.key});
752
+ });
753
+ window.addEventListener('keyup', e => send({type:'keyup', code:e.code, key:e.key}));
754
+ </script>
755
+ </body>
756
+ </html>
757
+ """
758
+
759
+
760
+ def _write_html():
761
+ # Always regenerate so template changes aren't ignored by a stale file.
762
+ html = HTML_TEMPLATE.replace("__PORT__", str(settings.port)).replace("__WSPATH__", WS_PATH)
763
+ with open("index.html", "w", encoding="utf-8") as f:
764
+ f.write(html)
765
+
766
+
767
+ def start_server(game_class, width=800, height=600):
768
+ """CLASS mode: pass your website CLASS (not an instance). Each browser tab gets its own
769
+ private instance. (For a plain pygame loop, skip this and just use display.flip().)"""
770
+ pygame.init()
771
+
772
+ global _class_mode
773
+ _class_mode = True
774
+ _write_html()
775
+
776
+ async def handler(websocket):
777
+ global _current
778
+ print("Tab connected")
779
+ inp = TabInput((width, height)) # private canvas per tab
780
+ reader_task = None
781
+ try:
782
+ # The page reports its window size as soon as it connects. Wait for that so
783
+ # get_window_size() / set_mode((0,0)) already work inside your __init__.
784
+ try:
785
+ inp.feed(json.loads(await asyncio.wait_for(websocket.recv(), 2)))
786
+ except asyncio.TimeoutError:
787
+ pass
788
+
789
+ _current = inp # no await until update() finishes: safe
790
+ instance = game_class()
791
+
792
+ async def reader():
793
+ async for msg in websocket:
794
+ try:
795
+ inp.feed(json.loads(msg))
796
+ except Exception as e:
797
+ print("Bad input message:", e)
798
+
799
+ reader_task = asyncio.create_task(reader())
800
+ last = None
801
+ last_caption = None
802
+ last_url = None
803
+ while not reader_task.done():
804
+ t0 = time.perf_counter()
805
+
806
+ _current = inp
807
+ instance.update(inp.surface) # always the tab's CURRENT surface
808
+
809
+ if inp.caption != last_caption: # set_caption() -> browser tab title
810
+ await websocket.send(json.dumps({"type": "title", "title": inp.caption}))
811
+ last_caption = inp.caption
812
+
813
+ if inp.url is not None and inp.url != last_url: # set_url() -> address bar
814
+ await websocket.send(json.dumps({"type": "url", "url": inp.url}))
815
+ last_url = inp.url
816
+
817
+ buf = io.BytesIO()
818
+ pygame.image.save(inp.surface, buf, "frame.jpg")
819
+ frame = buf.getvalue()
820
+ if frame != last: # don't resend identical frames
821
+ await websocket.send(frame)
822
+ last = frame
823
+
824
+ # non-blocking frame limiter (clock.tick() would freeze every tab)
825
+ await asyncio.sleep(max(0.0, 1 / settings.fps - (time.perf_counter() - t0)))
826
+ except websockets.exceptions.ConnectionClosed:
827
+ pass
828
+ except SystemExit: # the site called sys.exit(): close just this tab
829
+ pass
830
+ except Exception:
831
+ import traceback; traceback.print_exc() # surface errors from user code
832
+ finally:
833
+ if reader_task:
834
+ reader_task.cancel()
835
+ print("Tab closed")
836
+
837
+ async def main():
838
+ async with _serve(handler):
839
+ # open the page only once the server is actually listening
840
+ _announce()
841
+ print("-> webpy running; open as many tabs as you like (Ctrl+C to stop)")
842
+ await asyncio.Future()
843
+
844
+ try:
845
+ asyncio.run(main())
846
+ except KeyboardInterrupt:
847
+ pass
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: webpygame
3
+ Version: 1.0.0
4
+ Summary: Stream Pygame windows directly to an HTML browser viewport.
5
+ Author: Brodie
6
+ License-Expression: MIT
7
+ Keywords: pygame,website,browser,websocket
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Topic :: Software Development :: Libraries
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pygame>=2.0.0
15
+ Requires-Dist: websockets>=14.0
16
+ Dynamic: license-file
17
+
18
+ # webpy
19
+
20
+ Make a website with plain pygame. `webpy` streams your pygame window to any browser tab.
21
+
22
+ ```
23
+ pip install webpygame
24
+ ```
25
+
26
+ ```python
27
+ import webpy
28
+
29
+ screen = webpy.display.set_mode((800, 500))
30
+ clicks = 0
31
+ button = webpy.Rect(300, 220, 200, 60)
32
+
33
+ while True:
34
+ for event in webpy.event.get():
35
+ if event.type == webpy.MOUSEBUTTONDOWN and button.collidepoint(event.pos):
36
+ clicks += 1
37
+ screen.fill((30, 34, 48))
38
+ webpy.draw.rect(screen, (90, 94, 235), button, border_radius=12)
39
+ webpy.display.flip() # sends the frame to the browser
40
+ ```
41
+
42
+ Run it, and your browser opens. Every browser tab gets its own copy of your site.
43
+
44
+ * `webpy.display.url()`: the address for other computers on your network
45
+ * `webpy.display.public()`: a public link that works from anywhere (uses Cloudflare's free `cloudflared`)
46
+ * `webpy.display.set_url("/about")`: change the address bar
47
+ * `webpy.settings`: change defaults (`public`, `share`, `separate_tabs`, `max_tabs`, `fps`, ...)
48
+
49
+ Everything from pygame is available as `webpy.something` too.
@@ -0,0 +1,7 @@
1
+ webpy/__init__.py,sha256=9wKjxMDV9WmtpeniwkYgxv9s7aCgpzYc6ydkW-QOmJE,365
2
+ webpy/engine.py,sha256=upL1n8p4B9CihmZtsrNuF2eUTuLKKU31zUM1aJI6uxs,34097
3
+ webpygame-1.0.0.dist-info/licenses/LICENSE,sha256=NQAtkcreb1gF8aut5NvFnyPHstT0Fr8XObwXw0TyGjk,1063
4
+ webpygame-1.0.0.dist-info/METADATA,sha256=cep4t2galNNTpHAs7OfvqsRBD72FEsRu7r8schFjqFQ,1625
5
+ webpygame-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ webpygame-1.0.0.dist-info/top_level.txt,sha256=XrauGW3ycRhXQxIFTJM_J3JaoK53vtAHDC3pgvKOuLY,6
7
+ webpygame-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brodie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ webpy