From e4c811f51d669ec117f50b93307b0c5530b7569c Mon Sep 17 00:00:00 2001 From: jimmy Date: Fri, 1 May 2026 20:24:05 +1200 Subject: [PATCH] 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 --- .cursor/hooks.json | 12 + .cursor/hooks/dev-reload-touch.sh | 8 + .dockerignore | 14 + Dockerfile | 19 ++ LED_TUTORIAL.md | 75 +++++ Pipfile | 1 + Pipfile.lock | 90 +++++- README.md | 61 ++++ docker-compose.yml | 16 + src/static/.reload-token | 1 + src/static/index.html | 29 +- src/static/pyodide-worker.js | 34 ++- src/static/script.js | 390 ++++++++++++++++++++++--- src/static/styles.css | 213 +++++++++++++- tests/test_led_patterns.py | 44 +++ tests/test_selenium_smoke.py | 24 ++ workspace/code/led_patterns.py | 67 +++++ workspace/code/led_tutorial.py | 50 ++++ workspace/code/neopixel_demo.py | 12 + workspace/code/neopixel_time_test.py | 32 ++ workspace/code/panel16_bounce.py | 41 +++ workspace/code/panel16_matrix_rain.py | 37 +++ workspace/code/panel16_rainbow_wave.py | 33 +++ workspace/code/panel16_utils.py | 26 ++ workspace/code/pattern_chase_demo.py | 20 ++ workspace/code/pattern_rainbow_demo.py | 20 ++ workspace/code/pattern_twinkle_demo.py | 26 ++ workspace/lib/led_patterns.py | 68 +++++ workspace/lib/machine.py | 19 ++ workspace/lib/neopixel.py | 56 ++++ 30 files changed, 1478 insertions(+), 60 deletions(-) create mode 100644 .cursor/hooks.json create mode 100755 .cursor/hooks/dev-reload-touch.sh create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 LED_TUTORIAL.md create mode 100644 docker-compose.yml create mode 100644 src/static/.reload-token create mode 100644 tests/test_led_patterns.py create mode 100644 workspace/code/led_patterns.py create mode 100644 workspace/code/led_tutorial.py create mode 100644 workspace/code/neopixel_demo.py create mode 100644 workspace/code/neopixel_time_test.py create mode 100644 workspace/code/panel16_bounce.py create mode 100644 workspace/code/panel16_matrix_rain.py create mode 100644 workspace/code/panel16_rainbow_wave.py create mode 100644 workspace/code/panel16_utils.py create mode 100644 workspace/code/pattern_chase_demo.py create mode 100644 workspace/code/pattern_rainbow_demo.py create mode 100644 workspace/code/pattern_twinkle_demo.py create mode 100644 workspace/lib/led_patterns.py create mode 100644 workspace/lib/machine.py create mode 100644 workspace/lib/neopixel.py diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 0000000..8490b03 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "afterFileEdit": [ + { + "command": ".cursor/hooks/dev-reload-touch.sh", + "timeout": 5, + "matcher": "Write|TabWrite" + } + ] + } +} diff --git a/.cursor/hooks/dev-reload-touch.sh b/.cursor/hooks/dev-reload-touch.sh new file mode 100755 index 0000000..39921ea --- /dev/null +++ b/.cursor/hooks/dev-reload-touch.sh @@ -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" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..186d081 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitignore +.venv +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.cursor/ +tests/ +agent-transcripts/ +data/ +*.pyc +*.pyo +*.pyd diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4034e25 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LED_TUTORIAL.md b/LED_TUTORIAL.md new file mode 100644 index 0000000..ceaa526 --- /dev/null +++ b/LED_TUTORIAL.md @@ -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. diff --git a/Pipfile b/Pipfile index 48f870d..1ceb9d0 100644 --- a/Pipfile +++ b/Pipfile @@ -8,6 +8,7 @@ pytest = "*" pytest-cov = "*" httpx = "*" selenium = "*" +playwright = "*" [packages] fastapi = "*" diff --git a/Pipfile.lock b/Pipfile.lock index f7c0140..0ca5574 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f9de958b9982b3a30c107c2c02746aece031522dc429be84e4f9589c73404282" + "sha256": "ce177325185c5a9e04401b9368766f419921dfc5f9de934820507dae79998082" }, "pipfile-spec": 6, "requires": { @@ -586,6 +586,71 @@ "markers": "python_version >= '3.10'", "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": { "hashes": [ "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", @@ -643,6 +708,21 @@ "markers": "python_version >= '3.8'", "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": { "hashes": [ "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", @@ -651,6 +731,14 @@ "markers": "python_version >= '3.9'", "version": "==1.6.0" }, + "pyee": { + "hashes": [ + "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", + "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228" + ], + "markers": "python_version >= '3.8'", + "version": "==13.0.1" + }, "pygments": { "hashes": [ "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", diff --git a/README.md b/README.md index ed483b7..d7ce749 100644 --- a/README.md +++ b/README.md @@ -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). +## 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. **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/`) - `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. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ba85a63 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/src/static/.reload-token b/src/static/.reload-token new file mode 100644 index 0000000..75786fb --- /dev/null +++ b/src/static/.reload-token @@ -0,0 +1 @@ +1777623664358 diff --git a/src/static/index.html b/src/static/index.html index 564fbcc..387b880 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -5,7 +5,7 @@ Python Editor - +
@@ -29,6 +29,7 @@
Browser · Pyodide + LSP: n/a
Home @@ -36,9 +37,14 @@
- + +
@@ -49,6 +55,19 @@
+ +
Console Output

@@ -67,6 +86,6 @@
     
- + diff --git a/src/static/pyodide-worker.js b/src/static/pyodide-worker.js index 5d36e34..4300ea8 100644 --- a/src/static/pyodide-worker.js +++ b/src/static/pyodide-worker.js @@ -77,6 +77,38 @@ json.dumps(out) 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') { const files = payload.files && typeof payload.files === 'object' ? payload.files : {}; 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: fh.write(str(body)) -for entry in ("/workspace/lib", "/workspace"): +for entry in ("/workspace/code", "/workspace/lib", "/workspace"): if entry not in sys.path: sys.path.insert(0, entry) diff --git a/src/static/script.js b/src/static/script.js index 3c95c96..fc93e2e 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -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 { constructor() { @@ -7,6 +9,7 @@ class TextEditor { this.pyWorkerMsgId = 0; this.pyWorkerHandlers = new Map(); this.pyodideInited = false; + this.workerWarmupPromise = null; this.pyRunGeneration = 0; this.editor = null; this.currentFilePath = null; @@ -26,11 +29,19 @@ class TextEditor { this.completionIndex = 0; this.completionOpen = false; this.completionRequestId = 0; + this.diagnosticsRequestId = 0; + this.diagnosticsTimer = null; this.draggedItemPath = null; this.draggedItemIsDirectory = false; this.dragHoverExpandTimer = null; this.dragHoverTargetPath = 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(); } @@ -47,10 +58,44 @@ class TextEditor { this.loadSessionState(); this.setupEditor(); this.setupEventListeners(); + this.setupDevAutoReload(); this.updateRunButtonState(); + this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics'); + this.prewarmPyWorker(); 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 = {}) { const next = { ...init }; const headers = new Headers(init.headers || {}); @@ -77,7 +122,7 @@ class TextEditor { ensurePyWorker() { if (!this.pyWorker) { - const worker = new Worker('/static/pyodide-worker.js'); + const worker = new Worker('/static/pyodide-worker.js?v=3'); this.pyWorker = worker; worker.onmessage = (event) => this.handlePyWorkerMessage(event); } @@ -126,6 +171,19 @@ class TextEditor { 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() { try { const raw = localStorage.getItem(this.sessionStorageKey); @@ -139,11 +197,13 @@ class TextEditor { saveSessionState() { try { - const runFileSelect = document.getElementById('run-file-select'); + const runMainCheckbox = document.getElementById('run-main-checkbox'); + const panelModeCheckbox = document.getElementById('panel-16x16-checkbox'); const session = { openTabPaths: this.openTabs.map((tab) => tab.path), activeTabPath: this.activeTabPath, - selectedRunFile: runFileSelect ? runFileSelect.value : '', + runMainChecked: Boolean(runMainCheckbox && runMainCheckbox.checked), + panel16x16Checked: Boolean(panelModeCheckbox && panelModeCheckbox.checked), expandedDirs: Array.from(this.expandedDirs || []), selectedPath: this.selectedPath || '', selectedIsDirectory: Boolean(this.selectedIsDirectory) @@ -170,9 +230,13 @@ class TextEditor { if (session.activeTabPath && this.findTab(session.activeTabPath)) { this.switchToTab(session.activeTabPath); } - const runFileSelect = document.getElementById('run-file-select'); - if (runFileSelect && typeof session.selectedRunFile === 'string') { - runFileSelect.value = session.selectedRunFile; + const runMainCheckbox = document.getElementById('run-main-checkbox'); + if (runMainCheckbox && typeof session.runMainChecked === 'boolean') { + runMainCheckbox.checked = session.runMainChecked; + } + const panelModeCheckbox = document.getElementById('panel-16x16-checkbox'); + if (panelModeCheckbox && typeof session.panel16x16Checked === 'boolean') { + panelModeCheckbox.checked = session.panel16x16Checked; } this.saveSessionState(); } @@ -256,7 +320,7 @@ class TextEditor { setupEditor() { this.editor = new EditorView({ doc: '', - extensions: [basicSetup], + extensions: [basicSetup, this.languageCompartment.of([])], parent: document.getElementById('editor') }); @@ -323,10 +387,88 @@ class TextEditor { this.hideCompletionDropdown(); this.updateActiveTabContent(); this.markAsModified(); + this.scheduleAutoCompletion(); + this.scheduleDiagnostics(); } })(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() { const cursor = this.editor.state.selection.main.head; const lineInfo = this.editor.state.doc.lineAt(cursor); @@ -566,6 +708,27 @@ class TextEditor { 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', () => { this.createNewFile(); }); @@ -590,15 +753,26 @@ class TextEditor { window.location.href = '/'; }); - document.getElementById('run-file-select').addEventListener('change', (event) => { - const selectedPath = event.target.value; - if (!selectedPath) return; - const tab = this.findTab(selectedPath); - if (tab) { - this.switchToTab(selectedPath); - } - this.saveSessionState(); - }); + const runMainCheckbox = document.getElementById('run-main-checkbox'); + if (runMainCheckbox) { + runMainCheckbox.addEventListener('change', () => { + this.saveSessionState(); + this.updateRunButtonState(); + }); + } + 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.renderRunFileSelect(); 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 = ''; - 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) { return this.openTabs.find((tab) => tab.path === path); } @@ -705,10 +859,12 @@ class TextEditor { } }); this.ignoreNextChange = false; + this.setLanguageForPath(path); const currentFileEl = document.getElementById('current-file'); if (currentFileEl) currentFileEl.textContent = path; this.setEditorReadOnly(this.isReadOnlyPath(path)); this.updateRunButtonState(); + this.scheduleDiagnostics(); if (tab.isModified) { this.markAsModified(); } else { @@ -737,6 +893,7 @@ class TextEditor { this.activeTabPath = null; this.currentFilePath = null; this.setEditorReadOnly(false); + this.setLanguageForPath(''); this.ignoreNextChange = true; this.editor.dispatch({ changes: { @@ -749,6 +906,7 @@ class TextEditor { const currentFileEl = document.getElementById('current-file'); if (currentFileEl) currentFileEl.textContent = 'No file selected'; this.updateRunButtonState(); + this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics'); this.markAsSaved(); } } @@ -1021,12 +1179,14 @@ class TextEditor { } }); this.ignoreNextChange = false; + this.setLanguageForPath(filePath); this.markAsSaved(); this.setEditorReadOnly(this.isReadOnlyPath(filePath)); const currentFileEl = document.getElementById('current-file'); if (currentFileEl) currentFileEl.textContent = filePath; this.updateRunButtonState(); + this.scheduleDiagnostics(); this.renderTabs(); this.saveSessionState(); } catch (error) { @@ -1107,6 +1267,7 @@ class TextEditor { const currentFileEl = document.getElementById('current-file'); if (currentFileEl) currentFileEl.textContent = 'No file selected'; this.updateRunButtonState(); + this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics'); } this.directoryCache.clear(); @@ -1129,6 +1290,7 @@ class TextEditor { } else { this.activeTabPath = null; this.currentFilePath = null; + this.setLanguageForPath(''); this.ignoreNextChange = true; this.editor.dispatch({ changes: { @@ -1150,9 +1312,13 @@ class TextEditor { updateRunButtonState() { const runButton = document.getElementById('run-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')); - runButton.disabled = !hasPythonFile || this.isPythonRunning; + const canRun = runMainSelected || hasPythonFile; + runButton.disabled = !canRun; stopButton.disabled = !this.isPythonRunning; + this.updateLedWindowControls(); } 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 = ` + +
Waiting for frame...
+
+ `; + 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) { 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; this.consoleFlushTimer = setTimeout(() => { const consoleOutput = document.getElementById('console-output'); @@ -1185,8 +1489,8 @@ class TextEditor { } async runPython() { - const runFileSelect = document.getElementById('run-file-select'); - const selectedRunFile = runFileSelect && runFileSelect.value ? runFileSelect.value : this.currentFilePath; + const runMainCheckbox = document.getElementById('run-main-checkbox'); + const selectedRunFile = runMainCheckbox && runMainCheckbox.checked ? 'code/main.py' : this.currentFilePath; if (selectedRunFile && selectedRunFile !== this.currentFilePath && this.findTab(selectedRunFile)) { this.switchToTab(selectedRunFile); } @@ -1214,6 +1518,7 @@ class TextEditor { files[tab.path] = tab.content; } } + this.maybePrepareLedWindow(files); this.clearConsole(); const args = []; this.appendConsoleOutput([`$ pyodide ${selectedRunFile}\n`]); @@ -1228,6 +1533,7 @@ class TextEditor { }); if (generation === this.pyRunGeneration) { this.appendConsoleOutput(['\n[Finished]\n']); + this.closeLedPanelWindow(); } } catch (error) { this.appendConsoleOutput([`\n${error.message}\n`]); @@ -1244,8 +1550,10 @@ class TextEditor { this.pyRunGeneration += 1; this.disposePyWorker(); this.isPythonRunning = false; + this.closeLedPanelWindow(); this.appendConsoleOutput(['\n[Execution stopped — Pyodide worker was reset]\n']); this.updateRunButtonState(); + this.prewarmPyWorker(); } async deleteSelected() { diff --git a/src/static/styles.css b/src/static/styles.css index b1999ed..8f0c2db 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -7,13 +7,14 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f5f5f5; - height: 100vh; + height: 100dvh; overflow: hidden; } .container { display: flex; - height: 100vh; + height: 100dvh; + overflow: hidden; } /* Sidebar */ @@ -23,6 +24,7 @@ body { color: white; display: flex; flex-direction: column; + min-height: 0; } .sidebar-header { @@ -117,6 +119,9 @@ body { display: flex; flex-direction: column; background-color: white; + min-width: 0; + min-height: 0; + overflow: hidden; } .editor-header { @@ -191,15 +196,6 @@ body { 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 { padding: 0.5rem 1rem; border: 1px solid #e2e8f0; @@ -225,10 +221,28 @@ body { 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 { flex: 1; position: relative; min-height: 0; + overflow: hidden; } .hidden { @@ -350,6 +364,89 @@ body { 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 { padding: 0.5rem 0.75rem; font-size: 0.85rem; @@ -445,14 +542,106 @@ body { /* Responsive design */ @media (max-width: 768px) { + body { + height: 100dvh; + overflow: hidden; + } + + .container { + flex-direction: column; + height: 100dvh; + overflow: hidden; + } + .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 { width: 90%; 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 */ diff --git a/tests/test_led_patterns.py b/tests/test_led_patterns.py new file mode 100644 index 0000000..014e8bc --- /dev/null +++ b/tests/test_led_patterns.py @@ -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 diff --git a/tests/test_selenium_smoke.py b/tests/test_selenium_smoke.py index 58b7db6..59bdf70 100644 --- a/tests/test_selenium_smoke.py +++ b/tests/test_selenium_smoke.py @@ -7,6 +7,9 @@ import urllib.error import urllib.request 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") @@ -46,3 +49,24 @@ def test_home_page_title(driver): ) driver.get(f"{base}/") 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"))) diff --git a/workspace/code/led_patterns.py b/workspace/code/led_patterns.py new file mode 100644 index 0000000..875b91a --- /dev/null +++ b/workspace/code/led_patterns.py @@ -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 diff --git a/workspace/code/led_tutorial.py b/workspace/code/led_tutorial.py new file mode 100644 index 0000000..6998b39 --- /dev/null +++ b/workspace/code/led_tutorial.py @@ -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.") diff --git a/workspace/code/neopixel_demo.py b/workspace/code/neopixel_demo.py new file mode 100644 index 0000000..5a84044 --- /dev/null +++ b/workspace/code/neopixel_demo.py @@ -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() diff --git a/workspace/code/neopixel_time_test.py b/workspace/code/neopixel_time_test.py new file mode 100644 index 0000000..2b57df6 --- /dev/null +++ b/workspace/code/neopixel_time_test.py @@ -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.") diff --git a/workspace/code/panel16_bounce.py b/workspace/code/panel16_bounce.py new file mode 100644 index 0000000..cd2a5bb --- /dev/null +++ b/workspace/code/panel16_bounce.py @@ -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() diff --git a/workspace/code/panel16_matrix_rain.py b/workspace/code/panel16_matrix_rain.py new file mode 100644 index 0000000..132338b --- /dev/null +++ b/workspace/code/panel16_matrix_rain.py @@ -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() diff --git a/workspace/code/panel16_rainbow_wave.py b/workspace/code/panel16_rainbow_wave.py new file mode 100644 index 0000000..b6e0fbe --- /dev/null +++ b/workspace/code/panel16_rainbow_wave.py @@ -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() diff --git a/workspace/code/panel16_utils.py b/workspace/code/panel16_utils.py new file mode 100644 index 0000000..ba48297 --- /dev/null +++ b/workspace/code/panel16_utils.py @@ -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))) diff --git a/workspace/code/pattern_chase_demo.py b/workspace/code/pattern_chase_demo.py new file mode 100644 index 0000000..c47ceff --- /dev/null +++ b/workspace/code/pattern_chase_demo.py @@ -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() diff --git a/workspace/code/pattern_rainbow_demo.py b/workspace/code/pattern_rainbow_demo.py new file mode 100644 index 0000000..421e66b --- /dev/null +++ b/workspace/code/pattern_rainbow_demo.py @@ -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() diff --git a/workspace/code/pattern_twinkle_demo.py b/workspace/code/pattern_twinkle_demo.py new file mode 100644 index 0000000..fbe38ae --- /dev/null +++ b/workspace/code/pattern_twinkle_demo.py @@ -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() diff --git a/workspace/lib/led_patterns.py b/workspace/lib/led_patterns.py new file mode 100644 index 0000000..dcf1667 --- /dev/null +++ b/workspace/lib/led_patterns.py @@ -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 diff --git a/workspace/lib/machine.py b/workspace/lib/machine.py new file mode 100644 index 0000000..eb0a242 --- /dev/null +++ b/workspace/lib/machine.py @@ -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 diff --git a/workspace/lib/neopixel.py b/workspace/lib/neopixel.py new file mode 100644 index 0000000..9446b38 --- /dev/null +++ b/workspace/lib/neopixel.py @@ -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))