Skip to content

Commit 67cd854

Browse files
bajrangCoderclaude
andcommitted
feat: run blocking pygame loops and render plots for the console
pygame: - Ordinary blocking game loops now use SDL mode: with JSPI, the calls a game loop makes (display.flip/update, Clock.tick, time.wait/delay, event.wait) pause via run_sync so the browser can draw and Acode stays responsive (python/acode_pygame.py) - Check JSPI inside a task before running a blocking loop; without it, fall back with an explanation instead of freezing Acode - Stop blocking loops through a flag checked at those pauses, since they never reach an await; stopped runs no longer print into the next console - Explain the worker's "dummy not available" error Images: - Default matplotlib figure size to the console width (via the matplotlibrc read on first import), so text stays readable on phones - Render at the device pixel ratio and display at true size, so plots are sharp on HiDPI screens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c666559 commit 67cd854

11 files changed

Lines changed: 291 additions & 68 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,29 +29,26 @@ await micropip.install("snowballstemmer")
2929

3030
### Plots and images
3131

32-
`matplotlib` figures are shown in the console when you call `plt.show()`, and any figures left open are shown when the program ends. Pillow's `Image.show()` works too.
32+
`matplotlib` figures are shown in the console when you call `plt.show()`, and any figures left open are shown when the program ends. Figures default to the width of the console and are rendered at the screen's pixel density. Pillow's `Image.show()` works too.
3333

3434
### pygame (experimental)
3535

36-
pygame programs can draw to a canvas in the console using Pyodide's [SDL support](https://pyodide.org/en/stable/usage/sdl.html). The game loop must be async so the app stays responsive:
36+
pygame programs can draw to a canvas in the console using Pyodide's [SDL support](https://pyodide.org/en/stable/usage/sdl.html). Ordinary game loops work as written:
3737

3838
```python
39-
import asyncio
4039
import pygame
4140

4241
pygame.init()
43-
screen = pygame.display.set_mode((320, 240))
42+
screen = pygame.display.set_mode((1280, 720))
43+
clock = pygame.time.Clock()
4444

45-
async def main():
46-
while True:
47-
screen.fill((30, 30, 60))
48-
pygame.display.flip()
49-
await asyncio.sleep(1 / 60)
50-
51-
await main()
45+
while True:
46+
screen.fill("purple")
47+
pygame.display.flip()
48+
clock.tick(60)
5249
```
5350

54-
If SDL cannot start, the program runs without a display. Running again or closing the console stops the game.
51+
This needs a WebView with JSPI support (Android System WebView 137+), which lets the loop pause at `display.flip()` / `Clock.tick()` so the app stays responsive. On older WebViews, use an async loop (`await asyncio.sleep(0)` each frame) instead. If SDL cannot start, the program runs without a display. Running again or closing the console stops the game.
5552

5653
## Development
5754

‎changelog.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
- Console output can now be selected and copied
99
- Header button to toggle line wrap in the console (remembered between sessions)
1010
- Thin progress line under the console header while Python loads or code is running
11-
- matplotlib plots are shown in the console (`plt.show()`, or automatically at the end of a run), as are Pillow `Image.show()` images
12-
- Experimental SDL support: pygame programs with an async game loop draw to a canvas in the console, falling back to running without a display if SDL cannot start
11+
- matplotlib plots are shown in the console (`plt.show()`, or automatically at the end of a run), as are Pillow `Image.show()` images. Plots are sized to the console and rendered sharp on high-density screens
12+
- Experimental SDL support: pygame programs draw to a canvas in the console, including ordinary blocking game loops on WebViews with JSPI. Falls back to running without a display if SDL cannot start
1313
- Python crashes no longer leave the console stuck; Python restarts on the next run
1414
- Fixed Python failing to load (or staying on "loading...") on some devices
1515
- Clear error message when Python fails to load, with automatic retry on the next run

‎plugin.zip‎

9.99 KB
Binary file not shown.

‎src/main.js‎

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@ const WRAP_KEY = `${plugin.id}.wrap`;
66
const WRAP_ICON =
77
'<svg viewBox="0 0 24 24" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M4 19h6v-2H4v2zM20 5H4v2h16V5zm-3 6H4v2h13.25c1.1 0 2 .9 2 2s-.9 2-2 2H15v-2l-3 3 3 3v-2h2c2.21 0 4-1.79 4-4s-1.79-4-4-4z"/></svg>';
88

9-
// pygame programs use the experimental SDL runtime on the main thread, which
10-
// is only safe when their game loop yields with `await`
9+
// pygame programs use the experimental SDL runtime on the main thread
1110
const PYGAME_IMPORT = /^\s*(?:import|from)\s+pygame\b/m;
12-
const AWAIT = /\bawait\b/;
1311

1412
class Python {
1513
#worker;
@@ -163,16 +161,7 @@ class Python {
163161
await this.#cacheFile.writeFile("");
164162

165163
const code = editorManager.editor.getValue();
166-
if (PYGAME_IMPORT.test(code)) {
167-
if (!AWAIT.test(code)) {
168-
this.print(
169-
"Tip: pygame needs an async game loop (await asyncio.sleep(0) each frame) to show its window with the experimental SDL support.",
170-
"info",
171-
);
172-
} else if (await this.#runWithSdl(code)) {
173-
return;
174-
}
175-
}
164+
if (PYGAME_IMPORT.test(code) && (await this.#runWithSdl(code))) return;
176165
await this.runCode(code);
177166
}
178167

@@ -194,7 +183,8 @@ class Python {
194183
canvas: $canvas,
195184
stdout: (text) => this.print(text),
196185
stderr: (text) => this.print(text, "error"),
197-
showImage: (data) => this.#printImage(data),
186+
showImage: (data, scale) => this.#printImage(data, scale),
187+
...this.#displaySize(),
198188
});
199189
if (error) this.print(error, "error");
200190
return true;
@@ -239,6 +229,7 @@ class Python {
239229
this.#worker.postMessage({
240230
action: "run",
241231
code,
232+
...this.#displaySize(),
242233
});
243234
const res = await new Promise((resolve, error) => {
244235
this.#onRunSuccess = resolve;
@@ -306,9 +297,30 @@ class Python {
306297
this.#append($output, this.$input);
307298
}
308299

309-
#printImage(data) {
300+
/** Console width (CSS px) and pixel ratio, so figures fit and stay sharp */
301+
#displaySize() {
302+
const $main = this.$page.get(".main");
303+
const style = $main && getComputedStyle($main);
304+
const padding = style
305+
? Number.parseFloat(style.paddingLeft) +
306+
Number.parseFloat(style.paddingRight)
307+
: 0;
308+
return {
309+
width: Math.max(0, ($main?.clientWidth ?? 0) - padding),
310+
pixelRatio: window.devicePixelRatio || 1,
311+
};
312+
}
313+
314+
/**
315+
* Shows a PNG rendered at `scale` times its display size at its true
316+
* size, so it is sharp on HiDPI screens instead of upscaled.
317+
*/
318+
#printImage(data, scale = 1) {
310319
if (!this.$page.isConnected) return;
311320
const $img = tag("img", { src: `data:image/png;base64,${data}` });
321+
$img.onload = () => {
322+
$img.style.width = `${$img.naturalWidth / scale}px`;
323+
};
312324
this.#append(
313325
tag("div", { className: "py-media", children: [$img] }),
314326
this.$input,
@@ -368,7 +380,7 @@ class Python {
368380
break;
369381

370382
case "image":
371-
this.#printImage(e.data.data);
383+
this.#printImage(e.data.data, e.data.scale);
372384
break;
373385

374386
case "fatal":

‎src/python/acode_display.py‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,63 @@
11
"""Shows images from Python code in the Acode console.
22
33
Installed into site-packages by the plugin. ``show_image`` is provided by the
4-
plugin as the ``_acode`` JS module and receives base64 encoded PNG data.
4+
plugin as the ``_acode`` JS module and receives base64 encoded PNG data plus
5+
the scale it was rendered at.
56
"""
67

78
import base64
89
import io
10+
import os
911
import sys
1012

1113
from _acode import show_image
1214

1315
_pil_ready = False
1416

17+
# device pixel ratio of the console, figures are rendered at this scale
18+
scale = 1.0
1519

16-
def show_png(data):
17-
show_image(base64.b64encode(data).decode("ascii"))
1820

21+
def show_png(data, image_scale=1.0):
22+
show_image(base64.b64encode(data).decode("ascii"), image_scale)
1923

20-
def prepare(code):
21-
"""Called before running ``code``, after its packages are loaded."""
24+
25+
def prepare(code, width=0, pixel_ratio=1):
26+
"""Called before running ``code``, after its packages are loaded.
27+
28+
``width`` is the console width in CSS pixels, ``pixel_ratio`` the
29+
device pixel ratio.
30+
"""
2231
from pyodide.code import find_imports
2332

33+
global scale
34+
scale = max(1.0, min(float(pixel_ratio or 1), 3.0))
35+
if width:
36+
_fit_figures(width)
37+
2438
if "PIL" in find_imports(code):
2539
setup_pil()
2640

2741

42+
def _fit_figures(width):
43+
"""Default matplotlib figures to the console width, so their text stays
44+
readable on a phone instead of a 6.4in figure being shrunk to fit.
45+
46+
Written as the matplotlibrc read when matplotlib is first imported (also
47+
when imported indirectly, e.g. by pandas), so settings changed later by
48+
the program are left alone.
49+
"""
50+
if "matplotlib" in sys.modules:
51+
return
52+
rc_file = os.environ.get("MATPLOTLIBRC")
53+
if not rc_file:
54+
return
55+
inches = max(3.0, min(width / 100, 6.4))
56+
with open(rc_file, "w") as rc:
57+
rc.write(f"figure.figsize: {inches:.2f}, {inches * 0.75:.2f}\n")
58+
rc.write("figure.dpi: 100\n")
59+
60+
2861
def setup_pil():
2962
"""Make ``Image.show()`` display the image in the console."""
3063
global _pil_ready

‎src/python/acode_mpl_backend.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@
77
from matplotlib._pylab_helpers import Gcf
88
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
99

10-
from acode_display import show_png
10+
import acode_display
1111

1212
__all__ = ["FigureCanvas", "show"]
1313

1414

1515
def show(*args, **kwargs):
16+
scale = acode_display.scale
1617
for manager in Gcf.get_all_fig_managers():
18+
figure = manager.canvas.figure
1719
buffer = io.BytesIO()
18-
manager.canvas.figure.savefig(buffer, format="png", bbox_inches="tight")
19-
show_png(buffer.getvalue())
20+
# render at the device pixel ratio so plots stay sharp on HiDPI screens
21+
figure.savefig(
22+
buffer, format="png", bbox_inches="tight", dpi=figure.dpi * scale
23+
)
24+
acode_display.show_png(buffer.getvalue(), scale)
2025
Gcf.destroy_all()

‎src/python/acode_pygame.py‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Lets ordinary blocking pygame loops run on the browser's main thread.
2+
3+
A ``while running: ... clock.tick(60)`` loop never returns control to the
4+
browser, so nothing would be drawn and Acode would freeze. With JavaScript
5+
Promise Integration (JSPI) Python can pause on an awaitable, so the calls every
6+
game loop makes (display.flip/update, Clock.tick, time.wait/delay, event.wait)
7+
pause briefly and let the browser draw. Those pauses are also where a running
8+
game is stopped, since a blocking loop never reaches an ``await``.
9+
"""
10+
11+
import asyncio
12+
import time
13+
14+
import pygame
15+
from pyodide.ffi import can_run_sync, run_sync
16+
17+
_installed = False
18+
_stop_requested = False
19+
20+
21+
class StopProgram(BaseException):
22+
"""Raised inside the game loop when the console stops the program."""
23+
24+
25+
def reset():
26+
global _stop_requested
27+
_stop_requested = False
28+
29+
30+
def request_stop():
31+
global _stop_requested
32+
_stop_requested = True
33+
34+
35+
def _pause(seconds=0):
36+
if _stop_requested:
37+
raise StopProgram
38+
if can_run_sync():
39+
run_sync(asyncio.sleep(seconds))
40+
if _stop_requested:
41+
raise StopProgram
42+
43+
44+
def install():
45+
global _installed
46+
if _installed:
47+
return
48+
_installed = True
49+
50+
display = pygame.display
51+
original_flip = display.flip
52+
original_update = display.update
53+
54+
def flip():
55+
original_flip()
56+
_pause()
57+
58+
def update(*args, **kwargs):
59+
original_update(*args, **kwargs)
60+
_pause()
61+
62+
display.flip = flip
63+
display.update = update
64+
65+
original_clock = pygame.time.Clock
66+
67+
class Clock:
68+
"""pygame.time.Clock whose tick() waits without blocking the page."""
69+
70+
def __init__(self):
71+
self._clock = original_clock()
72+
self._last = time.perf_counter()
73+
74+
def tick(self, framerate=0):
75+
if framerate:
76+
delay = 1 / framerate - (time.perf_counter() - self._last)
77+
_pause(max(delay, 0))
78+
self._last = time.perf_counter()
79+
return self._clock.tick()
80+
81+
tick_busy_loop = tick
82+
83+
def __getattr__(self, name):
84+
return getattr(self._clock, name)
85+
86+
pygame.time.Clock = Clock
87+
if hasattr(pygame, "Clock"):
88+
pygame.Clock = Clock
89+
90+
def wait(milliseconds):
91+
start = time.perf_counter()
92+
_pause(milliseconds / 1000)
93+
return int((time.perf_counter() - start) * 1000)
94+
95+
pygame.time.wait = wait
96+
pygame.time.delay = wait
97+
98+
original_poll = pygame.event.poll
99+
100+
def event_wait(timeout=0):
101+
start = time.perf_counter()
102+
while True:
103+
event = original_poll()
104+
if event.type != pygame.NOEVENT:
105+
return event
106+
if timeout and (time.perf_counter() - start) * 1000 >= timeout:
107+
return event
108+
_pause(0.01)
109+
110+
pygame.event.wait = event_wait

0 commit comments

Comments
 (0)