Skip to content

Commit c666559

Browse files
bajrangCoderclaude
andcommitted
feat: show matplotlib/Pillow images and add experimental SDL support
- Move Pyodide setup into src/runtime.js, shared by the worker and the new SDL runtime; Python helpers live in src/python and are installed into site-packages - matplotlib: custom Agg backend (MPLBACKEND) posts figures as PNG to the console on plt.show(); unshown figures are shown when a run ends - Pillow: Image.show() displays in the console - Experimental SDL (pygame): programs with an async game loop run in a main-thread Pyodide drawing to a <canvas id="canvas"> in the console. SDL setup is checked with pygame.display.init() and any failure falls back to the worker. Keyboard input is limited to the canvas, Acode's document title is restored, and re-running or closing the console cancels the game - Worker runs pygame headless (SDL_VIDEODRIVER=dummy): without a canvas Emscripten's SDL video crashed Pyodide fatally - Handle Pyodide fatal errors: report them and restart Python on the next run instead of leaving the console stuck - Hide "already loaded" package messages on every run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3e45636 commit c666559

11 files changed

Lines changed: 519 additions & 39 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,32 @@ import micropip
2727
await micropip.install("snowballstemmer")
2828
```
2929

30+
### Plots and images
31+
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.
33+
34+
### pygame (experimental)
35+
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:
37+
38+
```python
39+
import asyncio
40+
import pygame
41+
42+
pygame.init()
43+
screen = pygame.display.set_mode((320, 240))
44+
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()
52+
```
53+
54+
If SDL cannot start, the program runs without a display. Running again or closing the console stops the game.
55+
3056
## Development
3157

3258
```sh

‎changelog.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
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
13+
- Python crashes no longer leave the console stuck; Python restarts on the next run
1114
- Fixed Python failing to load (or staying on "loading...") on some devices
1215
- Clear error message when Python fails to load, with automatic retry on the next run
1316
- Smaller plugin size (removed stale Pyodide files)

‎esbuild.config.mjs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ const buildConfig = {
9595
entryPoints: {
9696
main: "src/main.js",
9797
worker: "src/worker.js",
98+
// experimental SDL runtime, loaded on demand by main.js
99+
sdl: "src/sdl.js",
98100
},
99101
bundle: true,
100102
minify: true,
@@ -106,6 +108,8 @@ const buildConfig = {
106108
outdir: "dist",
107109
// Node-only imports inside Pyodide, never reached in the browser
108110
external: ["node:*", "ws"],
111+
// Python helpers installed into Pyodide's site-packages
112+
loader: { ".py": "text" },
109113
define: {
110114
PYODIDE_VERSION: JSON.stringify(pyodideVersion),
111115
},

‎plugin.zip‎

1.03 MB
Binary file not shown.

‎src/main.js‎

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ 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`
11+
const PYGAME_IMPORT = /^\s*(?:import|from)\s+pygame\b/m;
12+
const AWAIT = /\bawait\b/;
13+
914
class Python {
1015
#worker;
1116
#onInitError;
@@ -28,6 +33,7 @@ class Python {
2833
#initPromise = Promise.resolve(false);
2934
/** number of code runs still waiting for the worker */
3035
#running = 0;
36+
#sdlPromise = null;
3137

3238
INITIALIZING = 1;
3339
INITIALIZED = 2;
@@ -47,6 +53,7 @@ class Python {
4753

4854
const onhide = $page.onhide;
4955
$page.onhide = () => {
56+
window.acodePythonSdl?.stop();
5057
this.#state = this.NOT_INTIALIZED;
5158
this.#worker?.terminate();
5259
// runs on the terminated worker never reply
@@ -149,11 +156,75 @@ class Python {
149156
}
150157

151158
async run() {
159+
window.acodePythonSdl?.stop();
152160
this.#showPage();
153161
this.#inputCount = 0;
154162
this.#append(this.$input);
155163
await this.#cacheFile.writeFile("");
156-
await this.runCode(editorManager.editor.getValue());
164+
165+
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+
}
176+
await this.runCode(code);
177+
}
178+
179+
/**
180+
* Runs a pygame program with the experimental SDL runtime.
181+
* @returns {Promise<boolean>} false if SDL is unavailable and the caller
182+
* should fall back to the worker
183+
*/
184+
async #runWithSdl(code) {
185+
const $canvas = tag("canvas");
186+
const $media = tag("div", { className: "py-media", children: [$canvas] });
187+
this.#append($media, this.$input);
188+
this.#running += 1;
189+
this.#updateStatus();
190+
try {
191+
const sdl = await this.#loadSdl();
192+
const error = await sdl.run(code, {
193+
baseUrl: this.baseUrl,
194+
canvas: $canvas,
195+
stdout: (text) => this.print(text),
196+
stderr: (text) => this.print(text, "error"),
197+
showImage: (data) => this.#printImage(data),
198+
});
199+
if (error) this.print(error, "error");
200+
return true;
201+
} catch (error) {
202+
$media.remove();
203+
this.print(
204+
`Experimental SDL support is unavailable (${error?.message ?? error}). Running without it, so pygame windows cannot be shown.`,
205+
"info",
206+
);
207+
return false;
208+
} finally {
209+
this.#running = Math.max(0, this.#running - 1);
210+
this.#updateStatus();
211+
}
212+
}
213+
214+
#loadSdl() {
215+
this.#sdlPromise ??= new Promise((resolve, reject) => {
216+
const $script = tag("script", { src: `${this.baseUrl}sdl.js` });
217+
$script.onload = () => {
218+
if (window.acodePythonSdl) resolve(window.acodePythonSdl);
219+
else reject(new Error("sdl.js did not initialize"));
220+
};
221+
$script.onerror = () => reject(new Error("failed to load sdl.js"));
222+
document.head.append($script);
223+
}).catch((error) => {
224+
this.#sdlPromise = null;
225+
throw error;
226+
});
227+
return this.#sdlPromise;
157228
}
158229

159230
async terminal() {
@@ -199,6 +270,7 @@ class Python {
199270

200271
this.$wrapBtn?.remove();
201272
this.$status?.remove();
273+
window.acodePythonSdl?.stop();
202274
this.#worker?.terminate();
203275
editorManager.off("switch-file", this.checkRunnable.bind(this));
204276
editorManager.off("rename-file", this.checkRunnable.bind(this));
@@ -234,6 +306,15 @@ class Python {
234306
this.#append($output, this.$input);
235307
}
236308

309+
#printImage(data) {
310+
if (!this.$page.isConnected) return;
311+
const $img = tag("img", { src: `data:image/png;base64,${data}` });
312+
this.#append(
313+
tag("div", { className: "py-media", children: [$img] }),
314+
this.$input,
315+
);
316+
}
317+
237318
#showPage() {
238319
const $main = this.$page.get(".main");
239320
if (!this.$page.isConnected) {
@@ -286,6 +367,20 @@ class Python {
286367
this.print(text);
287368
break;
288369

370+
case "image":
371+
this.#printImage(e.data.data);
372+
break;
373+
374+
case "fatal":
375+
// Python cannot recover; restart it on the next run
376+
this.#worker?.terminate();
377+
this.#state = this.NOT_INTIALIZED;
378+
this.#isInput = false;
379+
this.#onRunError?.(
380+
`Python crashed (${error}). It will restart on the next run.`,
381+
);
382+
break;
383+
289384
case "stderr":
290385
this.print(text, "error");
291386
break;

‎src/python/acode_display.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Shows images from Python code in the Acode console.
2+
3+
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.
5+
"""
6+
7+
import base64
8+
import io
9+
import sys
10+
11+
from _acode import show_image
12+
13+
_pil_ready = False
14+
15+
16+
def show_png(data):
17+
show_image(base64.b64encode(data).decode("ascii"))
18+
19+
20+
def prepare(code):
21+
"""Called before running ``code``, after its packages are loaded."""
22+
from pyodide.code import find_imports
23+
24+
if "PIL" in find_imports(code):
25+
setup_pil()
26+
27+
28+
def setup_pil():
29+
"""Make ``Image.show()`` display the image in the console."""
30+
global _pil_ready
31+
if _pil_ready:
32+
return
33+
34+
from PIL import ImageShow
35+
36+
class AcodeViewer(ImageShow.Viewer):
37+
format = "PNG"
38+
39+
def show_image(self, image, **options):
40+
buffer = io.BytesIO()
41+
image.save(buffer, format="PNG")
42+
show_png(buffer.getvalue())
43+
return 1
44+
45+
ImageShow.register(AcodeViewer(), 0)
46+
_pil_ready = True
47+
48+
49+
def flush_figures():
50+
"""Show matplotlib figures the code created but never showed."""
51+
pyplot = sys.modules.get("matplotlib.pyplot")
52+
if pyplot is not None and pyplot.get_fignums():
53+
pyplot.show()

‎src/python/acode_mpl_backend.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Matplotlib backend that renders figures with Agg and shows them as PNG
2+
images in the Acode console. Selected with MPLBACKEND=module://acode_mpl_backend.
3+
"""
4+
5+
import io
6+
7+
from matplotlib._pylab_helpers import Gcf
8+
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
9+
10+
from acode_display import show_png
11+
12+
__all__ = ["FigureCanvas", "show"]
13+
14+
15+
def show(*args, **kwargs):
16+
for manager in Gcf.get_all_fig_managers():
17+
buffer = io.BytesIO()
18+
manager.canvas.figure.savefig(buffer, format="png", bbox_inches="tight")
19+
show_png(buffer.getvalue())
20+
Gcf.destroy_all()

0 commit comments

Comments
 (0)