diff --git a/src/static/bundled-demos/pin_demo.py b/src/static/bundled-demos/pin_demo.py index d63aa68..8d4ce1e 100644 --- a/src/static/bundled-demos/pin_demo.py +++ b/src/static/bundled-demos/pin_demo.py @@ -2,11 +2,12 @@ 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. + * 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 @@ -21,8 +22,9 @@ fader = PWM(Pin(13), freq=1000, duty_u16=0) def on_button(pin): - print("[irq] button rising edge -> toggling pin 2") - led_a.toggle() + # 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) @@ -33,12 +35,10 @@ duty = 0 direction = 1024 while True: + # Pin 2: fast on/off via direct .value(...) writes (no IRQ involvement). led_a.value(tick % 2) - if tick % 4 == 0: - led_b.on() - elif tick % 4 == 2: - led_b.off() + # Pin 13: triangular duty sweep so the PWM bar visibly fills and drains. duty += direction if duty >= 65535: duty = 65535 @@ -48,6 +48,8 @@ while True: 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 diff --git a/src/static/index.html b/src/static/index.html index 45e470e..f55663c 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -6,7 +6,7 @@ LED Editor - +
@@ -48,6 +48,9 @@ 16×16 panel + + +
@@ -118,6 +121,6 @@ - + diff --git a/src/static/script.js b/src/static/script.js index e887810..6c4418c 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -147,93 +147,123 @@ class TextEditor { async updateWorkspaceBanner() { const badge = document.getElementById('workspace-badge'); + const menuActions = document.getElementById('workspace-menu-actions'); + const menuLabel = document.getElementById('workspace-menu-label'); if (!badge) return; - if (this.localMode) { - badge.innerHTML = ''; - const label = document.createElement('span'); - label.className = 'workspace-badge-label'; - label.textContent = 'Local · IndexedDB'; - badge.appendChild(label); - if (supportsFolderPicker()) { - const pick = document.createElement('button'); - pick.type = 'button'; - pick.className = 'workspace-badge-action'; - pick.textContent = 'Folder…'; - pick.title = 'Save files to a folder on this device'; - pick.addEventListener('click', () => this.pickLocalFolder()); - badge.appendChild(pick); - } else { - const why = document.createElement('span'); - why.className = 'workspace-badge-note'; - why.textContent = '(no folder picker)'; - why.title = - 'window.showDirectoryPicker is not exposed in this browser context.\n' + - '• Firefox / Safari: not implemented (use Chromium-based browser).\n' + - '• Brave: enable brave://flags/#file-system-access-api or relax Shields for this site.\n' + - '• HTTPS-only requirement: must be served from localhost or https://.\n' + - 'Files keep saving to IndexedDB; use Export to download a ZIP.'; - badge.appendChild(why); - } - - const exportBtn = document.createElement('button'); - exportBtn.type = 'button'; - exportBtn.className = 'workspace-badge-action'; - exportBtn.textContent = 'Export'; - exportBtn.title = 'Download every workspace file as a .zip'; - exportBtn.addEventListener('click', () => this.exportWorkspaceZip()); - badge.appendChild(exportBtn); - - const importBtn = document.createElement('button'); - importBtn.type = 'button'; - importBtn.className = 'workspace-badge-action'; - importBtn.textContent = 'Import'; - importBtn.title = 'Upload a .zip — its files land in code/ (overwrites on conflict)'; - importBtn.addEventListener('click', () => this.importWorkspaceZip()); - badge.appendChild(importBtn); - - const resetBtn = document.createElement('button'); - resetBtn.type = 'button'; - resetBtn.className = 'workspace-badge-action'; - resetBtn.textContent = 'Reset demos'; - resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)'; - resetBtn.addEventListener('click', () => this.resetDemoFiles()); - badge.appendChild(resetBtn); - - const exit = document.createElement('button'); - exit.type = 'button'; - exit.className = 'workspace-badge-exit'; - exit.textContent = 'Exit'; - exit.title = 'Leave local mode and return to the home page'; - exit.addEventListener('click', () => { - if (!confirm('Leave local mode? Your files stay in this browser; you can come back later.')) return; - exitLocalMode(); - window.location.href = '/'; + // Helper: a button styled as a menu item. + const makeMenuButton = (text, title, onClick, opts = {}) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'menu-item menu-action' + (opts.danger ? ' menu-action-danger' : ''); + b.setAttribute('role', 'menuitem'); + b.textContent = text; + if (title) b.title = title; + b.addEventListener('click', (event) => { + // Close the dropdown after a click — feels more like a normal menu. + const details = document.getElementById('header-menu'); + if (details) details.open = false; + onClick(event); }); - badge.appendChild(exit); - badge.classList.remove('hidden'); + return b; + }; + + // Helper: a passive note line inside the menu (e.g. for "no folder picker"). + const makeMenuNote = (text, title) => { + const n = document.createElement('div'); + n.className = 'menu-note'; + n.textContent = text; + if (title) n.title = title; + return n; + }; + + badge.innerHTML = ''; + if (menuActions) menuActions.innerHTML = ''; + + const label = document.createElement('span'); + label.className = 'workspace-badge-label'; + badge.appendChild(label); + badge.classList.remove('hidden'); + + if (this.localMode) { + label.textContent = 'Local · IndexedDB'; + if (menuLabel) menuLabel.textContent = 'Workspace · Local'; + + if (menuActions) { + if (supportsFolderPicker()) { + menuActions.appendChild( + makeMenuButton('📁 Save to folder…', 'Save files to a folder on this device', () => + this.pickLocalFolder(), + ), + ); + } else { + menuActions.appendChild( + makeMenuNote( + '(folder picker unavailable)', + 'window.showDirectoryPicker is not exposed in this browser context.\n' + + '• Firefox / Safari: not implemented (use a Chromium-based browser).\n' + + '• Brave: enable brave://flags/#file-system-access-api or relax Shields for this site.\n' + + '• HTTPS-only requirement: must be served from localhost or https://.\n' + + 'Files keep saving to IndexedDB; use Export to download a ZIP.', + ), + ); + } + + menuActions.appendChild( + makeMenuButton('⬇️ Export workspace…', 'Download every workspace file as a .zip', () => + this.exportWorkspaceZip(), + ), + ); + menuActions.appendChild( + makeMenuButton('⬆️ Import .zip…', 'Upload a .zip — its files land in code/ (overwrites on conflict)', () => + this.importWorkspaceZip(), + ), + ); + menuActions.appendChild( + makeMenuButton( + '↻ Reset demos', + 'Re-copy the bundled demos into code/ (overwrites your edits to those files)', + () => this.resetDemoFiles(), + ), + ); + menuActions.appendChild( + makeMenuButton( + '🚪 Exit local mode', + 'Leave local mode and return to the home page', + () => { + if (!confirm('Leave local mode? Your files stay in this browser; you can come back later.')) return; + exitLocalMode(); + window.location.href = '/'; + }, + { danger: true }, + ), + ); + } try { const info = await this.localWorkspace.describeStorage(); if (info) { if (info.mode === 'filesystem') { label.textContent = `Local · ${info.label}`; - const swap = document.createElement('button'); - swap.type = 'button'; - swap.className = 'workspace-badge-action'; - swap.textContent = 'IndexedDB'; - swap.title = 'Switch storage back to in-browser IndexedDB'; - swap.addEventListener('click', () => this.useIndexedDbStorage()); - badge.insertBefore(swap, exit); + if (menuActions) { + const swap = makeMenuButton( + '↺ Switch to IndexedDB', + 'Switch storage back to in-browser IndexedDB', + () => this.useIndexedDbStorage(), + ); + // Insert near the top, just under the picker button (or first child). + menuActions.insertBefore(swap, menuActions.firstChild); + } } else if (info.pendingReconnect) { - label.textContent = `Local · IndexedDB (folder “${info.pendingFolderName}” needs reconnect)`; - const reconnect = document.createElement('button'); - reconnect.type = 'button'; - reconnect.className = 'workspace-badge-action'; - reconnect.textContent = 'Reconnect'; - reconnect.title = 'Re-grant read/write access to the previously picked folder'; - reconnect.addEventListener('click', () => this.reconnectLocalFolder()); - badge.insertBefore(reconnect, exit); + label.textContent = `Local · folder “${info.pendingFolderName}” (reconnect)`; + if (menuActions) { + const reconnect = makeMenuButton( + `🔌 Reconnect “${info.pendingFolderName}”`, + 'Re-grant read/write access to the previously picked folder', + () => this.reconnectLocalFolder(), + ); + menuActions.insertBefore(reconnect, menuActions.firstChild); + } } else { label.textContent = 'Local · IndexedDB'; } @@ -243,39 +273,31 @@ class TextEditor { } return; } - badge.innerHTML = ''; - const label = document.createElement('span'); - label.className = 'workspace-badge-label'; + label.textContent = this.workspaceUserId ? `Workspace: user ${this.workspaceUserId}` : 'Server workspace'; - badge.appendChild(label); + if (menuLabel) menuLabel.textContent = 'Workspace'; - const exportBtn = document.createElement('button'); - exportBtn.type = 'button'; - exportBtn.className = 'workspace-badge-action'; - exportBtn.textContent = 'Export'; - exportBtn.title = 'Download every workspace file as a .zip'; - exportBtn.addEventListener('click', () => this.exportWorkspaceZip()); - badge.appendChild(exportBtn); - - const importBtn = document.createElement('button'); - importBtn.type = 'button'; - importBtn.className = 'workspace-badge-action'; - importBtn.textContent = 'Import'; - importBtn.title = 'Upload a .zip — its files land in code/ (overwrites on conflict)'; - importBtn.addEventListener('click', () => this.importWorkspaceZip()); - badge.appendChild(importBtn); - - const resetBtn = document.createElement('button'); - resetBtn.type = 'button'; - resetBtn.className = 'workspace-badge-action'; - resetBtn.textContent = 'Reset demos'; - resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)'; - resetBtn.addEventListener('click', () => this.resetDemoFiles()); - badge.appendChild(resetBtn); - - badge.classList.remove('hidden'); + if (menuActions) { + menuActions.appendChild( + makeMenuButton('⬇️ Export workspace…', 'Download every workspace file as a .zip', () => + this.exportWorkspaceZip(), + ), + ); + menuActions.appendChild( + makeMenuButton('⬆️ Import .zip…', 'Upload a .zip — its files land in code/ (overwrites on conflict)', () => + this.importWorkspaceZip(), + ), + ); + menuActions.appendChild( + makeMenuButton( + '↻ Reset demos', + 'Re-copy the bundled demos into code/ (overwrites your edits to those files)', + () => this.resetDemoFiles(), + ), + ); + } } async resetDemoFiles() { @@ -1361,12 +1383,18 @@ class TextEditor { setupHeaderMenu() { const menu = document.getElementById('header-menu'); if (!menu) return; - document.addEventListener('click', (event) => { + /* Close the dropdown when the user taps anywhere outside it. We listen + * on both `pointerdown` (fires reliably for touch) and `click` (mouse + * users + keyboard activations) so the menu can't end up floating over + * the Pin / ADC / Serial panels on mobile. */ + const closeIfOutside = (event) => { if (!menu.open) return; - if (!menu.contains(event.target)) { - menu.open = false; - } - }); + const target = event.target; + if (target instanceof Node && menu.contains(target)) return; + menu.open = false; + }; + document.addEventListener('pointerdown', closeIfOutside); + document.addEventListener('click', closeIfOutside); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && menu.open) { menu.open = false; @@ -2433,7 +2461,8 @@ class TextEditor { button.type = 'button'; button.className = 'pin-toggle'; button.textContent = '0'; - button.addEventListener('click', () => { + const togglePin = (event) => { + if (event && typeof event.preventDefault === 'function') event.preventDefault(); const cur = this.pinInView ? (this.pinInView[pin] | 0) : (this._pinInLocal[pin] || 0); const next = cur ? 0 : 1; this._pinInLocal[pin] = next; @@ -2460,7 +2489,26 @@ class TextEditor { } button.textContent = next ? '1' : '0'; button.classList.toggle('on', Boolean(next)); + }; + /* On iOS Safari, `click` on a small button inside a scrollable parent + * sometimes fails to fire (the tap gets reclassified as a scroll + * gesture). `pointerup` fires reliably for finger taps; we guard with + * a 300ms suppression flag so we don't double-toggle on browsers that + * deliver both events. */ + let lastTriggerAt = 0; + const trigger = (event) => { + const now = (typeof performance !== 'undefined' && performance.now) + ? performance.now() + : Date.now(); + if (now - lastTriggerAt < 300) return; + lastTriggerAt = now; + togglePin(event); + }; + button.addEventListener('pointerup', (event) => { + if (event.pointerType === 'mouse' && event.button !== 0) return; + trigger(event); }); + button.addEventListener('click', trigger); const bar = document.createElement('div'); bar.className = 'pin-pwm-bar'; diff --git a/src/static/styles.css b/src/static/styles.css index 5d7a1a9..b5c3fd9 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -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; @@ -141,6 +142,54 @@ button, 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; @@ -277,40 +326,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 +701,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 { @@ -1172,6 +1194,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 +1272,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 +1356,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;