webpygame 1.0.0__tar.gz
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.
- webpygame-1.0.0/LICENSE +21 -0
- webpygame-1.0.0/PKG-INFO +49 -0
- webpygame-1.0.0/README.md +32 -0
- webpygame-1.0.0/pyproject.toml +26 -0
- webpygame-1.0.0/setup.cfg +4 -0
- webpygame-1.0.0/webpy/__init__.py +7 -0
- webpygame-1.0.0/webpy/engine.py +847 -0
- webpygame-1.0.0/webpygame.egg-info/PKG-INFO +49 -0
- webpygame-1.0.0/webpygame.egg-info/SOURCES.txt +10 -0
- webpygame-1.0.0/webpygame.egg-info/dependency_links.txt +1 -0
- webpygame-1.0.0/webpygame.egg-info/requires.txt +2 -0
- webpygame-1.0.0/webpygame.egg-info/top_level.txt +1 -0
webpygame-1.0.0/LICENSE
ADDED
|
@@ -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.
|
webpygame-1.0.0/PKG-INFO
ADDED
|
@@ -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,32 @@
|
|
|
1
|
+
# webpy
|
|
2
|
+
|
|
3
|
+
Make a website with plain pygame. `webpy` streams your pygame window to any browser tab.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install webpygame
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import webpy
|
|
11
|
+
|
|
12
|
+
screen = webpy.display.set_mode((800, 500))
|
|
13
|
+
clicks = 0
|
|
14
|
+
button = webpy.Rect(300, 220, 200, 60)
|
|
15
|
+
|
|
16
|
+
while True:
|
|
17
|
+
for event in webpy.event.get():
|
|
18
|
+
if event.type == webpy.MOUSEBUTTONDOWN and button.collidepoint(event.pos):
|
|
19
|
+
clicks += 1
|
|
20
|
+
screen.fill((30, 34, 48))
|
|
21
|
+
webpy.draw.rect(screen, (90, 94, 235), button, border_radius=12)
|
|
22
|
+
webpy.display.flip() # sends the frame to the browser
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run it, and your browser opens. Every browser tab gets its own copy of your site.
|
|
26
|
+
|
|
27
|
+
* `webpy.display.url()`: the address for other computers on your network
|
|
28
|
+
* `webpy.display.public()`: a public link that works from anywhere (uses Cloudflare's free `cloudflared`)
|
|
29
|
+
* `webpy.display.set_url("/about")`: change the address bar
|
|
30
|
+
* `webpy.settings`: change defaults (`public`, `share`, `separate_tabs`, `max_tabs`, `fps`, ...)
|
|
31
|
+
|
|
32
|
+
Everything from pygame is available as `webpy.something` too.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "webpygame"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Stream Pygame windows directly to an HTML browser viewport."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{ name = "Brodie" }]
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pygame>=2.0.0",
|
|
16
|
+
"websockets>=14.0",
|
|
17
|
+
]
|
|
18
|
+
keywords = ["pygame", "website", "browser", "websocket"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
packages = ["webpy"]
|
|
@@ -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 *
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
webpy
|