Expand browser editor runtime and LED simulation workflows.
Add Docker deployment support, richer Selenium/LED pattern tests, in-browser diagnostics, responsive UI improvements, and 16x16 panel simulation tooling to speed iteration and hardware-style prototyping. Made-with: Cursor
This commit is contained in:
1
src/static/.reload-token
Normal file
1
src/static/.reload-token
Normal file
@@ -0,0 +1 @@
|
||||
1777623664358
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Python Editor</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=6">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=10">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -29,6 +29,7 @@
|
||||
<div class="file-info">
|
||||
<span id="save-status" class="save-status"></span>
|
||||
<span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
|
||||
<span id="lsp-status" class="runtime-hint" title="Jedi in-browser diagnostics">LSP: n/a</span>
|
||||
</div>
|
||||
<div class="mode-toggle">
|
||||
<a id="home-btn" class="mode-btn active" href="/">Home</a>
|
||||
@@ -36,9 +37,14 @@
|
||||
<div class="editor-actions">
|
||||
<button id="run-btn" disabled>Run Python</button>
|
||||
<button id="stop-btn" disabled>Stop</button>
|
||||
<select id="run-file-select" title="Script to run">
|
||||
<option value="">Run active file</option>
|
||||
</select>
|
||||
<label for="run-main-checkbox" class="run-main-toggle">
|
||||
<input type="checkbox" id="run-main-checkbox" />
|
||||
Run `main.py`
|
||||
</label>
|
||||
<label for="panel-16x16-checkbox" class="run-main-toggle">
|
||||
<input type="checkbox" id="panel-16x16-checkbox" />
|
||||
16x16 panel
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,6 +55,19 @@
|
||||
<div id="completion-dropdown" class="completion-dropdown"></div>
|
||||
</div>
|
||||
|
||||
<section id="led-sim-panel" class="led-sim-panel hidden" aria-label="NeoPixel Simulator">
|
||||
<div class="led-sim-header">
|
||||
<h3>NeoPixel Simulator</h3>
|
||||
<div class="led-sim-actions">
|
||||
<button id="led-run-btn" type="button">Run</button>
|
||||
<button id="led-stop-btn" type="button">Stop</button>
|
||||
<button id="led-close-btn" type="button" aria-label="Close simulator">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="led-meta" class="led-meta">Waiting for neopixel.write()...</div>
|
||||
<div id="led-grid" class="led-grid"></div>
|
||||
</section>
|
||||
|
||||
<div class="console-container">
|
||||
<div class="console-header">Console Output</div>
|
||||
<pre id="console-output" class="console-output"></pre>
|
||||
@@ -67,6 +86,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/script.js?v=10"></script>
|
||||
<script type="module" src="/static/script.js?v=22"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = '<option value="">Run active file</option>';
|
||||
pythonTabs.forEach((path) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = path;
|
||||
option.textContent = path.startsWith('code/') ? path.slice('code/'.length) : path;
|
||||
if (path === currentValue || path === this.currentFilePath) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
findTab(path) {
|
||||
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 = `
|
||||
<style>
|
||||
body { margin: 0; background: #111827; color: #e5e7eb; font-family: system-ui, sans-serif; padding: 12px; }
|
||||
.meta { margin-bottom: 10px; color: #9ca3af; font-size: 13px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(16, 24px); grid-auto-rows: 24px; gap: 6px; }
|
||||
.led { width: 24px; height: 24px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.2); box-shadow: inset 0 0 8px rgba(0,0,0,0.45); }
|
||||
</style>
|
||||
<div id="meta" class="meta">Waiting for frame...</div>
|
||||
<div id="grid" class="grid"></div>
|
||||
`;
|
||||
this.ledPanelWindow = win;
|
||||
return win;
|
||||
}
|
||||
|
||||
closeLedPanelWindow() {
|
||||
if (this.ledPanelWindow && !this.ledPanelWindow.closed) {
|
||||
this.ledPanelWindow.close();
|
||||
}
|
||||
this.ledPanelWindow = null;
|
||||
}
|
||||
|
||||
updateLedWindowControls() {
|
||||
const runBtn = document.getElementById('led-run-btn');
|
||||
const stopBtn = document.getElementById('led-stop-btn');
|
||||
if (!runBtn || !stopBtn) return;
|
||||
const runMainCheckbox = document.getElementById('run-main-checkbox');
|
||||
const runMainSelected = Boolean(runMainCheckbox && runMainCheckbox.checked);
|
||||
const hasPythonFile = Boolean(this.currentFilePath && this.currentFilePath.toLowerCase().endsWith('.py'));
|
||||
runBtn.disabled = !(runMainSelected || hasPythonFile);
|
||||
stopBtn.disabled = !this.isPythonRunning;
|
||||
}
|
||||
|
||||
renderLedSimulation(frame) {
|
||||
this.lastLedFrame = frame;
|
||||
this.ledPanelDismissed = false;
|
||||
if (!frame) return;
|
||||
const panelModeCheckbox = document.getElementById('panel-16x16-checkbox');
|
||||
const panelMode = Boolean(panelModeCheckbox && panelModeCheckbox.checked);
|
||||
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
|
||||
if (panelMode) {
|
||||
const panelWindow = this.ensureLedPanelWindow();
|
||||
if (!panelWindow || panelWindow.closed) return;
|
||||
const meta = panelWindow.document.getElementById('meta');
|
||||
const grid = panelWindow.document.getElementById('grid');
|
||||
if (!meta || !grid) return;
|
||||
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3} | mode=16x16`;
|
||||
grid.innerHTML = '';
|
||||
const panelSize = 16 * 16;
|
||||
for (let panelIndex = 0; panelIndex < panelSize; panelIndex += 1) {
|
||||
const row = Math.floor(panelIndex / 16);
|
||||
const col = panelIndex % 16;
|
||||
const ledIndex = row % 2 === 0 ? row * 16 + (15 - col) : row * 16 + col;
|
||||
const px = pixels[ledIndex] || [0, 0, 0];
|
||||
const r = Number(px?.[0] ?? 0);
|
||||
const g = Number(px?.[1] ?? 0);
|
||||
const b = Number(px?.[2] ?? 0);
|
||||
const div = panelWindow.document.createElement('div');
|
||||
div.className = 'led';
|
||||
div.title = `panel(${row},${col}) -> #${ledIndex} (${r}, ${g}, ${b})`;
|
||||
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
||||
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
|
||||
grid.appendChild(div);
|
||||
}
|
||||
} else {
|
||||
const panel = this.ensureLedWindow();
|
||||
if (!panel) return;
|
||||
const meta = document.getElementById('led-meta');
|
||||
const grid = document.getElementById('led-grid');
|
||||
if (!meta || !grid) return;
|
||||
meta.textContent = `pin=${frame.pin ?? '?'} | leds=${pixels.length} | bpp=${frame.bpp ?? 3}`;
|
||||
grid.innerHTML = '';
|
||||
grid.classList.remove('panel-mode');
|
||||
pixels.forEach((px, i) => {
|
||||
const r = Number(px?.[0] ?? 0);
|
||||
const g = Number(px?.[1] ?? 0);
|
||||
const b = Number(px?.[2] ?? 0);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'led';
|
||||
div.title = `#${i} (${r}, ${g}, ${b})`;
|
||||
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
||||
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
processSimulationLines(lines) {
|
||||
for (const line of lines || []) {
|
||||
if (typeof line !== 'string') continue;
|
||||
const marker = '[neopixel-json]';
|
||||
const idx = line.indexOf(marker);
|
||||
if (idx === -1) continue;
|
||||
const jsonPart = line.slice(idx + marker.length).trim();
|
||||
if (!jsonPart) continue;
|
||||
try {
|
||||
const payload = JSON.parse(jsonPart);
|
||||
if (payload && payload.type === 'neopixel') {
|
||||
this.renderLedSimulation(payload);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore malformed simulation payloads.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendConsoleOutput(lines) {
|
||||
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() {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user