Expand browser editor runtime and LED simulation workflows.

Add Docker deployment support, richer Selenium/LED pattern tests, in-browser diagnostics, responsive UI improvements, and 16x16 panel simulation tooling to speed iteration and hardware-style prototyping.

Made-with: Cursor
This commit is contained in:
2026-05-01 20:24:05 +12:00
parent f204109a84
commit e4c811f51d
30 changed files with 1478 additions and 60 deletions

12
.cursor/hooks.json Normal file
View File

@@ -0,0 +1,12 @@
{
"version": 1,
"hooks": {
"afterFileEdit": [
{
"command": ".cursor/hooks/dev-reload-touch.sh",
"timeout": 5,
"matcher": "Write|TabWrite"
}
]
}
}

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
# Consume hook JSON input (not needed for this hook).
cat >/dev/null || true
mkdir -p "src/static"
date +%s%3N > "src/static/.reload-token"

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.gitignore
.venv
__pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.cursor/
tests/
agent-transcripts/
data/
*.pyc
*.pyo
*.pyd

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN pip install --no-cache-dir pipenv
COPY Pipfile Pipfile.lock ./
RUN pipenv install --system --deploy
COPY src ./src
COPY workspace ./workspace
EXPOSE 8080
CMD ["uvicorn", "app:app", "--app-dir", "src", "--host", "0.0.0.0", "--port", "8080"]

75
LED_TUTORIAL.md Normal file
View File

@@ -0,0 +1,75 @@
# Python LED Tutorial (NeoPixel Focus)
This tutorial is for the browser editor's ESP32-style mocks:
- `machine.Pin`
- `neopixel.NeoPixel`
Use `workspace/code/led_tutorial.py` while reading this guide.
## 1) Basic setup
```python
from machine import Pin
import neopixel
np = neopixel.NeoPixel(Pin(4), 12)
```
- `Pin(4)` means data pin 4 (matching common ESP32 examples).
- `12` is the number of LEDs in the strip/ring.
- `np` is your LED strip object.
## 2) Set one LED color
```python
np[0] = (255, 0, 0) # red
np.write()
```
- Colors are `(red, green, blue)` from `0` to `255`.
- Nothing updates visually until `np.write()`.
## 3) Fill all LEDs
```python
np.fill((0, 0, 255)) # all blue
np.write()
```
## 4) Clear LEDs (turn off)
```python
np.fill((0, 0, 0))
np.write()
```
## 5) Animate over time
```python
import time
for step in range(20):
np.fill((step * 10, 0, 255 - step * 10))
np.write()
time.sleep(0.08)
```
`time.sleep(...)` controls animation speed.
## 6) Moving pixel example
```python
for i in range(len(np)):
np.fill((0, 0, 0))
np[i] = (255, 120, 0)
np.write()
time.sleep(0.06)
```
## 7) Tips
- Keep color values in `0..255`.
- Use helper functions for repeated color logic.
- Start with short loops, then increase frames once behavior looks good.
- If the simulator is closed, run your script again to show updates.

View File

@@ -8,6 +8,7 @@ pytest = "*"
pytest-cov = "*" pytest-cov = "*"
httpx = "*" httpx = "*"
selenium = "*" selenium = "*"
playwright = "*"
[packages] [packages]
fastapi = "*" fastapi = "*"

90
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "f9de958b9982b3a30c107c2c02746aece031522dc429be84e4f9589c73404282" "sha256": "ce177325185c5a9e04401b9368766f419921dfc5f9de934820507dae79998082"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -586,6 +586,71 @@
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==7.13.5" "version": "==7.13.5"
}, },
"greenlet": {
"hashes": [
"sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846",
"sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4",
"sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662",
"sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce",
"sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2",
"sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588",
"sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13",
"sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e",
"sha256:29ea813b2e1f45fa9649a17853b2b5465c4072fbcb072e5af6cd3a288216574a",
"sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3",
"sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b",
"sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033",
"sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628",
"sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136",
"sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b",
"sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d",
"sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2",
"sha256:4b28037cb07768933c54d81bfe47a85f9f402f57d7d69743b991a713b63954eb",
"sha256:4d0eadc7e4d9ffb2af4247b606cae307be8e448911e5a0d0b16d72fc3d224cfd",
"sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b",
"sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1",
"sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16",
"sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d",
"sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106",
"sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba",
"sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c",
"sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc",
"sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7",
"sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339",
"sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b",
"sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae",
"sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8",
"sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2",
"sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5",
"sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf",
"sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f",
"sha256:804a70b328e706b785c6ef16187051c394a63dd1a906d89be24b6ad77759f13f",
"sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2",
"sha256:884f649de075b84739713d41dd4dfd41e2b910bfb769c4a3ea02ec1da52cd9bb",
"sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082",
"sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7",
"sha256:9c615f869163e14bb1ced20322d8038fb680b08236521ac3f30cd4c1288785a0",
"sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c",
"sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853",
"sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988",
"sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3",
"sha256:ba8f0bdc2fae6ce915dfd0c16d2d00bca7e4247c1eae4416e06430e522137858",
"sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37",
"sha256:cda05425526240807408156b6960a17a79a0c760b813573b67027823be760977",
"sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4",
"sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8",
"sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86",
"sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f",
"sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112",
"sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e",
"sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2",
"sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8",
"sha256:f8c30c2225f40dd76c50790f0eb3b5c7c18431efb299e2782083e1981feed243",
"sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564"
],
"markers": "platform_machine == 'aarch64' or (platform_machine == 'ppc64le' or (platform_machine == 'x86_64' or (platform_machine == 'amd64' or (platform_machine == 'AMD64' or (platform_machine == 'win32' or platform_machine == 'WIN32')))))",
"version": "==3.5.0"
},
"h11": { "h11": {
"hashes": [ "hashes": [
"sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1",
@@ -643,6 +708,21 @@
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==26.2" "version": "==26.2"
}, },
"playwright": {
"hashes": [
"sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6",
"sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119",
"sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100",
"sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9",
"sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47",
"sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e",
"sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d",
"sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==1.59.0"
},
"pluggy": { "pluggy": {
"hashes": [ "hashes": [
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3",
@@ -651,6 +731,14 @@
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==1.6.0" "version": "==1.6.0"
}, },
"pyee": {
"hashes": [
"sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8",
"sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"
],
"markers": "python_version >= '3.8'",
"version": "==13.0.1"
},
"pygments": { "pygments": {
"hashes": [ "hashes": [
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f",

View File

@@ -48,6 +48,24 @@ If nothing is listening, the smoke test **skips** with a short message instead o
Open [http://localhost:8080](http://localhost:8080). Open [http://localhost:8080](http://localhost:8080).
## Deploy with Docker
Build and run with Docker Compose:
```bash
cp .env.example .env
mkdir -p data
docker compose up --build
```
Then open [http://localhost:8080](http://localhost:8080).
Notes:
- `workspace/` is mounted to `/app/workspace` so your code persists locally.
- `data/` is mounted to `/app/data` for the SQLite auth DB.
- In container mode, `WORKSPACE_ROOT` and `AUTH_DATABASE_PATH` are set by `docker-compose.yml`.
**User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` (if `AUTH_REGISTER_OPEN=true`) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts. **User accounts** — Set `AUTH_ENABLED=true` in `.env` to require sign-in for workspace APIs. Users live in a SQLite file (`AUTH_DATABASE_PATH`, default `./data/editor.db`). Use `/register` (if `AUTH_REGISTER_OPEN=true`) or `BOOTSTRAP_ADMIN_USERNAME` / `BOOTSTRAP_ADMIN_PASSWORD` for the first superuser. Superusers can **GET/POST/DELETE `/api/users`** to list, create, or remove accounts.
**API key** — If `EDITOR_API_KEY` is set, requests may use `Authorization: Bearer …` instead of a session (useful for automation). When `AUTH_ENABLED=true`, a valid session *or* API key is accepted. **API key** — If `EDITOR_API_KEY` is set, requests may use `Authorization: Bearer …` instead of a session (useful for automation). When `AUTH_ENABLED=true`, a valid session *or* API key is accepted.
@@ -58,3 +76,46 @@ The home page can store the API key in `sessionStorage` when you are not using c
- `src/` — FastAPI app and static UI (`src/static/`) - `src/` — FastAPI app and static UI (`src/static/`)
- `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API) - `workspace/` — default tree: `code/` (editable), `lib/` (read-only via API)
## ESP32 / NeoPixel mock
The browser runtime now includes MicroPython-style mocks in `workspace/lib`:
- `machine.Pin`
- `neopixel.NeoPixel`
Use them from scripts in `workspace/code` exactly like ESP32 examples:
```python
from machine import Pin
import neopixel
np = neopixel.NeoPixel(Pin(4), 8)
np[0] = (255, 0, 0)
np.write()
```
`write()` updates the NeoPixel simulator window so you can verify behavior visually.
Tutorial files:
- `LED_TUTORIAL.md` - step-by-step NeoPixel tutorial
- `workspace/code/led_tutorial.py` - runnable guided LED example
- `workspace/code/led_patterns.py` - reusable pattern helpers (`rainbow_frame`, `chase_frame`, `twinkle_frame`)
- `workspace/code/pattern_rainbow_demo.py` - rainbow animation demo
- `workspace/code/pattern_chase_demo.py` - chase animation demo
- `workspace/code/pattern_twinkle_demo.py` - twinkle animation demo
- `workspace/code/panel16_utils.py` - helpers for 16x16 serpentine mapping
- `workspace/code/panel16_rainbow_wave.py` - 16x16 rainbow wave
- `workspace/code/panel16_bounce.py` - 16x16 bouncing pixel with trail
- `workspace/code/panel16_matrix_rain.py` - 16x16 matrix rain effect
## Dev auto-reload hook
Project hook files are included in `.cursor/`:
- `.cursor/hooks.json`
- `.cursor/hooks/dev-reload-touch.sh`
When files are edited through Cursor tools, the hook updates `src/static/.reload-token`.
The editor (on localhost) polls that token and auto-reloads the browser when it changes.

16
docker-compose.yml Normal file
View File

@@ -0,0 +1,16 @@
services:
python-editor:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
env_file:
- .env
environment:
- WORKSPACE_ROOT=/app/workspace
- AUTH_DATABASE_PATH=/app/data/editor.db
volumes:
- ./workspace:/app/workspace
- ./data:/app/data
restart: unless-stopped

1
src/static/.reload-token Normal file
View File

@@ -0,0 +1 @@
1777623664358

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Python Editor</title> <title>Python Editor</title>
<link rel="icon" href="data:,"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=6"> <link rel="stylesheet" href="/static/styles.css?v=10">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -29,6 +29,7 @@
<div class="file-info"> <div class="file-info">
<span id="save-status" class="save-status"></span> <span id="save-status" class="save-status"></span>
<span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span> <span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
<span id="lsp-status" class="runtime-hint" title="Jedi in-browser diagnostics">LSP: n/a</span>
</div> </div>
<div class="mode-toggle"> <div class="mode-toggle">
<a id="home-btn" class="mode-btn active" href="/">Home</a> <a id="home-btn" class="mode-btn active" href="/">Home</a>
@@ -36,9 +37,14 @@
<div class="editor-actions"> <div class="editor-actions">
<button id="run-btn" disabled>Run Python</button> <button id="run-btn" disabled>Run Python</button>
<button id="stop-btn" disabled>Stop</button> <button id="stop-btn" disabled>Stop</button>
<select id="run-file-select" title="Script to run"> <label for="run-main-checkbox" class="run-main-toggle">
<option value="">Run active file</option> <input type="checkbox" id="run-main-checkbox" />
</select> Run `main.py`
</label>
<label for="panel-16x16-checkbox" class="run-main-toggle">
<input type="checkbox" id="panel-16x16-checkbox" />
16x16 panel
</label>
</div> </div>
</div> </div>
@@ -49,6 +55,19 @@
<div id="completion-dropdown" class="completion-dropdown"></div> <div id="completion-dropdown" class="completion-dropdown"></div>
</div> </div>
<section id="led-sim-panel" class="led-sim-panel hidden" aria-label="NeoPixel Simulator">
<div class="led-sim-header">
<h3>NeoPixel Simulator</h3>
<div class="led-sim-actions">
<button id="led-run-btn" type="button">Run</button>
<button id="led-stop-btn" type="button">Stop</button>
<button id="led-close-btn" type="button" aria-label="Close simulator">Close</button>
</div>
</div>
<div id="led-meta" class="led-meta">Waiting for neopixel.write()...</div>
<div id="led-grid" class="led-grid"></div>
</section>
<div class="console-container"> <div class="console-container">
<div class="console-header">Console Output</div> <div class="console-header">Console Output</div>
<pre id="console-output" class="console-output"></pre> <pre id="console-output" class="console-output"></pre>
@@ -67,6 +86,6 @@
</div> </div>
</div> </div>
<script type="module" src="/static/script.js?v=10"></script> <script type="module" src="/static/script.js?v=22"></script>
</body> </body>
</html> </html>

View File

@@ -77,6 +77,38 @@ json.dumps(out)
return; return;
} }
if (type === 'diagnostics') {
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
const vpath = `/workspace/${rel}`;
p.globals.set('__diag_code', String(payload.content ?? ''));
p.globals.set('__diag_path', vpath);
p.globals.set('__diag_extra_json', JSON.stringify(payload.extra_files || {}));
const raw = p.runPython(`
import json, os
import jedi
extra = json.loads(__diag_extra_json)
os.makedirs("/workspace", exist_ok=True)
for rel_path, body in extra.items():
rel_path = str(rel_path).lstrip("/")
full = os.path.join("/workspace", rel_path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as fh:
fh.write(str(body))
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
with open(__diag_path, "w", encoding="utf-8") as fh:
fh.write(__diag_code)
proj = jedi.Project("/workspace")
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
errs = s.get_syntax_errors()
out = [{"line": e.line, "column": e.column, "message": str(e.get_message())} for e in errs]
json.dumps(out)
`);
const diagnostics = JSON.parse(String(raw));
self.postMessage({ id, type: 'diagnostics', ok: true, diagnostics });
return;
}
if (type === 'run') { if (type === 'run') {
const files = payload.files && typeof payload.files === 'object' ? payload.files : {}; const files = payload.files && typeof payload.files === 'object' ? payload.files : {};
const mainRel = String(payload.mainPath || '').replace(/^\/+/, ''); const mainRel = String(payload.mainPath || '').replace(/^\/+/, '');
@@ -97,7 +129,7 @@ for rel, body in files.items():
with open(full, "w", encoding="utf-8") as fh: with open(full, "w", encoding="utf-8") as fh:
fh.write(str(body)) fh.write(str(body))
for entry in ("/workspace/lib", "/workspace"): for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
if entry not in sys.path: if entry not in sys.path:
sys.path.insert(0, entry) sys.path.insert(0, entry)

View File

@@ -1,4 +1,6 @@
import { EditorView, basicSetup } from "/static/codemirror.bundle.mjs"; import { EditorView, basicSetup } from "https://esm.sh/codemirror";
import { Compartment } from "https://esm.sh/@codemirror/state";
import { python } from "https://esm.sh/@codemirror/lang-python";
class TextEditor { class TextEditor {
constructor() { constructor() {
@@ -7,6 +9,7 @@ class TextEditor {
this.pyWorkerMsgId = 0; this.pyWorkerMsgId = 0;
this.pyWorkerHandlers = new Map(); this.pyWorkerHandlers = new Map();
this.pyodideInited = false; this.pyodideInited = false;
this.workerWarmupPromise = null;
this.pyRunGeneration = 0; this.pyRunGeneration = 0;
this.editor = null; this.editor = null;
this.currentFilePath = null; this.currentFilePath = null;
@@ -26,11 +29,19 @@ class TextEditor {
this.completionIndex = 0; this.completionIndex = 0;
this.completionOpen = false; this.completionOpen = false;
this.completionRequestId = 0; this.completionRequestId = 0;
this.diagnosticsRequestId = 0;
this.diagnosticsTimer = null;
this.draggedItemPath = null; this.draggedItemPath = null;
this.draggedItemIsDirectory = false; this.draggedItemIsDirectory = false;
this.dragHoverExpandTimer = null; this.dragHoverExpandTimer = null;
this.dragHoverTargetPath = null; this.dragHoverTargetPath = null;
this.savedSession = null; this.savedSession = null;
this.languageCompartment = new Compartment();
this.autoCompleteTimer = null;
this.ledSimWindow = null;
this.ledPanelDismissed = false;
this.lastLedFrame = null;
this.ledPanelWindow = null;
this.init(); this.init();
} }
@@ -47,10 +58,44 @@ class TextEditor {
this.loadSessionState(); this.loadSessionState();
this.setupEditor(); this.setupEditor();
this.setupEventListeners(); this.setupEventListeners();
this.setupDevAutoReload();
this.updateRunButtonState(); this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
this.prewarmPyWorker();
this.loadInitialDirectoryState().then(() => this.restoreSessionTabs()); this.loadInitialDirectoryState().then(() => this.restoreSessionTabs());
} }
setupDevAutoReload() {
const isLocalhost =
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1';
if (!isLocalhost) return;
let lastToken = null;
const poll = async () => {
try {
const resp = await fetch(`/static/.reload-token?ts=${Date.now()}`, { cache: 'no-store' });
if (!resp.ok) {
return;
}
const token = (await resp.text()).trim();
if (lastToken === null) {
lastToken = token;
return;
}
if (token && token !== lastToken) {
window.location.reload();
return;
}
} catch (_err) {
// Dev hook file may not exist yet.
}
};
setInterval(poll, 1000);
poll();
}
apiFetch(url, init = {}) { apiFetch(url, init = {}) {
const next = { ...init }; const next = { ...init };
const headers = new Headers(init.headers || {}); const headers = new Headers(init.headers || {});
@@ -77,7 +122,7 @@ class TextEditor {
ensurePyWorker() { ensurePyWorker() {
if (!this.pyWorker) { if (!this.pyWorker) {
const worker = new Worker('/static/pyodide-worker.js'); const worker = new Worker('/static/pyodide-worker.js?v=3');
this.pyWorker = worker; this.pyWorker = worker;
worker.onmessage = (event) => this.handlePyWorkerMessage(event); worker.onmessage = (event) => this.handlePyWorkerMessage(event);
} }
@@ -126,6 +171,19 @@ class TextEditor {
this.pyodideInited = true; this.pyodideInited = true;
} }
prewarmPyWorker() {
if (this.pyodideInited || this.workerWarmupPromise) {
return;
}
this.workerWarmupPromise = this.ensurePyodideReady()
.catch(() => {
// Ignore warm-up failures; next foreground run will retry.
})
.finally(() => {
this.workerWarmupPromise = null;
});
}
loadSessionState() { loadSessionState() {
try { try {
const raw = localStorage.getItem(this.sessionStorageKey); const raw = localStorage.getItem(this.sessionStorageKey);
@@ -139,11 +197,13 @@ class TextEditor {
saveSessionState() { saveSessionState() {
try { try {
const runFileSelect = document.getElementById('run-file-select'); const runMainCheckbox = document.getElementById('run-main-checkbox');
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
const session = { const session = {
openTabPaths: this.openTabs.map((tab) => tab.path), openTabPaths: this.openTabs.map((tab) => tab.path),
activeTabPath: this.activeTabPath, activeTabPath: this.activeTabPath,
selectedRunFile: runFileSelect ? runFileSelect.value : '', runMainChecked: Boolean(runMainCheckbox && runMainCheckbox.checked),
panel16x16Checked: Boolean(panelModeCheckbox && panelModeCheckbox.checked),
expandedDirs: Array.from(this.expandedDirs || []), expandedDirs: Array.from(this.expandedDirs || []),
selectedPath: this.selectedPath || '', selectedPath: this.selectedPath || '',
selectedIsDirectory: Boolean(this.selectedIsDirectory) selectedIsDirectory: Boolean(this.selectedIsDirectory)
@@ -170,9 +230,13 @@ class TextEditor {
if (session.activeTabPath && this.findTab(session.activeTabPath)) { if (session.activeTabPath && this.findTab(session.activeTabPath)) {
this.switchToTab(session.activeTabPath); this.switchToTab(session.activeTabPath);
} }
const runFileSelect = document.getElementById('run-file-select'); const runMainCheckbox = document.getElementById('run-main-checkbox');
if (runFileSelect && typeof session.selectedRunFile === 'string') { if (runMainCheckbox && typeof session.runMainChecked === 'boolean') {
runFileSelect.value = session.selectedRunFile; runMainCheckbox.checked = session.runMainChecked;
}
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
if (panelModeCheckbox && typeof session.panel16x16Checked === 'boolean') {
panelModeCheckbox.checked = session.panel16x16Checked;
} }
this.saveSessionState(); this.saveSessionState();
} }
@@ -256,7 +320,7 @@ class TextEditor {
setupEditor() { setupEditor() {
this.editor = new EditorView({ this.editor = new EditorView({
doc: '', doc: '',
extensions: [basicSetup], extensions: [basicSetup, this.languageCompartment.of([])],
parent: document.getElementById('editor') parent: document.getElementById('editor')
}); });
@@ -323,10 +387,88 @@ class TextEditor {
this.hideCompletionDropdown(); this.hideCompletionDropdown();
this.updateActiveTabContent(); this.updateActiveTabContent();
this.markAsModified(); this.markAsModified();
this.scheduleAutoCompletion();
this.scheduleDiagnostics();
} }
})(this.editor.dispatch); })(this.editor.dispatch);
} }
setLspStatus(text, title = '') {
const status = document.getElementById('lsp-status');
if (!status) return;
status.textContent = text;
status.title = title || text;
}
scheduleDiagnostics() {
if (this.diagnosticsTimer) {
clearTimeout(this.diagnosticsTimer);
}
this.diagnosticsTimer = setTimeout(() => {
this.runDiagnostics();
}, 220);
}
async runDiagnostics() {
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
return;
}
const requestId = ++this.diagnosticsRequestId;
this.setLspStatus('LSP: checking...', 'Running Jedi syntax diagnostics');
try {
await this.ensurePyodideReady();
const extraFiles = {};
for (const tab of this.openTabs) {
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
extraFiles[tab.path] = tab.content;
}
}
const data = await this.callPyWorker('diagnostics', {
path: this.currentFilePath,
content: this.editor.state.doc.toString(),
extra_files: extraFiles
});
if (requestId !== this.diagnosticsRequestId) return;
const diagnostics = Array.isArray(data.diagnostics) ? data.diagnostics : [];
if (diagnostics.length === 0) {
this.setLspStatus('LSP: OK', 'No syntax errors');
} else {
const first = diagnostics[0];
const msg = `${first.message || 'Syntax error'} (line ${first.line ?? '?'})`;
this.setLspStatus(`LSP: ${diagnostics.length} issue(s)`, msg);
}
} catch (_error) {
this.setLspStatus('LSP: unavailable', 'Diagnostics failed');
}
}
scheduleAutoCompletion() {
if (!this.currentFilePath || !this.currentFilePath.toLowerCase().endsWith('.py')) {
return;
}
const cursor = this.editor.state.selection.main.head;
const doc = this.editor.state.doc.toString();
const prev = cursor > 0 ? doc[cursor - 1] : '';
if (!(prev === '.' || /[A-Za-z0-9_]/.test(prev))) {
return;
}
if (this.autoCompleteTimer) {
clearTimeout(this.autoCompleteTimer);
}
this.autoCompleteTimer = setTimeout(() => {
this.showCompletionDropdown();
}, 140);
}
setLanguageForPath(path) {
const ext = (path || '').toLowerCase();
const language = ext.endsWith('.py') ? python() : [];
this.editor.dispatch({
effects: this.languageCompartment.reconfigure(language),
});
}
getCursorLineAndColumn() { getCursorLineAndColumn() {
const cursor = this.editor.state.selection.main.head; const cursor = this.editor.state.selection.main.head;
const lineInfo = this.editor.state.doc.lineAt(cursor); const lineInfo = this.editor.state.doc.lineAt(cursor);
@@ -566,6 +708,27 @@ class TextEditor {
this.stopPython(); this.stopPython();
}); });
const ledRunBtn = document.getElementById('led-run-btn');
if (ledRunBtn) {
ledRunBtn.addEventListener('click', () => {
this.runPython();
});
}
const ledStopBtn = document.getElementById('led-stop-btn');
if (ledStopBtn) {
ledStopBtn.addEventListener('click', () => {
this.stopPython();
});
}
const ledCloseBtn = document.getElementById('led-close-btn');
if (ledCloseBtn) {
ledCloseBtn.addEventListener('click', () => {
this.ledPanelDismissed = true;
const panel = document.getElementById('led-sim-panel');
if (panel) panel.classList.add('hidden');
});
}
document.getElementById('create-file-btn').addEventListener('click', () => { document.getElementById('create-file-btn').addEventListener('click', () => {
this.createNewFile(); this.createNewFile();
}); });
@@ -590,15 +753,26 @@ class TextEditor {
window.location.href = '/'; window.location.href = '/';
}); });
document.getElementById('run-file-select').addEventListener('change', (event) => { const runMainCheckbox = document.getElementById('run-main-checkbox');
const selectedPath = event.target.value; if (runMainCheckbox) {
if (!selectedPath) return; runMainCheckbox.addEventListener('change', () => {
const tab = this.findTab(selectedPath); this.saveSessionState();
if (tab) { this.updateRunButtonState();
this.switchToTab(selectedPath); });
} }
this.saveSessionState(); const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
}); if (panelModeCheckbox) {
panelModeCheckbox.addEventListener('change', () => {
this.saveSessionState();
if (!panelModeCheckbox.checked && this.ledPanelWindow && !this.ledPanelWindow.closed) {
this.ledPanelWindow.close();
this.ledPanelWindow = null;
}
if (this.lastLedFrame) {
this.renderLedSimulation(this.lastLedFrame);
}
});
}
} }
@@ -644,29 +818,9 @@ class TextEditor {
this.closeTab(closeButton.dataset.path); this.closeTab(closeButton.dataset.path);
}); });
}); });
this.renderRunFileSelect();
this.saveSessionState(); this.saveSessionState();
} }
renderRunFileSelect() {
const select = document.getElementById('run-file-select');
if (!select) return;
const pythonTabs = this.openTabs
.map((tab) => tab.path)
.filter((path) => typeof path === 'string' && path.toLowerCase().endsWith('.py'));
const currentValue = select.value;
select.innerHTML = '<option value="">Run active file</option>';
pythonTabs.forEach((path) => {
const option = document.createElement('option');
option.value = path;
option.textContent = path.startsWith('code/') ? path.slice('code/'.length) : path;
if (path === currentValue || path === this.currentFilePath) {
option.selected = true;
}
select.appendChild(option);
});
}
findTab(path) { findTab(path) {
return this.openTabs.find((tab) => tab.path === path); return this.openTabs.find((tab) => tab.path === path);
} }
@@ -705,10 +859,12 @@ class TextEditor {
} }
}); });
this.ignoreNextChange = false; this.ignoreNextChange = false;
this.setLanguageForPath(path);
const currentFileEl = document.getElementById('current-file'); const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = path; if (currentFileEl) currentFileEl.textContent = path;
this.setEditorReadOnly(this.isReadOnlyPath(path)); this.setEditorReadOnly(this.isReadOnlyPath(path));
this.updateRunButtonState(); this.updateRunButtonState();
this.scheduleDiagnostics();
if (tab.isModified) { if (tab.isModified) {
this.markAsModified(); this.markAsModified();
} else { } else {
@@ -737,6 +893,7 @@ class TextEditor {
this.activeTabPath = null; this.activeTabPath = null;
this.currentFilePath = null; this.currentFilePath = null;
this.setEditorReadOnly(false); this.setEditorReadOnly(false);
this.setLanguageForPath('');
this.ignoreNextChange = true; this.ignoreNextChange = true;
this.editor.dispatch({ this.editor.dispatch({
changes: { changes: {
@@ -749,6 +906,7 @@ class TextEditor {
const currentFileEl = document.getElementById('current-file'); const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = 'No file selected'; if (currentFileEl) currentFileEl.textContent = 'No file selected';
this.updateRunButtonState(); this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
this.markAsSaved(); this.markAsSaved();
} }
} }
@@ -1021,12 +1179,14 @@ class TextEditor {
} }
}); });
this.ignoreNextChange = false; this.ignoreNextChange = false;
this.setLanguageForPath(filePath);
this.markAsSaved(); this.markAsSaved();
this.setEditorReadOnly(this.isReadOnlyPath(filePath)); this.setEditorReadOnly(this.isReadOnlyPath(filePath));
const currentFileEl = document.getElementById('current-file'); const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = filePath; if (currentFileEl) currentFileEl.textContent = filePath;
this.updateRunButtonState(); this.updateRunButtonState();
this.scheduleDiagnostics();
this.renderTabs(); this.renderTabs();
this.saveSessionState(); this.saveSessionState();
} catch (error) { } catch (error) {
@@ -1107,6 +1267,7 @@ class TextEditor {
const currentFileEl = document.getElementById('current-file'); const currentFileEl = document.getElementById('current-file');
if (currentFileEl) currentFileEl.textContent = 'No file selected'; if (currentFileEl) currentFileEl.textContent = 'No file selected';
this.updateRunButtonState(); this.updateRunButtonState();
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
} }
this.directoryCache.clear(); this.directoryCache.clear();
@@ -1129,6 +1290,7 @@ class TextEditor {
} else { } else {
this.activeTabPath = null; this.activeTabPath = null;
this.currentFilePath = null; this.currentFilePath = null;
this.setLanguageForPath('');
this.ignoreNextChange = true; this.ignoreNextChange = true;
this.editor.dispatch({ this.editor.dispatch({
changes: { changes: {
@@ -1150,9 +1312,13 @@ class TextEditor {
updateRunButtonState() { updateRunButtonState() {
const runButton = document.getElementById('run-btn'); const runButton = document.getElementById('run-btn');
const stopButton = document.getElementById('stop-btn'); const stopButton = document.getElementById('stop-btn');
const runMainCheckbox = document.getElementById('run-main-checkbox');
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py')); const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
runButton.disabled = !hasPythonFile || this.isPythonRunning; const canRun = runMainSelected || hasPythonFile;
runButton.disabled = !canRun;
stopButton.disabled = !this.isPythonRunning; stopButton.disabled = !this.isPythonRunning;
this.updateLedWindowControls();
} }
clearConsole() { clearConsole() {
@@ -1165,9 +1331,147 @@ class TextEditor {
} }
} }
maybePrepareLedWindow(files) {
const importsNeoPixel = Object.values(files || {}).some((content) =>
typeof content === 'string' &&
/\bimport\s+neopixel\b|\bfrom\s+neopixel\s+import\b/.test(content)
);
if (!importsNeoPixel) return;
this.ledPanelDismissed = false;
this.ensureLedWindow();
}
ensureLedWindow() {
const panel = document.getElementById('led-sim-panel');
if (!panel) return null;
if (!this.ledPanelDismissed) {
panel.classList.remove('hidden');
}
this.updateLedWindowControls();
return panel;
}
ensureLedPanelWindow() {
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
return this.ledPanelWindow;
}
const win = window.open('', 'neopixel-16x16', 'width=760,height=760');
if (!win) return null;
win.document.title = 'NeoPixel 16x16 Panel';
win.document.body.innerHTML = `
<style>
body { margin: 0; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; padding: 12px; }
.meta { margin-bottom: 10px; color: #9ca3af; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(16, 24px); grid-auto-rows: 24px; gap: 6px; }
.led { width: 24px; height: 24px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); box-shadow: inset 0 0 8px rgba(0,0,0,0.45); }
</style>
<div id="meta" class="meta">Waiting for frame...</div>
<div id="grid" class="grid"></div>
`;
this.ledPanelWindow = win;
return win;
}
closeLedPanelWindow() {
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
this.ledPanelWindow.close();
}
this.ledPanelWindow = null;
}
updateLedWindowControls() {
const runBtn = document.getElementById('led-run-btn');
const stopBtn = document.getElementById('led-stop-btn');
if (!runBtn || !stopBtn) return;
const runMainCheckbox = document.getElementById('run-main-checkbox');
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
runBtn.disabled = !(runMainSelected || hasPythonFile);
stopBtn.disabled = !this.isPythonRunning;
}
renderLedSimulation(frame) {
this.lastLedFrame = frame;
this.ledPanelDismissed = false;
if (!frame) return;
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
const panelMode = Boolean(panelModeCheckbox && panelModeCheckbox.checked);
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
if (panelMode) {
const panelWindow = this.ensureLedPanelWindow();
if (!panelWindow || panelWindow.closed) return;
const meta = panelWindow.document.getElementById('meta');
const grid = panelWindow.document.getElementById('grid');
if (!meta || !grid) return;
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3} | mode=16x16`;
grid.innerHTML = '';
const panelSize = 16 * 16;
for (let panelIndex = 0; panelIndex < panelSize; panelIndex += 1) {
const row = Math.floor(panelIndex / 16);
const col = panelIndex % 16;
const ledIndex = row % 2 === 0 ? row * 16 + (15 - col) : row * 16 + col;
const px = pixels[ledIndex] || [0, 0, 0];
const r = Number(px?.[0] ?? 0);
const g = Number(px?.[1] ?? 0);
const b = Number(px?.[2] ?? 0);
const div = panelWindow.document.createElement('div');
div.className = 'led';
div.title = `panel(${row},${col}) -> #${ledIndex} (${r}, ${g}, ${b})`;
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
grid.appendChild(div);
}
} else {
const panel = this.ensureLedWindow();
if (!panel) return;
const meta = document.getElementById('led-meta');
const grid = document.getElementById('led-grid');
if (!meta || !grid) return;
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3}`;
grid.innerHTML = '';
grid.classList.remove('panel-mode');
pixels.forEach((px, i) => {
const r = Number(px?.[0] ?? 0);
const g = Number(px?.[1] ?? 0);
const b = Number(px?.[2] ?? 0);
const div = document.createElement('div');
div.className = 'led';
div.title = `#${i} (${r}, ${g}, ${b})`;
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
grid.appendChild(div);
});
}
}
processSimulationLines(lines) {
for (const line of lines || []) {
if (typeof line !== 'string') continue;
const marker = '[neopixel-json]';
const idx = line.indexOf(marker);
if (idx === -1) continue;
const jsonPart = line.slice(idx + marker.length).trim();
if (!jsonPart) continue;
try {
const payload = JSON.parse(jsonPart);
if (payload && payload.type === 'neopixel') {
this.renderLedSimulation(payload);
}
} catch (_err) {
// Ignore malformed simulation payloads.
}
}
}
appendConsoleOutput(lines) { appendConsoleOutput(lines) {
if (!Array.isArray(lines) || lines.length === 0) return; if (!Array.isArray(lines) || lines.length === 0) return;
this.consolePendingText += lines.join(''); this.processSimulationLines(lines);
const visibleLines = lines.filter((line) => {
if (typeof line !== 'string') return false;
return !line.includes('[neopixel-json]');
});
if (visibleLines.length === 0) return;
this.consolePendingText += visibleLines.join('');
if (this.consoleFlushTimer) return; if (this.consoleFlushTimer) return;
this.consoleFlushTimer = setTimeout(() => { this.consoleFlushTimer = setTimeout(() => {
const consoleOutput = document.getElementById('console-output'); const consoleOutput = document.getElementById('console-output');
@@ -1185,8 +1489,8 @@ class TextEditor {
} }
async runPython() { async runPython() {
const runFileSelect = document.getElementById('run-file-select'); const runMainCheckbox = document.getElementById('run-main-checkbox');
const selectedRunFile = runFileSelect && runFileSelect.value ? runFileSelect.value : this.currentFilePath; const selectedRunFile = runMainCheckbox && runMainCheckbox.checked ? 'code/main.py' : this.currentFilePath;
if (selectedRunFile && selectedRunFile !== this.currentFilePath && this.findTab(selectedRunFile)) { if (selectedRunFile && selectedRunFile !== this.currentFilePath && this.findTab(selectedRunFile)) {
this.switchToTab(selectedRunFile); this.switchToTab(selectedRunFile);
} }
@@ -1214,6 +1518,7 @@ class TextEditor {
files[tab.path] = tab.content; files[tab.path] = tab.content;
} }
} }
this.maybePrepareLedWindow(files);
this.clearConsole(); this.clearConsole();
const args = []; const args = [];
this.appendConsoleOutput([`$ pyodide ${selectedRunFile}\n`]); this.appendConsoleOutput([`$ pyodide ${selectedRunFile}\n`]);
@@ -1228,6 +1533,7 @@ class TextEditor {
}); });
if (generation === this.pyRunGeneration) { if (generation === this.pyRunGeneration) {
this.appendConsoleOutput(['\n[Finished]\n']); this.appendConsoleOutput(['\n[Finished]\n']);
this.closeLedPanelWindow();
} }
} catch (error) { } catch (error) {
this.appendConsoleOutput([`\n${error.message}\n`]); this.appendConsoleOutput([`\n${error.message}\n`]);
@@ -1244,8 +1550,10 @@ class TextEditor {
this.pyRunGeneration += 1; this.pyRunGeneration += 1;
this.disposePyWorker(); this.disposePyWorker();
this.isPythonRunning = false; this.isPythonRunning = false;
this.closeLedPanelWindow();
this.appendConsoleOutput(['\n[Execution stopped — Pyodide worker was reset]\n']); this.appendConsoleOutput(['\n[Execution stopped — Pyodide worker was reset]\n']);
this.updateRunButtonState(); this.updateRunButtonState();
this.prewarmPyWorker();
} }
async deleteSelected() { async deleteSelected() {

View File

@@ -7,13 +7,14 @@
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5; background-color: #f5f5f5;
height: 100vh; height: 100dvh;
overflow: hidden; overflow: hidden;
} }
.container { .container {
display: flex; display: flex;
height: 100vh; height: 100dvh;
overflow: hidden;
} }
/* Sidebar */ /* Sidebar */
@@ -23,6 +24,7 @@ body {
color: white; color: white;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.sidebar-header { .sidebar-header {
@@ -117,6 +119,9 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: white; background-color: white;
min-width: 0;
min-height: 0;
overflow: hidden;
} }
.editor-header { .editor-header {
@@ -191,15 +196,6 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
#run-file-select {
min-width: 220px;
padding: 0.5rem 0.65rem;
border: 1px solid #cbd5e0;
border-radius: 6px;
font-size: 0.85rem;
background: white;
}
.editor-actions button { .editor-actions button {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
@@ -225,10 +221,28 @@ body {
background-color: #edf2f7; background-color: #edf2f7;
} }
.run-main-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
color: #374151;
background: white;
white-space: nowrap;
}
.run-main-toggle input[type="checkbox"] {
margin: 0;
}
.editor-container { .editor-container {
flex: 1; flex: 1;
position: relative; position: relative;
min-height: 0; min-height: 0;
overflow: hidden;
} }
.hidden { .hidden {
@@ -350,6 +364,89 @@ body {
background: #0f172a; background: #0f172a;
} }
.led-sim-panel {
border-top: 1px solid #e2e8f0;
background: #111827;
color: #e5e7eb;
padding: 0.75rem;
}
.led-sim-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
}
.led-sim-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.led-sim-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.led-sim-actions button {
padding: 0.3rem 0.65rem;
border: 1px solid #374151;
background: #1f2937;
color: #e5e7eb;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
}
.led-sim-actions button:hover:not(:disabled) {
background: #243244;
}
.led-sim-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.led-meta {
margin-top: 0.5rem;
color: #9ca3af;
font-size: 0.82rem;
}
.led-grid {
margin-top: 0.6rem;
display: flex;
flex-wrap: wrap;
overflow-x: hidden;
overflow-y: auto;
gap: 8px;
width: 100%;
max-height: 132px;
padding: 0.1rem 0 0.2rem;
}
.led-grid.panel-mode {
display: grid;
grid-template-columns: repeat(16, minmax(0, 24px));
grid-auto-rows: 24px;
justify-content: start;
align-content: start;
gap: 6px;
max-height: 470px;
overflow: auto;
}
.led {
width: 24px;
height: 24px;
min-width: 24px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.45);
}
.console-header { .console-header {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-size: 0.85rem; font-size: 0.85rem;
@@ -445,14 +542,106 @@ body {
/* Responsive design */ /* Responsive design */
@media (max-width: 768px) { @media (max-width: 768px) {
body {
height: 100dvh;
overflow: hidden;
}
.container {
flex-direction: column;
height: 100dvh;
overflow: hidden;
}
.sidebar { .sidebar {
width: 250px; width: 100%;
max-height: 30vh;
min-height: 170px;
}
.sidebar-header {
padding: 0.7rem 0.8rem;
}
.file-tree {
min-height: 100px;
}
.main-content {
min-height: 0;
}
.editor-header {
padding: 0.65rem;
flex-wrap: wrap;
gap: 0.55rem;
align-items: stretch;
}
.file-info {
width: 100%;
justify-content: space-between;
}
.mode-toggle {
order: 3;
}
.editor-actions {
width: 100%;
order: 4;
flex-wrap: wrap;
}
.editor-actions button {
flex: 1 1 100px;
}
.run-main-toggle {
width: 100%;
justify-content: flex-start;
flex: 1 1 100%;
}
.tabs {
padding: 0.3rem 0.35rem;
}
.tab-title {
max-width: 150px;
}
.editor-container {
min-height: 46vh;
}
.cm-editor {
font-size: 13px;
} }
.modal-content { .modal-content {
width: 90%; width: 90%;
margin: 20% auto; margin: 20% auto;
} }
.led-grid {
max-height: 120px;
}
.led-sim-header {
flex-wrap: wrap;
align-items: center;
}
.led-sim-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.console-container {
height: 180px;
}
} }
/* Scrollbar styling */ /* Scrollbar styling */

View File

@@ -0,0 +1,44 @@
import importlib.util
from pathlib import Path
def _load_patterns_module():
repo_root = Path(__file__).resolve().parents[1]
module_path = repo_root / "workspace" / "code" / "led_patterns.py"
spec = importlib.util.spec_from_file_location("led_patterns", module_path)
module = importlib.util.module_from_spec(spec)
assert spec is not None and spec.loader is not None
spec.loader.exec_module(module)
return module
def test_rainbow_frame_shape_and_bounds():
patterns = _load_patterns_module()
frame = patterns.rainbow_frame(12, 3)
assert len(frame) == 12
for color in frame:
assert len(color) == 3
assert all(0 <= c <= 255 for c in color)
def test_chase_frame_has_head_and_tail():
patterns = _load_patterns_module()
frame = patterns.chase_frame(8, 5, color=(10, 20, 30), tail=(1, 2, 3))
assert len(frame) == 8
assert frame[5] == (10, 20, 30)
assert frame[4] == (1, 2, 3)
assert sum(1 for c in frame if c != (0, 0, 0)) == 2
def test_twinkle_frame_is_deterministic_for_same_inputs():
patterns = _load_patterns_module()
a = patterns.twinkle_frame(20, frame=9, seed=777, sparkles=4)
b = patterns.twinkle_frame(20, frame=9, seed=777, sparkles=4)
assert a == b
def test_twinkle_frame_varies_between_frames():
patterns = _load_patterns_module()
a = patterns.twinkle_frame(20, frame=1, seed=777, sparkles=4)
b = patterns.twinkle_frame(20, frame=2, seed=777, sparkles=4)
assert a != b

View File

@@ -7,6 +7,9 @@ import urllib.error
import urllib.request import urllib.request
import pytest import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.ui import WebDriverWait
pytest.importorskip("selenium.webdriver") pytest.importorskip("selenium.webdriver")
@@ -46,3 +49,24 @@ def test_home_page_title(driver):
) )
driver.get(f"{base}/") driver.get(f"{base}/")
assert "Python Editor" in driver.title assert "Python Editor" in driver.title
@pytest.mark.selenium
def test_editor_page_loads_core_controls(driver):
base = os.environ.get("SELENIUM_BASE_URL", "http://127.0.0.1:8080").rstrip("/")
if not _server_reachable(base):
pytest.skip(
f"No server at {base}. In another terminal run: "
"pipenv run dev (then re-run this test, or set SELENIUM_BASE_URL)."
)
driver.get(f"{base}/editor")
# If the app is configured with AUTH_ENABLED=true, unauthenticated requests redirect to /login.
if "/login" in (driver.current_url or ""):
pytest.skip("Editor requires login; set AUTH_ENABLED=false for this Selenium smoke test.")
wait = WebDriverWait(driver, 10)
wait.until(ec.presence_of_element_located((By.ID, "run-btn")))
wait.until(ec.presence_of_element_located((By.ID, "stop-btn")))
wait.until(ec.presence_of_element_located((By.ID, "file-tree")))

View File

@@ -0,0 +1,67 @@
"""LED pattern helpers inspired by embedded NeoPixel drivers."""
from __future__ import annotations
import random
Color = tuple[int, int, int]
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def wheel(pos: int) -> Color:
"""Return a rainbow color for position 0-255."""
pos = 255 - (pos & 255)
if pos < 85:
return (_clamp(255 - pos * 3), 0, _clamp(pos * 3))
if pos < 170:
pos -= 85
return (0, _clamp(pos * 3), _clamp(255 - pos * 3))
pos -= 170
return (_clamp(pos * 3), _clamp(255 - pos * 3), 0)
def rainbow_frame(led_count: int, frame: int, step: int = 4) -> list[Color]:
"""Generate one rainbow frame across all LEDs."""
if led_count <= 0:
return []
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
def chase_frame(
led_count: int,
frame: int,
color: Color = (255, 120, 0),
tail: Color = (16, 0, 0),
) -> list[Color]:
"""Generate a two-pixel chase pattern."""
if led_count <= 0:
return []
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
head = frame % led_count
trail = (head - 1) % led_count
out[trail] = tuple(_clamp(v) for v in tail) # type: ignore[assignment]
out[head] = tuple(_clamp(v) for v in color) # type: ignore[assignment]
return out
def twinkle_frame(
led_count: int,
frame: int,
base: Color = (0, 0, 8),
sparkle: Color = (255, 255, 180),
sparkles: int = 3,
seed: int = 1337,
) -> list[Color]:
"""Generate deterministic twinkle frames for testing/replay."""
if led_count <= 0:
return []
out: list[Color] = [tuple(_clamp(v) for v in base) for _ in range(led_count)] # type: ignore[list-item]
rng = random.Random(seed + frame)
for _ in range(min(max(0, sparkles), led_count)):
idx = rng.randrange(led_count)
out[idx] = tuple(_clamp(v) for v in sparkle) # type: ignore[assignment]
return out

View File

@@ -0,0 +1,50 @@
"""LED tutorial script for NeoPixel in the browser editor.
Run this file and watch the in-app NeoPixel simulator panel.
"""
from machine import Pin
import neopixel
import time
LED_COUNT = 12
np = neopixel.NeoPixel(Pin(4), LED_COUNT)
def show_step(title: str):
print(f"\n--- {title} ---")
show_step("Step 1: single colors")
np.fill((0, 0, 0))
np.write()
time.sleep(0.2)
np[0] = (255, 0, 0) # red
np[1] = (0, 255, 0) # green
np[2] = (0, 0, 255) # blue
np.write()
time.sleep(0.8)
show_step("Step 2: fill strip")
np.fill((40, 0, 120))
np.write()
time.sleep(0.6)
show_step("Step 3: moving pixel")
for i in range(len(np)):
np.fill((0, 0, 0))
np[i] = (255, 120, 0)
np.write()
time.sleep(0.06)
show_step("Step 4: simple pulse")
for level in list(range(0, 200, 20)) + list(range(200, -1, -20)):
np.fill((level, 0, level // 3))
np.write()
time.sleep(0.05)
show_step("Done")
np.fill((0, 0, 0))
np.write()
print("Tutorial complete.")

View File

@@ -0,0 +1,12 @@
"""Example ESP32 NeoPixel script using mock modules in browser."""
from machine import Pin
import neopixel
np = neopixel.NeoPixel(Pin(4), 8)
np.fill((0, 0, 0))
np[0] = (255, 0, 0)
np[1] = (0, 255, 0)
np[2] = (0, 0, 255)
np.write()

View File

@@ -0,0 +1,32 @@
"""NeoPixel time-based animation test for the browser simulator."""
from machine import Pin
import neopixel
import time
np = neopixel.NeoPixel(Pin(4), 50)
def wheel(pos: int) -> tuple[int, int, int]:
"""Generate rainbow colors across 0-255 positions."""
pos = 255 - (pos & 255)
if pos < 85:
return (255 - pos * 3, 0, pos * 3)
if pos < 170:
pos -= 85
return (0, pos * 3, 255 - pos * 3)
pos -= 170
return (pos * 3, 255 - pos * 3, 0)
print("Starting NeoPixel time test...")
for frame in range(60):
for i in range(len(np)):
np[i] = wheel((i * 256 // len(np) + frame * 4) & 255)
np.write()
time.sleep(0.08)
np.fill((0, 0, 0))
np.write()
print("NeoPixel time test complete.")

View File

@@ -0,0 +1,41 @@
"""16x16 bouncing pixel with fading trail."""
from machine import Pin
import neopixel
import time
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
trail = [[0 for _ in range(PANEL_W)] for _ in range(PANEL_H)]
x = 0
y = 0
vx = 1
vy = 1
for _frame in range(420):
for yy in range(PANEL_H):
for xx in range(PANEL_W):
trail[yy][xx] = max(0, trail[yy][xx] - 14)
trail[y][x] = 255
for yy in range(PANEL_H):
for xx in range(PANEL_W):
v = trail[yy][xx]
np[xy_to_index(xx, yy)] = (clamp8(v), clamp8(v // 2), clamp8(30))
np.write()
time.sleep(0.02)
x += vx
y += vy
if x <= 0 or x >= PANEL_W - 1:
vx *= -1
if y <= 0 or y >= PANEL_H - 1:
vy *= -1
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,37 @@
"""16x16 matrix-style rain animation."""
from machine import Pin
import neopixel
import random
import time
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
rng = random.Random(42)
heads = [rng.randrange(-PANEL_H, 0) for _ in range(PANEL_W)]
for _frame in range(320):
for y in range(PANEL_H):
for x in range(PANEL_W):
np[xy_to_index(x, y)] = (0, 0, 0)
for x in range(PANEL_W):
heads[x] += 1
if heads[x] > PANEL_H + 6:
heads[x] = rng.randrange(-PANEL_H, 0)
head_y = heads[x]
for tail in range(8):
y = head_y - tail
if 0 <= y < PANEL_H:
brightness = clamp8(255 - tail * 36)
np[xy_to_index(x, y)] = (0, brightness, 0)
np.write()
time.sleep(0.045)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,33 @@
"""16x16 rainbow wave animation."""
from machine import Pin
import neopixel
import time
from panel16_utils import PANEL_H, PANEL_W, clamp8, xy_to_index
np = neopixel.NeoPixel(Pin(4), PANEL_W * PANEL_H)
def wheel(pos: int) -> tuple[int, int, int]:
pos = 255 - (pos & 255)
if pos < 85:
return (clamp8(255 - pos * 3), 0, clamp8(pos * 3))
if pos < 170:
pos -= 85
return (0, clamp8(pos * 3), clamp8(255 - pos * 3))
pos -= 170
return (clamp8(pos * 3), clamp8(255 - pos * 3), 0)
for frame in range(240):
for y in range(PANEL_H):
for x in range(PANEL_W):
color_pos = ((x * 10) + (y * 6) + frame * 5) & 255
np[xy_to_index(x, y)] = wheel(color_pos)
np.write()
time.sleep(0.04)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,26 @@
"""Helpers for 16x16 serpentine NeoPixel panel animations.
Mapping matches the simulator's 16x16 mode:
- first LED at top-right
- row 0 goes right -> left
- row 1 goes left -> right
"""
from __future__ import annotations
PANEL_W = 16
PANEL_H = 16
def xy_to_index(x: int, y: int, width: int = PANEL_W) -> int:
"""Map panel coordinate (x, y) to LED index."""
x = int(x)
y = int(y)
if y % 2 == 0:
return y * width + (width - 1 - x)
return y * width + x
def clamp8(v: int) -> int:
return max(0, min(255, int(v)))

View File

@@ -0,0 +1,20 @@
"""Chase pattern demo using led_patterns helpers."""
from machine import Pin
import neopixel
import time
from led_patterns import chase_frame
np = neopixel.NeoPixel(Pin(4), 24)
for frame in range(120):
frame_colors = chase_frame(len(np), frame, color=(0, 220, 255), tail=(0, 40, 55))
for i, color in enumerate(frame_colors):
np[i] = color
np.write()
time.sleep(0.05)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,20 @@
"""Rainbow pattern demo using led_patterns helpers."""
from machine import Pin
import neopixel
import time
from led_patterns import rainbow_frame
np = neopixel.NeoPixel(Pin(4), 256)
for frame in range(120):
frame_colors = rainbow_frame(len(np), frame, step=5)
for i, color in enumerate(frame_colors):
np[i] = color
np.write()
time.sleep(0.05)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,26 @@
"""Twinkle pattern demo using led_patterns helpers."""
from machine import Pin
import neopixel
import time
from led_patterns import twinkle_frame
np = neopixel.NeoPixel(Pin(4), 36)
for frame in range(120):
frame_colors = twinkle_frame(
len(np),
frame,
base=(0, 0, 6),
sparkle=(255, 210, 130),
sparkles=5,
)
for i, color in enumerate(frame_colors):
np[i] = color
np.write()
time.sleep(0.08)
np.fill((0, 0, 0))
np.write()

View File

@@ -0,0 +1,68 @@
"""Compatibility pattern helpers for NeoPixel demos.
This file mirrors `workspace/code/led_patterns.py` so imports like
`from led_patterns import ...` work even in older worker sessions that only
include `/workspace/lib` in `sys.path`.
"""
from __future__ import annotations
import random
Color = tuple[int, int, int]
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def wheel(pos: int) -> Color:
pos = 255 - (pos & 255)
if pos < 85:
return (_clamp(255 - pos * 3), 0, _clamp(pos * 3))
if pos < 170:
pos -= 85
return (0, _clamp(pos * 3), _clamp(255 - pos * 3))
pos -= 170
return (_clamp(pos * 3), _clamp(255 - pos * 3), 0)
def rainbow_frame(led_count: int, frame: int, step: int = 4) -> list[Color]:
if led_count <= 0:
return []
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
def chase_frame(
led_count: int,
frame: int,
color: Color = (255, 120, 0),
tail: Color = (16, 0, 0),
) -> list[Color]:
if led_count <= 0:
return []
out: list[Color] = [(0, 0, 0) for _ in range(led_count)]
head = frame % led_count
trail = (head - 1) % led_count
out[trail] = tuple(_clamp(v) for v in tail) # type: ignore[assignment]
out[head] = tuple(_clamp(v) for v in color) # type: ignore[assignment]
return out
def twinkle_frame(
led_count: int,
frame: int,
base: Color = (0, 0, 8),
sparkle: Color = (255, 255, 180),
sparkles: int = 3,
seed: int = 1337,
) -> list[Color]:
if led_count <= 0:
return []
out: list[Color] = [tuple(_clamp(v) for v in base) for _ in range(led_count)] # type: ignore[list-item]
rng = random.Random(seed + frame)
for _ in range(min(max(0, sparkles), led_count)):
idx = rng.randrange(led_count)
out[idx] = tuple(_clamp(v) for v in sparkle) # type: ignore[assignment]
return out

19
workspace/lib/machine.py Normal file
View File

@@ -0,0 +1,19 @@
"""Minimal MicroPython-style machine module mock for browser simulation."""
class Pin:
IN = 0
OUT = 1
PULL_UP = 2
PULL_DOWN = 3
def __init__(self, pin_id: int, mode: int = OUT, value: int = 0):
self.id = int(pin_id)
self.mode = int(mode)
self._value = 1 if value else 0
def value(self, new_value=None):
if new_value is None:
return self._value
self._value = 1 if int(new_value) else 0
return self._value

56
workspace/lib/neopixel.py Normal file
View File

@@ -0,0 +1,56 @@
"""NeoPixel mock for Pyodide/browser execution.
Supports a useful subset of MicroPython's neopixel.NeoPixel API:
- NeoPixel(pin, n, bpp=3, timing=1)
- __setitem__, __getitem__, __len__
- fill(color)
- write() # prints current pixel buffer snapshot
"""
import json
def _normalize_color(value, bpp: int):
if not hasattr(value, "__iter__"):
raise TypeError("Color must be an iterable, e.g. (r, g, b)")
parts = [int(v) for v in value]
if len(parts) != bpp:
raise ValueError(f"Expected {bpp} color channels, got {len(parts)}")
out = []
for channel in parts:
out.append(max(0, min(255, channel)))
return tuple(out)
class NeoPixel:
def __init__(self, pin, n: int, bpp: int = 3, timing: int = 1):
self.pin = pin
self.n = int(n)
self.bpp = int(bpp)
self.timing = int(timing)
self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
def __len__(self):
return self.n
def __getitem__(self, index):
return self._buf[int(index)]
def __setitem__(self, index, color):
idx = int(index)
self._buf[idx] = _normalize_color(color, self.bpp)
def fill(self, color):
c = _normalize_color(color, self.bpp)
for i in range(self.n):
self._buf[i] = c
def write(self):
pin_id = getattr(self.pin, "id", self.pin)
payload = {
"type": "neopixel",
"pin": pin_id,
"pixels": [list(pixel) for pixel in self._buf],
"bpp": self.bpp,
}
print("[neopixel-json]" + json.dumps(payload))