Compare commits

..

8 Commits

Author SHA1 Message Date
551c3d1efc Add PyPI package installer, file-tree context menu, and demos UI
Expose micropip installs from the menu, right-click actions on the file
tree, local folder/file open shortcuts, and browse demos/ alongside code/.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:55 +12:00
8c45097ec5 Support demos/ in the local-mode workspace client
Treat demos as a writable top-level folder alongside code/, seed it on
first open, and ship browser_fetch.py with bundled lib stubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:45 +12:00
d38f819c49 Add browser_fetch helpers and async HTTP demos for Pyodide
Ship pyfetch-based fetch utilities in lib/, run asyncio scripts via
nest-asyncio in the worker, and add sample demos for HTTPS in the browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:34 +12:00
98fa4260d4 Relocate bundled demo sources into bundled-demos/demo/
Mirror bundled-lib/ by keeping manifest.json at the bundle root and
shipping sample .py files from a demo/ subdirectory.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:17 +12:00
e3400120d3 Seed bundled demos under top-level demos/ instead of code/
Move canonical sample scripts to a sibling folder of code/ and lib/ so
user projects stay separate from shipped examples. Backend seeding,
writable paths, and docs follow the new layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:34:12 +12:00
d355174f5a Mobile UX polish: scrollable column, menu workspace section, pin tap fixes
The phone layout had three problems that compounded when running ADC /
Pin / Serial demos: the editor refused to shrink past 42vh so panels
spilled over a clipped console, the workspace badge was crammed full of
buttons, and the IN-pin toggle button was unreliable to tap.

- styles.css: on `<= 768px`, make `.main-content` scroll vertically and
  give the editor a fixed 50vh height (drops to 32vh via `:has()` when a
  simulator panel is open). All panels + console pin to `flex: 0 0 auto`
  so flexbox stops squashing them. Inner panel scroll caps tightened to
  22vh so two stacked panels don't push the console below the fold.
  `.pin-toggle` gets `touch-action: manipulation` + `user-select: none`
  to fix iOS taps inside scrollable parents.

- script.js: pin button now has a `pointerup` backup with a 300 ms
  debounce alongside `click` (Safari sometimes drops `click` on small
  buttons inside scroll containers). The `⋮` workspace menu auto-closes
  on outside `pointerdown` as well as `click`, so the open dropdown
  can't sit on top of the Pin panel and absorb taps.

- script.js / index.html / styles.css: move every Workspace action
  (Export, Import, Reset demos, plus the local-mode-only Folder…,
  Reconnect, IndexedDB swap, Exit) out of the badge and into a new
  "Workspace" section in the `⋮` menu. Badge keeps just the storage
  label. Adds `.menu-separator`, `.menu-section-label`, `.menu-note`,
  and `.menu-action` styles; removes the now-unused
  `.workspace-badge-action` / `-exit` / `-note` rules.

- bundled-demos/pin_demo.py: pin 4 is now driven exclusively by the
  IRQ handler, so it stays steady until the IN button is pressed —
  previously it auto-flashed via on/off in the loop, which made the
  IRQ effect indistinguishable from the existing animation. The IRQ
  handler also no longer prints on every press (the panel indicator
  is the feedback).

Cache busters: styles.css 32 -> 36, script.js 57 -> 59.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 07:16:45 +12:00
7ee15f8eac Stop tracking workspace/; bundled-demos/ is the canonical demo source
`workspace/` is runtime state (per-user folders, no-auth dev's `code/`)
and shouldn't be in git. The same files were previously committed under
both `workspace/code/` and `src/static/bundled-demos/`, which forced a
Docker `diff -q` sync check and leaked user-scoped paths into version
control.

- /workspace/ added to .gitignore; all previously tracked files removed
  via `git rm --cached`.
- src/static/bundled-demos/ becomes the single source of truth: panel16
  demos, led_tutorial, led_patterns, neopixel demos, and main.py move
  here alongside the existing canonical demos.
- New BUNDLED_DEMOS_DIR config; user_workspace seeders read from it.
- main.py lifespan seeds WORKSPACE_ROOT/code/ on startup so a fresh
  clone running `pipenv run dev` still gets the full sample set
  (existing files never overwritten — user edits survive restarts).
- Dockerfile drops `COPY workspace` and the diff sanity check.
- README/LED_TUTORIAL repointed at the new canonical paths.
- test_led_patterns loads led_patterns.py from bundled-demos.
- test_api uses mkdir(exist_ok=True) for `code/` (startup pre-creates).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 06:55:59 +12:00
b8d62e01d9 Fix Docker build by relying on committed bundled-demos copies
The previous build step copied `workspace/code/<demo>.py` into
`src/static/bundled-demos/` at image-build time. That failed for some
build contexts where `workspace/` wasn't materialised when the RUN
ran (cp: cannot stat ... No such file or directory).

Since `src/static/bundled-demos/*.py` are version-controlled and ship
with `COPY src ./src`, the runtime image already has them. Replace the
fragile cp loop with a `diff -q` invariant that fails the build if a
canonical demo drifted between `workspace/code/` and
`src/static/bundled-demos/`, catching mismatches at build time instead
of runtime.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-10 06:44:53 +12:00
45 changed files with 2352 additions and 792 deletions

7
.gitignore vendored
View File

@@ -176,6 +176,9 @@ cython_debug/
# PyPI configuration file
.pypirc
# Editor / workspace (generated locally)
workspace/users/
# Editor runtime state — `workspace/` holds user files (auth-mode per-user
# folders, no-auth dev's `code/`); the canonical demo source lives under
# `src/static/bundled-demos/demo/` seeds into `workspace/demos/` (and per-user `users/.../demos/`)
# on startup. Nothing under `workspace/` should ever be committed.
/workspace/
src/static/.reload-token

View File

@@ -14,14 +14,8 @@ RUN pipenv install --system --deploy
COPY src ./src
COPY lib ./lib
RUN mkdir -p src/static/bundled-lib && cp -f lib/*.py src/static/bundled-lib/
COPY workspace ./workspace
# Mirror canonical demo files into the static bundle so the editor's
# "Reset demos" button works from a static-only host too.
RUN mkdir -p src/static/bundled-demos && \
for f in pattern_rainbow_demo.py pattern_twinkle_demo.py pattern_chase_demo.py \
adc_slider_demo.py pin_demo.py serial_demo.py; do \
cp -f "workspace/code/$f" "src/static/bundled-demos/$f"; \
done
# `workspace/` is runtime/user state (gitignored) and is created on demand
# at app startup — the image does not need to ship it.
EXPOSE 8080

View File

@@ -5,7 +5,7 @@ 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.
Open `demos/led_tutorial.py` in the editor while reading this guide. (Source of truth: `src/static/bundled-demos/demo/led_tutorial.py` — the top-level `demos/` folder is seeded from there on first run.)
## 1) Basic setup

View File

@@ -110,7 +110,7 @@ The browser runtime ships MicroPython-style stubs in repo `lib/` (they appear as
- `utime` — `ticks_ms`, `ticks_diff`, `ticks_add`, `sleep_ms`, `sleep_us`, `sleep`
- `micropython.const` — no-op helper for ported constant declarations
Use them from scripts in `workspace/code` like typical ESP32 / MicroPython examples:
Use them from the top-level `demos/` folder (sibling of `code/` and `lib/`; first run seeds from `src/static/bundled-demos/demo/`) like typical ESP32 / MicroPython examples:
```python
from machine import Pin
@@ -132,18 +132,20 @@ Simulator modes:
- rows zig-zag left/right.
- The 16x16 popup closes automatically on **Stop** or when script execution finishes.
Tutorial files:
Tutorial files (canonical source — committed under `src/static/bundled-demos/demo/`; copies appear under top-level `demos/` on first run):
- `LED_TUTORIAL.md` - step-by-step NeoPixel tutorial
- `workspace/code/led_tutorial.py` - runnable guided LED example
- `workspace/code/led_patterns.py` - shared pattern helpers (used by automated tests); each `pattern_*_demo.py` duplicates what it needs and uses only Python stdlib + `machine` / `neopixel` / `time`
- `workspace/code/pattern_rainbow_demo.py` - rainbow animation (self-contained)
- `workspace/code/pattern_chase_demo.py` - Knight Riderstyle bouncing scanner (self-contained)
- `workspace/code/pattern_twinkle_demo.py` - twinkle animation (self-contained)
- `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
- `led_tutorial.py` - runnable guided LED example
- `led_patterns.py` - shared pattern helpers (used by automated tests); each `pattern_*_demo.py` duplicates what it needs and uses only Python stdlib + `machine` / `neopixel` / `time`
- `pattern_rainbow_demo.py` - rainbow animation (self-contained)
- `pattern_chase_demo.py` - Knight Riderstyle bouncing scanner (self-contained)
- `pattern_twinkle_demo.py` - twinkle animation (self-contained)
- `panel16_utils.py` - helpers for 16x16 serpentine mapping
- `panel16_rainbow_wave.py` - 16x16 rainbow wave
- `panel16_bounce.py` - 16x16 bouncing pixel with trail
- `panel16_matrix_rain.py` - 16x16 matrix rain effect
> `workspace/` is gitignored runtime state. To edit the **shipped** demo source, edit `src/static/bundled-demos/demo/<file>.py` and re-run "Reset demos" in the editor (or restart the dev server with an empty `workspace/demos/`).
## Dev auto-reload hook

38
lib/browser_fetch.py Normal file
View File

@@ -0,0 +1,38 @@
"""Browser-friendly async HTTP for the Pyodide worker.
These helpers use ``pyodide.http.pyfetch``, which maps to the browser
``fetch`` API (no Python TLS stack). Prefer them for ``https://`` in
Pyodide: ``aiohttp`` in the worker often raises
``RuntimeError('SSL is not supported.')`` for HTTPS even though the wheel
exists, because user-level SSL is not wired the same as on CPython.
The browser's normal rules apply: the page is served over HTTPS so
``http://`` URLs are blocked as mixed content, and the response host must
send permissive CORS headers (e.g. ``Access-Control-Allow-Origin``) or the
browser hides the body even if the request succeeded.
"""
from __future__ import annotations
from typing import Any
async def fetch_text(url: str) -> str:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.text()
async def fetch_bytes(url: str) -> bytes:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.bytes()
async def fetch_json(url: str, **kwargs: Any) -> Any:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.json(**kwargs)

View File

@@ -28,3 +28,11 @@ load_env_file(PROJECT_ROOT / ".env")
_default_workspace = PROJECT_ROOT / "workspace"
WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", str(_default_workspace))).resolve()
# Canonical demo bundle root (`manifest.json` lives here). Sample `.py`
# sources live under `demo/` (same idea as `bundled-lib/` for shared modules).
# They ship with the static bundle (`/static/bundled-demos/...`) so a
# static-only host also exposes them. `workspace/` is intentionally NOT used
# for canonical data — it is treated as runtime/user state and is gitignored.
BUNDLED_DEMOS_DIR = STATIC_DIR / "bundled-demos"
BUNDLED_DEMOS_CODE_DIR = BUNDLED_DEMOS_DIR / "demo"

View File

@@ -6,7 +6,7 @@ from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
from editor_app.config import STATIC_DIR
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
from editor_app.db.models import Base
from editor_app.db.session import get_engine
from editor_app.deps import require_api_access
@@ -14,11 +14,19 @@ from editor_app.routers.auth_routes import router as auth_router
from editor_app.routers.files import router as files_router
from editor_app.routers.frontend import router as frontend_router
from editor_app.routers.users_admin import router as users_admin_router
from editor_app.services import accounts
from editor_app.services import accounts, user_workspace
@asynccontextmanager
async def lifespan(_app: FastAPI):
# `workspace/` is gitignored runtime state. On a fresh clone it doesn't
# exist, and in no-auth dev mode the file tree would otherwise be empty
# — seed every bundled demo under top-level `demos/` (next to `code/`)
# so `pipenv run dev` after `git clone` Just Works without needing user
# accounts. Existing files are left alone (user edits are preserved).
WORKSPACE_ROOT.mkdir(parents=True, exist_ok=True)
user_workspace.seed_all_bundled_demos(WORKSPACE_ROOT)
engine = get_engine()
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:

View File

@@ -7,7 +7,7 @@ from editor_app import config
from editor_app.models import FileInfo
LIB_DIR_NAME = "lib"
WRITABLE_ROOTS = {"code"}
WRITABLE_ROOTS = {"code", "demos"}
def _workspace_root(workspace_root: Path | None = None) -> Path:
@@ -33,6 +33,9 @@ def normalize_relative_path(relative_path: str) -> str:
if len(parts) >= 2 and parts[0] == "code":
while len(parts) >= 2 and parts[0] == parts[1] == "code":
parts.pop(1)
if len(parts) >= 2 and parts[0] == "demos":
while len(parts) >= 2 and parts[0] == parts[1] == "demos":
parts.pop(1)
return "/".join(parts)
@@ -84,7 +87,7 @@ def _ensure_writable_path(target_path: Path, workspace_root: Path | None = None)
if not _is_writable_path(target_path, workspace_root):
raise HTTPException(
status_code=403,
detail="Only code/ is writable (lib is read-only)",
detail="Only code/ and demos/ are writable (lib is read-only)",
)

View File

@@ -6,13 +6,14 @@ from pathlib import Path
from editor_app import config
_BUNDLED_DEMO_PY_DIR = config.BUNDLED_DEMOS_CODE_DIR
# Top-level workspace folder for shipped samples (sibling of ``code/``, like ``lib/``).
_EDITOR_DEMOS_ROOT = "demos"
DEFAULT_MAIN_PY = 'print("Hello, World!")\n'
# Self-contained demos copied from shipped `workspace/code/` (stdlib + machine/neopixel/time only).
# New accounts get a copy of each one in their own `code/` folder so the
# editor has something to show on first login. They're treated as
# starting points — users can edit/delete freely without affecting the
# shipped originals.
# Self-contained demos copied from static ``bundled-demos/demo/`` (stdlib + machine/neopixel/time only).
# New accounts get a copy under ``<user_root>/demos/`` (same level as ``code/``).
_CANONICAL_DEMO_FILENAMES = (
"pattern_rainbow_demo.py",
"pattern_twinkle_demo.py",
@@ -20,6 +21,8 @@ _CANONICAL_DEMO_FILENAMES = (
"adc_slider_demo.py",
"pin_demo.py",
"serial_demo.py",
"async_fetch_demo.py",
"aiohttp_fetch_demo.py",
)
@@ -33,11 +36,22 @@ def user_workspace_root(user_id: int, username: str, workspace_root: Path | None
return root / "users" / safe_workspace_leaf(username, user_id)
def _seed_canonical_demos_into_code(code_dir: Path) -> None:
src_root = config.PROJECT_ROOT.resolve() / "workspace" / "code"
def _seed_canonical_demos(user_root: Path) -> None:
"""Copy bundled demos into ``demos/`` if missing.
Reads from `BUNDLED_DEMOS_CODE_DIR` (``src/static/bundled-demos/demo/``).
Skips if the file already exists under ``demos/``, legacy ``code/<name>.py``,
or old ``code/demos/<name>.py``.
"""
src_root = _BUNDLED_DEMO_PY_DIR.resolve()
demos_dir = user_root / _EDITOR_DEMOS_ROOT
code_dir = user_root / "code"
demos_dir.mkdir(parents=True, exist_ok=True)
for filename in _CANONICAL_DEMO_FILENAMES:
dst = code_dir / filename
if dst.exists():
dst = demos_dir / filename
legacy_flat = code_dir / filename
legacy_nested = code_dir / "demos" / filename
if dst.exists() or legacy_flat.exists() or legacy_nested.exists():
continue
src = src_root / filename
if src.is_file():
@@ -45,13 +59,50 @@ def _seed_canonical_demos_into_code(code_dir: Path) -> None:
def ensure_default_code_main(user_root: Path) -> None:
"""Ensure code/ has main.py and self-contained NeoPixel demos (copied from repo workspace/code/)."""
"""Ensure ``code/main.py`` and canonical demos under top-level ``demos/``.
Demos are sourced from `BUNDLED_DEMOS_CODE_DIR` (the single committed home
for sample scripts). Only files listed in `_CANONICAL_DEMO_FILENAMES`
get auto-seeded — the rest are available via the editor's "Reset
demos" button or a manual copy."""
code_dir = user_root / "code"
code_dir.mkdir(parents=True, exist_ok=True)
main_py = code_dir / "main.py"
if not main_py.exists():
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
_seed_canonical_demos_into_code(code_dir)
_seed_canonical_demos(user_root)
def seed_all_bundled_demos(user_root: Path) -> None:
"""Copy *every* ``.py`` file in `BUNDLED_DEMOS_CODE_DIR` into ``<user_root>/demos/``.
Used at app startup to populate a fresh workspace with the full sample
set so a no-auth dev install (`pipenv run dev` after `git clone`) has
something to play with. Existing files are not overwritten — user edits
are preserved.
"""
code_dir = user_root / "code"
code_dir.mkdir(parents=True, exist_ok=True)
main_py = code_dir / "main.py"
if not main_py.exists():
main_py.write_text(DEFAULT_MAIN_PY, encoding="utf-8")
demos_dir = user_root / _EDITOR_DEMOS_ROOT
demos_dir.mkdir(parents=True, exist_ok=True)
src_root = _BUNDLED_DEMO_PY_DIR.resolve()
if not src_root.is_dir():
return
for src in sorted(src_root.iterdir()):
if not src.is_file() or not src.name.endswith(".py"):
continue
dst = demos_dir / src.name
legacy_flat = code_dir / src.name
legacy_nested = code_dir / "demos" / src.name
if dst.exists() or legacy_flat.exists() or legacy_nested.exists():
continue
try:
dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
def rename_user_workspace_leaf(

View File

@@ -0,0 +1,32 @@
"""Async HTTPS GET of Pyodides lock file (browser-safe).
Older Pyodide + ``aiohttp`` examples used ``ClientSession`` here, but the
Pyodide ``aiohttp`` wheel often raises ``RuntimeError('SSL is not supported.')``
for ``https://`` — there is no real Python TLS stack in the worker; traffic must
go through the browsers ``fetch``.
This demo uses the shared ``browser_fetch`` helpers (``pyodide.http.pyfetch``
under the hood), same idea as ``async_fetch_demo.py``.
CORS still applies — jsDelivr allows cross-origin GETs. ``print(..., flush=True)``
helps batched worker stdout appear before the run finishes.
"""
import asyncio
from browser_fetch import fetch_json
async def main():
url = "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide-lock.json"
print("Fetching", url, "via browser_fetch.fetch_json ...", flush=True)
data = await fetch_json(url)
info = data.get("info") or {}
packages = data.get("packages") or {}
print("pyodide-lock version:", info.get("version"), flush=True)
print("python runtime :", info.get("python"), flush=True)
print("indexed packages :", len(packages), flush=True)
print("aiohttp wheel in lock:", "aiohttp" in packages, flush=True)
asyncio.run(main())

View File

@@ -0,0 +1,27 @@
"""Async HTTP in the browser (Pyodide).
``aiohttp`` and similar clients use OS sockets, which Wasm does not provide, so
a ``session.get(...)`` can hang after ``print("Running")`` with no body.
Use ``pyodide.http.pyfetch`` or the shared ``browser_fetch`` helpers. From an
HTTPS editor page, use ``https://`` URLs (mixed content blocks ``http://``).
Many sites do not send CORS headers, so the browser blocks the response even
when the URL is valid. This demo uses jsDelivr JSON that allows cross-origin
GET (same host Pyodide loads from).
"""
import asyncio
from browser_fetch import fetch_json
async def main():
print("Running")
url = "https://cdn.jsdelivr.net/pyodide/v0.26.4/full/repodata.json"
data = await fetch_json(url)
info = data.get("info") or {}
print("Fetched repodata; pyodide lock says:", info.get("version"), info.get("python"))
asyncio.run(main())

View File

@@ -0,0 +1,56 @@
"""Pin features demo.
A "Pins" panel appears below the editor while this script runs:
* Pin 2 (OUT) — blinks automatically every ~200 ms via ``.value(...)``.
* Pin 4 (OUT) — stays put until you press the button, then flips
(driven from the IRQ handler with ``.toggle()``).
* Pin 0 (IN) — click the toggle button in the panel to drive a 0 → 1
rising edge; the IRQ fires and flips pin 4.
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty.
"""
import time
from machine import Pin, PWM
led_a = Pin(2, Pin.OUT)
led_b = Pin(4, Pin.OUT)
button = Pin(0, Pin.IN, Pin.PULL_UP)
fader = PWM(Pin(13), freq=1000, duty_u16=0)
def on_button(pin):
# Pin 4 is IRQ-driven on purpose — its only source of change is the
# button press, so when you see it flip you know the IRQ fired.
led_b.toggle()
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
tick = 0
duty = 0
direction = 1024
while True:
# Pin 2: fast on/off via direct .value(...) writes (no IRQ involvement).
led_a.value(tick % 2)
# Pin 13: triangular duty sweep so the PWM bar visibly fills and drains.
duty += direction
if duty >= 65535:
duty = 65535
direction = -1024
elif duty <= 0:
duty = 0
direction = 1024
fader.duty_u16(duty)
# Poll the IN pin so its IRQ actually fires when the panel button changes.
# (`.value()` reads the current state and dispatches any pending edge.)
button.value()
tick += 1
time.sleep(0.1)

View File

@@ -1,10 +1,11 @@
{
"files": [
"pattern_rainbow_demo.py",
"pattern_twinkle_demo.py",
"pattern_chase_demo.py",
"adc_slider_demo.py",
"pin_demo.py",
"serial_demo.py"
"demo/pattern_rainbow_demo.py",
"demo/pattern_twinkle_demo.py",
"demo/pattern_chase_demo.py",
"demo/adc_slider_demo.py",
"demo/pin_demo.py",
"demo/serial_demo.py",
"demo/aiohttp_fetch_demo.py"
]
}

View File

@@ -1,54 +0,0 @@
"""Pin features demo.
A "Pins" panel appears below the editor while this script runs:
* Pin 2 (OUT) — blinks every 200 ms; the indicator follows along.
* Pin 4 (OUT) — chases through .on() / .off() / .toggle().
* Pin 0 (IN) — click the toggle button in the panel to flip its value.
When it goes 0 -> 1 we register an IRQ that toggles pin 2.
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty cycle.
"""
import time
from machine import Pin, PWM
led_a = Pin(2, Pin.OUT)
led_b = Pin(4, Pin.OUT)
button = Pin(0, Pin.IN, Pin.PULL_UP)
fader = PWM(Pin(13), freq=1000, duty_u16=0)
def on_button(pin):
print("[irq] button rising edge -> toggling pin 2")
led_a.toggle()
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
tick = 0
duty = 0
direction = 1024
while True:
led_a.value(tick % 2)
if tick % 4 == 0:
led_b.on()
elif tick % 4 == 2:
led_b.off()
duty += direction
if duty >= 65535:
duty = 65535
direction = -1024
elif duty <= 0:
duty = 0
direction = 1024
fader.duty_u16(duty)
button.value()
tick += 1
time.sleep(0.1)

View File

@@ -0,0 +1,38 @@
"""Browser-friendly async HTTP for the Pyodide worker.
These helpers use ``pyodide.http.pyfetch``, which maps to the browser
``fetch`` API (no Python TLS stack). Prefer them for ``https://`` in
Pyodide: ``aiohttp`` in the worker often raises
``RuntimeError('SSL is not supported.')`` for HTTPS even though the wheel
exists, because user-level SSL is not wired the same as on CPython.
The browser's normal rules apply: the page is served over HTTPS so
``http://`` URLs are blocked as mixed content, and the response host must
send permissive CORS headers (e.g. ``Access-Control-Allow-Origin``) or the
browser hides the body even if the request succeeded.
"""
from __future__ import annotations
from typing import Any
async def fetch_text(url: str) -> str:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.text()
async def fetch_bytes(url: str) -> bytes:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.bytes()
async def fetch_json(url: str, **kwargs: Any) -> Any:
from pyodide.http import pyfetch
r = await pyfetch(url)
return await r.json(**kwargs)

View File

@@ -6,7 +6,7 @@
<meta name="theme-color" content="#2d3748">
<title>LED Editor</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=32">
<link rel="stylesheet" href="/static/styles.css?v=41">
</head>
<body>
<div class="container">
@@ -26,6 +26,8 @@
</div>
</div>
<div id="file-tree-context-menu" class="file-tree-context-menu" role="menu" hidden aria-hidden="true"></div>
<div class="main-content">
<div class="editor-header">
<button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-label="Toggle file tree" aria-controls="sidebar" aria-expanded="true" title="Toggle file browser"><span class="sidebar-toggle-icon" aria-hidden="true"></span></button>
@@ -48,6 +50,12 @@
<input type="checkbox" id="panel-16x16-checkbox" />
16×16 panel
</label>
<button type="button" class="menu-item menu-button" id="packages-menu-btn" role="menuitem">📦 Python packages…</button>
<button type="button" class="menu-item menu-button" id="menu-open-local-folder-btn" role="menuitem">📂 Open local folder…</button>
<button type="button" class="menu-item menu-button" id="menu-open-local-file-btn" role="menuitem">📄 Open local file…</button>
<div class="menu-separator" role="separator"></div>
<div class="menu-section-label" id="workspace-menu-label">Workspace</div>
<div id="workspace-menu-actions" role="group"></div>
</div>
</details>
</div>
@@ -118,6 +126,27 @@
</div>
</div>
<script type="module" src="/static/script.js?v=57"></script>
<div id="packages-modal" class="modal">
<div class="modal-content packages-modal-content">
<h3>Python packages (Pyodide)</h3>
<p class="packages-modal-hint">Install from PyPI using micropip inside the browser. Pure-Python wheels (or packages built for WebAssembly) usually work; manylinux-only C extensions often do not.</p>
<label class="packages-field-label" for="packages-install-input">Package names or PEP 508 specs</label>
<input type="text" id="packages-install-input" placeholder="e.g. httpx, phonenumbers" autocomplete="off" />
<div id="packages-pypi-info" class="packages-pypi-info" aria-live="polite"></div>
<div class="packages-modal-actions-row">
<button type="button" id="packages-lookup-btn" class="packages-secondary-btn">Look up on PyPI</button>
</div>
<div id="packages-modal-status" class="packages-modal-status" aria-live="polite"></div>
<pre id="packages-install-log" class="packages-install-log hidden" aria-label="Micropip installer output"></pre>
<p class="packages-saved-label">Saved in this browser (restored when the editor loads Pyodide):</p>
<ul id="packages-saved-list" class="packages-saved-list"></ul>
<div class="modal-actions">
<button type="button" id="packages-install-btn">Install</button>
<button type="button" id="packages-close-btn">Close</button>
</div>
</div>
</div>
<script type="module" src="/static/script.js?v=83"></script>
</body>
</html>

View File

@@ -91,7 +91,7 @@ function isLibPath(path) {
function isWritablePath(path) {
if (!path) return false;
const root = path.split('/')[0];
return root === 'code';
return root === 'code' || root === 'demos';
}
function splitParts(path) {
@@ -367,6 +367,7 @@ class FileSystemBackend {
}
async ensureSeed() {
await this._resolveDir(['demos'], true);
const codeDir = await this._resolveDir(['code'], true);
let mainExists = false;
for await (const [name] of codeDir.entries()) {
@@ -543,20 +544,22 @@ class FileSystemBackend {
async listAllPyFiles() {
const out = {};
let codeDir;
try {
codeDir = await this._resolveDir(['code']);
} catch (_e) {
return out;
}
const collected = [];
await this._readAllRecursive(['code'], codeDir, collected);
for (const { path, handle } of collected) {
if (!path.toLowerCase().endsWith('.py')) continue;
for (const rootSeg of ['code', 'demos']) {
let dir;
try {
out[path] = await (await handle.getFile()).text();
dir = await this._resolveDir([rootSeg]);
} catch (_e) {
// Skip unreadable files.
continue;
}
const collected = [];
await this._readAllRecursive([rootSeg], dir, collected);
for (const { path, handle } of collected) {
if (!path.toLowerCase().endsWith('.py')) continue;
try {
out[path] = await (await handle.getFile()).text();
} catch (_e) {
// Skip unreadable files.
}
}
}
return out;
@@ -564,19 +567,21 @@ class FileSystemBackend {
async listAllUserFiles() {
const out = {};
let codeDir;
try {
codeDir = await this._resolveDir(['code']);
} catch (_e) {
return out;
}
const collected = [];
await this._readAllRecursive(['code'], codeDir, collected);
for (const { path, handle } of collected) {
for (const rootSeg of ['code', 'demos']) {
let dir;
try {
out[path] = await (await handle.getFile()).text();
dir = await this._resolveDir([rootSeg]);
} catch (_e) {
// Skip unreadable files (e.g. binaries the editor can't open).
continue;
}
const collected = [];
await this._readAllRecursive([rootSeg], dir, collected);
for (const { path, handle } of collected) {
try {
out[path] = await (await handle.getFile()).text();
} catch (_e) {
// Skip unreadable files (e.g. binaries the editor can't open).
}
}
}
return out;
@@ -700,7 +705,7 @@ export class LocalWorkspaceClient {
/* --- Lib bundle ------------------------------------------------- */
async _tryLoadStaticBundledLib() {
const names = ['machine.py', 'neopixel.py'];
const names = ['machine.py', 'neopixel.py', 'browser_fetch.py'];
const map = {};
for (const name of names) {
const r = await fetch(`/static/bundled-lib/${encodeURIComponent(name)}`, {
@@ -770,6 +775,9 @@ export class LocalWorkspaceClient {
if (!filtered.some((row) => row.name === 'code')) {
filtered.push({ name: 'code', is_directory: true, size: null });
}
if (!filtered.some((row) => row.name === 'demos')) {
filtered.push({ name: 'demos', is_directory: true, size: null });
}
if (this.libFiles && Object.keys(this.libFiles).length) {
filtered.push({ name: 'lib', is_directory: true, size: null });
}
@@ -810,7 +818,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable (lib is read-only)' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable (lib is read-only)' });
const content = body && typeof body.content === 'string' ? body.content : '';
await this.backend.writeFile(path, content);
return jsonResponse(200, { filename: basename(path) });
@@ -821,7 +829,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
const r = await this.backend.deleteFile(path);
if (!r.ok) {
if (r.reason === 'missing') return jsonResponse(404, { detail: 'File not found' });
@@ -836,7 +844,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
const r = await this.backend.createFolder(path);
if (!r.ok) {
if (r.reason === 'exists') return jsonResponse(400, { detail: 'Folder already exists' });
@@ -850,7 +858,7 @@ export class LocalWorkspaceClient {
const path = normalizePath(rawPath);
if (!path) return jsonResponse(400, { detail: 'Empty path' });
if (isLibPath(path)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ is writable' });
if (!isWritablePath(path)) return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
const r = await this.backend.deleteFolder(path);
if (!r.ok) {
if (r.reason === 'missing') return jsonResponse(404, { detail: 'Folder not found' });
@@ -867,7 +875,7 @@ export class LocalWorkspaceClient {
if (!source) return jsonResponse(400, { detail: 'Missing source_path' });
if (isLibPath(source) || isLibPath(dest)) return jsonResponse(403, { detail: 'lib is read-only' });
if (!isWritablePath(source) || (dest && !isWritablePath(dest))) {
return jsonResponse(403, { detail: 'Only code/ is writable' });
return jsonResponse(403, { detail: 'Only code/ and demos/ are writable' });
}
const r = await this.backend.movePath(source, dest);
if (!r.ok) {

View File

@@ -6,6 +6,26 @@ const PYODIDE_INDEX_URL = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/';
let pyodide = null;
let loadingPromise = null;
/**
* Install PyPI wheels into this Pyodide interpreter via micropip.
* Pure-Python wheels (or Pyodide-built packages) work; manylinux
* binary wheels that are not built for Emscripten will fail.
*/
async function micropipInstallSpecs(p, specs) {
const list = specs
.map((s) => String(s || '').trim())
.filter(Boolean);
if (!list.length) {
return;
}
p.globals.set('__micropip_specs', p.toPy(list));
await p.runPythonAsync(`
import micropip
specs = list(__micropip_specs)
await micropip.install(specs)
`);
}
async function ensurePyodide() {
if (pyodide) {
return pyodide;
@@ -20,9 +40,17 @@ async function ensurePyodide() {
batched: (txt) => self.postMessage({ type: 'io', stream: 'stderr', text: txt }),
});
await p.loadPackage('micropip');
/* Optional wheels (numpy, scipy, pandas, scikit-learn, …) are not preloaded —
first worker init stays small. Install from user code with
`await micropip.install("…", index_urls=[…])` (same host as `loadPyodide`).
HTTPS: use `pyodide.http.pyfetch` / `browser_fetch`, not `aiohttp`. */
await p.runPythonAsync(`
import micropip
await micropip.install("jedi")
try:
await micropip.install("nest-asyncio")
except Exception:
pass
`);
return p;
})();
@@ -137,13 +165,47 @@ self.onmessage = async (event) => {
self.__serial_in_capacity = 0;
}
}
await ensurePyodide();
self.postMessage({ id, type: 'init', ok: true });
const p = await ensurePyodide();
const rawExtra =
payload && Array.isArray(payload.persistedMicropipPackages)
? payload.persistedMicropipPackages
: [];
const extra = rawExtra.map((s) => String(s || '').trim()).filter(Boolean);
let micropipRestoreError = null;
if (extra.length) {
try {
await micropipInstallSpecs(p, extra);
} catch (err) {
micropipRestoreError = err && err.message ? String(err.message) : String(err);
}
}
const initReply = { id, type: 'init', ok: true };
if (micropipRestoreError) {
initReply.micropipRestoreError = micropipRestoreError;
}
self.postMessage(initReply);
return;
}
const p = await ensurePyodide();
if (type === 'micropipInstall') {
const raw =
payload && payload.specs != null
? payload.specs
: payload && payload.spec != null
? [payload.spec]
: [];
const specs = (Array.isArray(raw) ? raw : [raw]).map((s) => String(s || '').trim()).filter(Boolean);
if (!specs.length) {
self.postMessage({ id, type: 'micropipInstall', ok: false, error: 'No package names given' });
return;
}
await micropipInstallSpecs(p, specs);
self.postMessage({ id, type: 'micropipInstall', ok: true });
return;
}
if (type === 'complete') {
const rel = String(payload.path || 'scratch.py').replace(/^\/+/, '');
const vpath = `/workspace/${rel}`;
@@ -168,12 +230,12 @@ for rel_path, body in extra.items():
os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
with open(__cm_path, "w", encoding="utf-8") as fh:
fh.write(__cm_code)
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
sys.path.append(entry)
proj = jedi.Project(
"/workspace",
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
added_sys_path=["/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"],
)
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
items = s.complete(line=__cm_line, column=__cm_col)
@@ -206,12 +268,12 @@ for rel_path, body in extra.items():
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
with open(__diag_path, "w", encoding="utf-8") as fh:
fh.write(__diag_code)
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
sys.path.append(entry)
proj = jedi.Project(
"/workspace",
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
added_sys_path=["/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"],
)
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
errs = s.get_syntax_errors()
@@ -243,15 +305,62 @@ for rel, body in files.items():
with open(full, "w", encoding="utf-8") as fh:
fh.write(str(body))
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
for entry in ("/workspace/code", "/workspace/demos", "/workspace/lib", "/workspace"):
if entry not in sys.path:
sys.path.insert(0, entry)
sys.path.append(entry)
os.chdir("/workspace")
main = __run_main
sys.argv = [main] + list(__run_args)
runpy.run_path(main, run_name="__main__")
# runpy.run_path is synchronous; user asyncio.run() needs an *active* loop (nest_asyncio).
# Await an async wrapper so get_running_loop() works during the script.
import asyncio
import asyncio.events
import asyncio.base_events
def _patch_set_debug(cls):
_orig = cls.set_debug
def set_debug(self, enabled):
try:
return _orig(self, enabled)
except NotImplementedError:
pass
cls.set_debug = set_debug
_patch_set_debug(asyncio.events.AbstractEventLoop)
_patch_set_debug(asyncio.base_events.BaseEventLoop)
async def __pyodide_run_user_script():
loop = asyncio.get_running_loop()
try:
import nest_asyncio
nest_asyncio.apply(loop)
except Exception:
try:
import nest_asyncio
nest_asyncio.apply()
except Exception:
pass
def _pyodide_asyncio_run(coro, *, debug=False):
task = asyncio.ensure_future(coro, loop=loop)
return loop.run_until_complete(task)
asyncio.run = _pyodide_asyncio_run
import runpy
runpy.run_path(main, run_name="__main__")
await __pyodide_run_user_script()
`);
try {
await p.runPythonAsync(`
import sys
for _s in (sys.stdout, sys.stderr):
try:
_s.flush()
except Exception:
pass
`);
} catch (_e) {
// ignore
}
self.postMessage({ id, type: 'run', ok: true });
return;
}

File diff suppressed because it is too large Load Diff

View File

@@ -113,7 +113,8 @@ button,
border-radius: 8px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
z-index: 60;
min-width: 220px;
min-width: 240px;
max-width: min(85vw, 320px);
padding: 0.35rem;
display: flex;
flex-direction: column;
@@ -137,10 +138,67 @@ button,
background: #f7fafc;
}
button.menu-item.menu-button {
border: none;
background: transparent;
width: 100%;
text-align: left;
font: inherit;
font-family: inherit;
}
.menu-checkbox input[type="checkbox"] {
margin: 0;
}
.menu-separator {
height: 1px;
background: #e2e8f0;
margin: 0.35rem 0.2rem;
}
.menu-section-label {
padding: 0.35rem 0.7rem 0.2rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #94a3b8;
}
.menu-note {
padding: 0.4rem 0.7rem;
font-size: 0.78rem;
color: #94a3b8;
font-style: italic;
}
/* Buttons styled as menu items (Workspace actions, etc.). */
.menu-action {
appearance: none;
background: transparent;
border: none;
width: 100%;
text-align: left;
font: inherit;
color: #2d3748;
}
.menu-action:hover,
.menu-action:focus-visible {
background: #f1f5f9;
outline: none;
}
.menu-action-danger {
color: #b91c1c;
}
.menu-action-danger:hover,
.menu-action-danger:focus-visible {
background: #fee2e2;
}
/* Sidebar */
.sidebar {
width: 300px;
@@ -190,6 +248,63 @@ button,
padding: 0.5rem;
}
.file-tree-context-menu {
position: fixed;
z-index: 250;
min-width: 210px;
max-width: min(92vw, 300px);
background: #fff;
border: 1px solid #cbd5e0;
border-radius: 8px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
padding: 0.35rem 0;
font-size: 0.875rem;
color: #2d3748;
}
.file-tree-context-menu[hidden] {
display: none !important;
}
.file-tree-context-menu .file-tree-cm-note {
padding: 0.35rem 0.85rem 0.5rem;
font-size: 0.75rem;
color: #718096;
line-height: 1.35;
}
.file-tree-context-menu button {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 0.85rem;
border: none;
background: transparent;
cursor: pointer;
font: inherit;
color: inherit;
}
.file-tree-context-menu button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.file-tree-context-menu button:hover:not(:disabled) {
background: #edf2f7;
}
.file-tree-context-menu button.file-tree-cm-danger {
color: #c53030;
}
.file-tree-context-menu hr.file-tree-cm-sep {
height: 1px;
margin: 0.3rem 0.5rem;
background: #e2e8f0;
border: none;
}
.file-item {
padding: 0.5rem;
cursor: pointer;
@@ -277,40 +392,7 @@ button,
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 220px;
}
.workspace-badge-exit,
.workspace-badge-action {
appearance: none;
border: 1px solid #cbd5e1;
background: transparent;
color: #475569;
border-radius: 4px;
padding: 0.05rem 0.4rem;
font-size: 0.7rem;
cursor: pointer;
line-height: 1.4;
}
.workspace-badge-exit:hover,
.workspace-badge-action:hover {
background: #e2e8f0;
}
.workspace-badge-action {
border-color: #93c5fd;
color: #1d4ed8;
}
.workspace-badge-action:hover {
background: #dbeafe;
}
.workspace-badge-note {
font-size: 0.7rem;
color: #94a3b8;
font-style: italic;
max-width: 240px;
}
#current-file {
@@ -685,6 +767,12 @@ button,
font-size: 0.85rem;
cursor: pointer;
text-align: center;
/* iOS Safari swallows taps on small buttons inside scrollable parents
* unless `touch-action: manipulation` is set (kills the 300ms double-tap
* zoom delay and the "is this a scroll start?" hesitation). */
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(56, 189, 248, 0.25);
user-select: none;
}
.pin-toggle.on {
@@ -1104,6 +1192,134 @@ button,
border-color: #2c5aa0;
}
.packages-modal-content {
width: min(440px, 92vw);
max-height: 85vh;
overflow-y: auto;
margin: 6% auto;
}
.packages-modal-hint {
font-size: 0.8rem;
color: #718096;
line-height: 1.45;
margin: 0 0 0.75rem;
}
.packages-field-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
margin: 0.25rem 0 0.35rem;
}
.packages-pypi-info {
min-height: 1.2rem;
font-size: 0.85rem;
color: #334155;
margin: 0.15rem 0 0.5rem;
}
.packages-modal-actions-row {
margin-bottom: 0.65rem;
}
.packages-secondary-btn {
padding: 0.4rem 0.75rem;
border: 1px solid #cbd5e0;
background: #f8fafc;
color: #334155;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
}
.packages-secondary-btn:hover {
background: #edf2f7;
}
.packages-modal-status {
min-height: 1.25rem;
font-size: 0.85rem;
color: #2b6cb0;
margin-bottom: 0.5rem;
}
.packages-install-log {
max-height: 12rem;
overflow: auto;
margin: 0 0 0.65rem;
padding: 0.45rem 0.5rem;
font-size: 0.72rem;
line-height: 1.35;
color: #1e293b;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
white-space: pre-wrap;
word-break: break-word;
}
.packages-saved-label {
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
margin: 0 0 0.35rem;
}
.packages-saved-list {
list-style: none;
margin: 0 0 1rem;
padding: 0;
border: 1px solid #e2e8f0;
border-radius: 6px;
max-height: 10rem;
overflow-y: auto;
}
.packages-saved-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border-bottom: 1px solid #f1f5f9;
font-size: 0.85rem;
color: #2d3748;
}
.packages-saved-item:last-child {
border-bottom: none;
}
.packages-saved-item span {
word-break: break-all;
}
.packages-saved-empty {
padding: 0.55rem 0.65rem;
color: #94a3b8;
font-size: 0.85rem;
}
.packages-remove-btn {
flex-shrink: 0;
padding: 0.2rem 0.45rem;
font-size: 0.75rem;
border: 1px solid #e2e8f0;
background: white;
border-radius: 4px;
cursor: pointer;
color: #4a5568;
}
.packages-remove-btn:hover {
background: #fff5f5;
border-color: #feb2b2;
color: #c53030;
}
/* Desktop: hide the file browser when collapsed via the hamburger toggle. */
@media (min-width: 769px) {
.sidebar.is-collapsed {
@@ -1172,6 +1388,12 @@ button,
.main-content {
width: 100%;
min-height: 0;
/* Let the right column scroll as a whole on phones. With ADC + Pins +
* Serial all open at once, their combined intrinsic heights exceed the
* viewport; without this the console (last child) gets clipped. */
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.editor-header {
@@ -1244,9 +1466,31 @@ button,
}
.editor-container {
/* Pin the editor at a usable height; the rest of the column scrolls. */
flex: 0 0 auto;
height: 50vh;
min-height: 42vh;
}
/* When any of the live simulator panels (Pins / ADC / Serial / LED grid)
* is visible, give the editor less screen real estate so the panels and
* console don't get pushed below the fold. The whole column is still
* scrollable, this just biases space toward the panels. */
.main-content:has(.pin-panel:not(.hidden), .adc-panel:not(.hidden), .serial-panel:not(.hidden), .led-sim-panel:not(.hidden)) .editor-container {
height: 32vh;
min-height: 26vh;
}
/* Stop the editor / panels / console from being squashed by flexbox —
* each keeps its natural (or capped) height and the column scrolls. */
.led-sim-panel,
.pin-panel,
.adc-panel,
.serial-panel,
.console-container {
flex: 0 0 auto;
}
.cm-editor {
font-size: 14px; /* >=16px would prevent iOS zoom but feels too large here; CM is contenteditable so no zoom anyway. */
}
@@ -1306,6 +1550,21 @@ button,
-webkit-tap-highlight-color: transparent;
}
/* Cap each panel's scroll region tighter on mobile so two or three of them
* stacked don't push the console far below the fold. The whole column is
* already scrollable; this just keeps individual panels compact. */
.pin-rows {
max-height: 22vh;
}
.adc-sliders {
max-height: 22vh;
}
.serial-output {
max-height: 22vh;
}
.adc-slider {
min-height: 2.75rem;
padding: 0.75rem 0;

View File

@@ -245,6 +245,6 @@
</section>
</div>
</main>
<script type="module" src="/static/tutorial.js?v=2"></script>
<script type="module" src="/static/tutorial.js?v=4"></script>
</body>
</html>

View File

@@ -289,7 +289,7 @@ class TutorialApp {
ensurePyWorker() {
if (!this.pyWorker) {
this.pyWorker = new Worker("/static/pyodide-worker.js?v=4");
this.pyWorker = new Worker("/static/pyodide-worker.js?v=24");
this.pyWorker.onmessage = (event) => this.onWorkerMessage(event);
}
return this.pyWorker;

View File

@@ -67,7 +67,7 @@ def test_save_file_collapses_duplicate_scoped_prefix(client, tmp_path):
def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
code_dir = tmp_path / "code"
code_dir.mkdir()
code_dir.mkdir(exist_ok=True)
(code_dir / "main.py").write_text("print('ok')\n", encoding="utf-8")
save_blocked = client.post("/api/file/lib/new.txt", json={"content": "nope"})
@@ -115,7 +115,7 @@ def test_read_file_non_utf8_returns_400(client, tmp_path):
def test_delete_file_success_and_errors(client, tmp_path):
target = tmp_path / "code" / "delete-me.txt"
target.parent.mkdir()
target.parent.mkdir(exist_ok=True)
target.write_text("x", encoding="utf-8")
ok = client.delete("/api/file/code/delete-me.txt")
@@ -219,7 +219,7 @@ def test_folder_create_and_delete(client, tmp_path):
def test_create_folder_collapses_duplicate_scoped_prefix(client, tmp_path):
(tmp_path / "code").mkdir()
(tmp_path / "code").mkdir(exist_ok=True)
create = client.post("/api/folder/new/code/code/nested", json={"path": "ignored"})
assert create.status_code == 200
assert (tmp_path / "code" / "nested").is_dir()
@@ -230,14 +230,14 @@ def test_folder_delete_errors(client, tmp_path):
missing = client.delete("/api/folder/code/missing")
assert missing.status_code == 404
(tmp_path / "code").mkdir()
(tmp_path / "code").mkdir(exist_ok=True)
(tmp_path / "code" / "file.txt").write_text("x", encoding="utf-8")
not_dir = client.delete("/api/folder/code/file.txt")
assert not_dir.status_code == 400
def test_workspace_py_sources_returns_python_files(client, tmp_path):
(tmp_path / "code").mkdir()
(tmp_path / "code").mkdir(exist_ok=True)
(tmp_path / "code" / "app.py").write_text("x = 1\n", encoding="utf-8")
response = client.get("/api/workspace/py-sources")

View File

@@ -92,14 +92,16 @@ def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch):
assert reg.status_code == 200
assert reg.json()["username"] == "alice"
uid = reg.json()["id"]
code_root = tmp_path / "users" / f"alice-{uid}" / "code"
user_root = tmp_path / "users" / f"alice-{uid}"
code_root = user_root / "code"
demos_dir = user_root / "demos"
on_disk = code_root / "main.py"
assert on_disk.is_file()
assert on_disk.read_text(encoding="utf-8") == 'print("Hello, World!")\n'
canonical = ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py")
for fname in canonical:
cp = code_root / fname
assert cp.is_file(), f"missing bundled copy {fname} (workspace/code must ship with app)"
cp = demos_dir / fname
assert cp.is_file(), f"missing bundled copy demos/{fname} (workspace must ship with app)"
text = cp.read_text(encoding="utf-8")
assert len(text.strip()) > 20
assert "from led_patterns" not in text
@@ -110,7 +112,7 @@ def test_new_user_workspace_has_default_main_py(tmp_path, monkeypatch):
assert fetched.status_code == 200
assert fetched.json()["filename"] == "main.py"
assert 'Hello, World!' in fetched.json()["content"]
chase = client.get("/api/file/code/pattern_chase_demo.py")
chase = client.get("/api/file/demos/pattern_chase_demo.py")
assert chase.status_code == 200
assert "knight_rider_scanner_frame" in chase.json()["content"]
@@ -311,14 +313,6 @@ def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch):
shared_lib = tmp_path / "lib"
shared_lib.mkdir(parents=True, exist_ok=True)
(shared_lib / "shared.py").write_text("VALUE = 42\n", encoding="utf-8")
# Mirror canonical demo files so `_seed_canonical_demos_into_code` still works.
real_demos = config.PROJECT_ROOT / "workspace" / "code"
fake_demos = tmp_path / "workspace" / "code"
fake_demos.mkdir(parents=True, exist_ok=True)
for fname in ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py"):
src = real_demos / fname
if src.is_file():
(fake_demos / fname).write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
monkeypatch.setattr(config, "PROJECT_ROOT", tmp_path)

View File

@@ -4,7 +4,8 @@ from pathlib import Path
def _load_patterns_module():
repo_root = Path(__file__).resolve().parents[1]
module_path = repo_root / "workspace" / "code" / "led_patterns.py"
# Canonical home for shipped demos — `workspace/` is gitignored.
module_path = repo_root / "src" / "static" / "bundled-demos" / "demo" / "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

View File

@@ -1,55 +0,0 @@
"""ADC slider demo — drag the sliders that appear under the editor.
Two simulated ADCs:
* pin 34 — sets the base hue of a rainbow
* pin 35 — sets overall brightness
The strip lights up while the script runs; the values update live (no need to
restart the script when you move the slider).
"""
import time
from machine import ADC, Pin
from neopixel import NeoPixel
NUM_LEDS = 16
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
hue_pot = ADC(Pin(34))
bri_pot = ADC(Pin(35))
def hsv_to_rgb(h, s, v):
h = h - int(h)
i = int(h * 6)
f = h * 6 - i
p = v * (1 - s)
q = v * (1 - f * s)
t = v * (1 - (1 - f) * s)
if i == 0:
r, g, b = v, t, p
elif i == 1:
r, g, b = q, v, p
elif i == 2:
r, g, b = p, v, t
elif i == 3:
r, g, b = p, q, v
elif i == 4:
r, g, b = t, p, v
else:
r, g, b = v, p, q
return int(r * 255), int(g * 255), int(b * 255)
print("Move the ADC sliders below the editor while this runs.")
while True:
base_hue = hue_pot.read_u16() / 65535
brightness = bri_pot.read_u16() / 65535
for i in range(NUM_LEDS):
h = (base_hue + i / NUM_LEDS) % 1.0
strip[i] = hsv_to_rgb(h, 1.0, brightness)
strip.write()
time.sleep(0.04)

View File

@@ -1,83 +0,0 @@
"""Knight Riderstyle bouncing scanner — self-contained (stdlib + simulated hardware only)."""
import time
from machine import Pin
import neopixel
# --- helpers
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def _bounce_head_index(led_count: int, frame: int) -> int:
if led_count <= 1:
return 0
span = led_count - 1
cycle = span * 2
if cycle <= 0:
return 0
t = frame % cycle
return t if t <= span else 2 * span - t
def _bounce_phase_tail_direction(led_count: int, frame: int) -> int:
if led_count <= 1:
return -1
span = led_count - 1
cycle = span * 2
if cycle <= 0:
return -1
t = frame % cycle
if t <= span:
return -1
return 1
def knight_rider_scanner_frame(
led_count: int,
frame: int,
head_color=(220, 0, 28),
tail_len: int = 8,
falloff_gamma: float = 2.6,
):
if led_count <= 0:
return []
out = [(0, 0, 0) for _ in range(led_count)]
tl = max(1, tail_len)
head = _bounce_head_index(led_count, frame)
direc = _bounce_phase_tail_direction(led_count, frame)
gamma = max(1.05, falloff_gamma)
for rk in reversed(range(tl)):
idx = head + direc * rk
if idx < 0 or idx >= led_count:
continue
w = max(0.0, float(tl - rk) / float(tl))
strength = w**gamma
out[idx] = tuple(_clamp(int(head_color[ch] * strength)) for ch in range(3))
return out
# --- demo
NUM_LEDS = 16
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
for frame in range(200):
frame_colors = knight_rider_scanner_frame(
len(np),
frame,
head_color=(220, 0, 36),
tail_len=10,
falloff_gamma=2.85,
)
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

@@ -1,47 +0,0 @@
"""Rainbow NeoPixel sweep — self-contained (stdlib + simulated hardware only)."""
import time
from machine import Pin
import neopixel
# --- helpers (same logic as bundled led_patterns.py, inlined here)
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def wheel(pos: int):
"""Return rainbow RGB at position 0255."""
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):
if led_count <= 0:
return []
return [wheel((i * 256 // led_count + frame * step) & 255) for i in range(led_count)]
# --- demo
NUM_LEDS = 16
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
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

@@ -1,54 +0,0 @@
"""Twinkle NeoPixel demo — self-contained (stdlib + simulated hardware only)."""
import random
import time
from machine import Pin
import neopixel
# --- helpers
def _clamp(channel: int) -> int:
return max(0, min(255, int(channel)))
def twinkle_frame(
led_count: int,
frame: int,
base=(0, 0, 8),
sparkle=(255, 255, 180),
sparkles: int = 3,
seed: int = 1337,
):
if led_count <= 0:
return []
out = [tuple(_clamp(v) for v in base) for _ in range(led_count)]
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)
return out
# --- demo
NUM_LEDS = 16
np = neopixel.NeoPixel(Pin(4), NUM_LEDS)
for frame in range(120):
frame_colors = twinkle_frame(
len(np),
frame,
base=(0, 0, 6),
sparkle=(255, 210, 130),
sparkles=3,
)
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

@@ -1,54 +0,0 @@
"""Pin features demo.
A "Pins" panel appears below the editor while this script runs:
* Pin 2 (OUT) — blinks every 200 ms; the indicator follows along.
* Pin 4 (OUT) — chases through .on() / .off() / .toggle().
* Pin 0 (IN) — click the toggle button in the panel to flip its value.
When it goes 0 -> 1 we register an IRQ that toggles pin 2.
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty cycle.
"""
import time
from machine import Pin, PWM
led_a = Pin(2, Pin.OUT)
led_b = Pin(4, Pin.OUT)
button = Pin(0, Pin.IN, Pin.PULL_UP)
fader = PWM(Pin(13), freq=1000, duty_u16=0)
def on_button(pin):
print("[irq] button rising edge -> toggling pin 2")
led_a.toggle()
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
tick = 0
duty = 0
direction = 1024
while True:
led_a.value(tick % 2)
if tick % 4 == 0:
led_b.on()
elif tick % 4 == 2:
led_b.off()
duty += direction
if duty >= 65535:
duty = 65535
direction = -1024
elif duty <= 0:
duty = 0
direction = 1024
fader.duty_u16(duty)
button.value()
tick += 1
time.sleep(0.1)

View File

@@ -1,90 +0,0 @@
"""Serial in/out demo.
When this script runs, a "Serial monitor" pane appears below the editor.
Try this:
* type hello and press Enter -> Python echoes "echo: hello"
* type color red -> the strip turns red
* try color 0,128,255 -> any (r,g,b) tuple works
* type off -> strip blanks
* type bye -> script exits cleanly
Anything Python `write()`s to the UART shows up in green; what you type back
is shown in white.
"""
import time
from machine import Pin, UART
from neopixel import NeoPixel
NUM_LEDS = 16
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
uart = UART(0, baudrate=115200)
PALETTE = {
"red": (255, 0, 0),
"green": (0, 255, 0),
"blue": (0, 0, 255),
"white": (200, 200, 200),
"purple": (160, 0, 200),
"orange": (255, 110, 0),
}
def fill(color):
strip.fill(color)
strip.write()
def parse_color(arg):
arg = arg.strip().lower()
if arg in PALETTE:
return PALETTE[arg]
parts = [p for p in arg.replace(",", " ").split() if p]
if len(parts) == 3:
try:
return tuple(max(0, min(255, int(p))) for p in parts)
except ValueError:
return None
return None
uart.write("ready. commands: color <name|r,g,b> | off | bye\n")
fill((0, 0, 0))
running = True
while running:
line = uart.readline()
if line is None:
time.sleep(0.05)
continue
text = line.decode("utf-8", errors="replace").strip()
if not text:
continue
if text == "bye":
uart.write("goodbye!\n")
running = False
break
if text == "off":
fill((0, 0, 0))
uart.write("strip off\n")
continue
if text.startswith("color"):
rest = text[len("color"):].strip()
color = parse_color(rest) if rest else None
if color is None:
uart.write("usage: color <name> | color r,g,b\n")
else:
fill(color)
uart.write(f"strip = {color}\n")
continue
uart.write(f"echo: {text}\n")
fill((0, 0, 0))