From bcacf64c79fbbef21ba7a1cbd9a2988a34c4f19e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= Date: Sun, 25 Oct 2026 23:23:17 +0100 Subject: [PATCH 1/2] One console and Stop (P18, phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop: while Python is busy for more than 300 ms (a program, a REPL input, Tests, a visualization) the run button becomes «■ Stoppen». Stopping ends the worker and starts a new one: the code stays, the console keeps what was printed and says that Python restarted. Hosts can stop through the command { type: 'stop' } and learn about busy through onBusyChange; the embed passes both on to its parent as protocol version 4 (glpython:stop, glpython:busy, capabilities.stop). One console («Konsole»): the program's output, REPL inputs, Tests reports and notes form one chronological transcript; a Tests report is appended instead of replacing it. Output streams while a program or REPL input runs (at most 50 messages per 50 ms, shown every 40 ms), so an endless loop shows what it prints. The console keeps the last 100,000 characters, follows new output unless scrolled up, and in the embed grows up to 480px. A REPL input before any Run (e.g. right after a stop) failed with "No module named 'gturtle'": "/" was only put on sys.path by a Run. session.py now adds it when it loads. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- README.md | 26 +++++- embed/demo.html | 9 +- scripts/check-embed-protocol.mjs | 12 ++- src/GlPythonWorkspace.tsx | 150 ++++++++++++++++++++++++++----- src/embed/App.tsx | 14 +++ src/embed/style.css | 13 ++- src/embedProtocol.ts | 30 ++++++- src/i18n.ts | 8 +- src/index.ts | 2 + src/pyodide-worker.js | 37 +++++++- src/runtime/session.py | 6 ++ src/style.css | 9 +- 13 files changed, 274 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8df38f7..a9d2007 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ There is no unit test framework. The automated checks are `test:protocol` (plain - Values are shown through `_glpython_safe_repr`, which writes types as learners do (`int`, not ``); the program's own output keeps Python's repr. Worker messages: -- `run` → `result`/`error` +- `run` → `result`/`error`; while it runs, `output` messages (`{id, text}`, `id` undefined for a program, the input's id for a REPL input) stream what was printed, at most 50 per 50 ms. The final message brings the whole output again. - `run-visual` → `trace-result` (`{trace, ast, output, graphics, error, errorMessage, truncated}`) - `repl` → `repl-result` - `run-visual-repl` (a doctest example) → `trace-result` with `rootPath`/`sources`, or `repl-result` if the input doesn't parse @@ -68,7 +68,8 @@ Worker messages: - **Rendering always replays the full trace up to the current step's `step` number.** In substitution mode the scrubber moves through a filtered step list, so use list indices only for the scrubber, never as step numbers. - **Visualizer modes:** in `box` mode every call is a `Frame` box at its call site. In `substitution` mode (the default; prop `visualizerMode`, embed `?visualizer=`), a call with `shortForm` renders as `Frame` scope `'inline'`: its return expression with parameters substituted, in parentheses in operator context. - **Layout and scrolling:** nested boxes show their variables below their code (CSS). The scroll effect follows the active box horizontally. It sets `scrollLeft`/`scrollTop` on the visualizer view only; don't use `scrollIntoView`, which also scrolls the LiaScript page around the embed and moves the step buttons. +- **Console and Stop:** output, REPL inputs, Tests reports and notes form one transcript (`ConsoleEntry` list after `output`). While Python is busy for more than `STOP_BUTTON_DELAY`, the run button is **Stop**: `stopPython` terminates the worker and calls `startWorker` again, so the REPL namespace is gone after a stop. The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`). - **UI strings** come in via the `t: CoreStrings` prop. `src/i18n.ts` is their single source: `coreStrings` holds German and English, and `resolveUiLocale` picks the language. The embed uses them directly; the glpython IDE imports them and adds only its shell strings. New strings have been added as *optional* `CoreStrings` keys with an English fallback, so older hosts keep compiling; add both languages to `coreStrings`. Python values, reprs and error messages are never translated. - **Embedded/reduced layouts:** the knobs are `compact`, `capabilities` (`WorkspaceCapabilities`, including `repl`) and `command` (`WorkspaceCommand`, an immutable `{id, type}` used by editor-free hosts to trigger run/visualize). Defaults must preserve the full-workspace behavior. -**Embed protocol.** `src/embedProtocol.ts` defines the versioned message types and validators (`parseParentMessage`, `parseParentOrigin`, `GLPYTHON_EMBED_PROTOCOL_VERSION`, `GLPYTHON_MAX_SOURCE_LENGTH`), all re-exported from `src/index.ts`. The iframe (`src/embed/App.tsx`) requires an exact `parentOrigin` query param, sends `glpython:ready`/`glpython:resize`, and accepts `glpython:load`/`run`/`visualize`/`reset`. `run`/`visualize` carry the source snapshot so the parent remains the authoritative editor. Version 2 adds `files` + `mainFile` for programs of several files, version 3 adds `glpython:test` (doctests); the embed still accepts every earlier version and answers each parent in the version it last used, so older (e.g. cached) macros keep working. Modes: `eval` (with REPL), `turtle` (graphics layout), `visual` (visualizer-only, cannot exit). Reset remounts the workspace via a `key` generation counter, which also recreates the worker. Any protocol change should bump/handle the version and be covered in `scripts/check-embed-protocol.mjs`; also update `embed/demo.html` and the README. +**Embed protocol.** `src/embedProtocol.ts` defines the versioned message types and validators (`parseParentMessage`, `parseParentOrigin`, `GLPYTHON_EMBED_PROTOCOL_VERSION`, `GLPYTHON_MAX_SOURCE_LENGTH`), all re-exported from `src/index.ts`. The iframe (`src/embed/App.tsx`) requires an exact `parentOrigin` query param, sends `glpython:ready`/`glpython:resize`, and accepts `glpython:load`/`run`/`visualize`/`reset`. `run`/`visualize` carry the source snapshot so the parent remains the authoritative editor. Version 2 adds `files` + `mainFile` for programs of several files, version 3 adds `glpython:test` (doctests); version 4 adds `glpython:stop` and `glpython:busy`; the embed still accepts every earlier version and answers each parent in the version it last used, so older (e.g. cached) macros keep working. Modes: `eval` (with REPL), `turtle` (graphics layout), `visual` (visualizer-only, cannot exit). Reset remounts the workspace via a `key` generation counter, which also recreates the worker. Any protocol change should bump/handle the version and be covered in `scripts/check-embed-protocol.mjs`; also update `embed/demo.html` and the README. diff --git a/README.md b/README.md index 74874ae..4769af5 100644 --- a/README.md +++ b/README.md @@ -175,9 +175,25 @@ visualizer's error marking, worded by `errorHint` and labelled by In the visualizer, a call with the wrong arguments fails as in Python, with Python's own `TypeError`. +### Console and Stop + +The console (`output`: «Konsole») is one chronological transcript, like Python +in a terminal: what the program printed - shown while it runs, not only at the +end - then every REPL input with its answer, every Tests report and every note, +in the order they happened. Errors and their hints stand where they happened. +Run and Reset clear it. It keeps the last 100,000 characters of a long output. + +While Python is busy for more than a moment (a program, a REPL input, Tests, +a visualization), the run button becomes **Stop** (`stop`: «Stoppen»). Stopping +ends Python's worker and starts a new one: the code stays, the console keeps +what was printed and adds `stoppedNote`, and the REPL starts in an empty +namespace (earlier entries can't be visualized anymore). Hosts can stop with +the command `{ id, type: 'stop' }`, and learn when Python is busy through the +`onBusyChange` prop. + ### REPL -Below the output panel, a `>>>` input line runs Python in the namespace of the +Below the console, a `>>>` input line runs Python in the namespace of the last Run, so learners can call the file's functions directly. An expression shows its value (`None` too, greyed out); a statement such as `a = 5` shows nothing; a `def` runs but adds the hint `replDefinitionHint`; an error shows @@ -276,7 +292,13 @@ files run as a black box. Version 3 adds `glpython:test` (same fields as `run`), which runs the doctests of that file. The embed still accepts every earlier version and answers every parent in the version that parent last used (version 1 until it hears otherwise), so older parents keep working. -`glpython:ready` reports `capabilities.files` and `capabilities.tests`. +Version 4 adds `glpython:stop`, which stops what Python is running by +restarting it (the source stays), and `glpython:busy` (`busy: true|false`), +which the embed sends to a version 4 parent when Python has been busy for more +than a moment (a program, a REPL input, Tests, a visualization), so the +parent's run button can become a stop button, as the workspace's own does. +`glpython:ready` reports `capabilities.files`, `capabilities.tests` and +`capabilities.stop`. ```ts iframe.contentWindow?.postMessage({ diff --git a/embed/demo.html b/embed/demo.html index 0b113f7..f8a9049 100644 --- a/embed/demo.html +++ b/embed/demo.html @@ -40,7 +40,7 @@ type: `glpython:${command}`, version: GLPYTHON_EMBED_PROTOCOL_VERSION, } - if (command !== 'reset') { + if (command !== 'reset' && command !== 'stop') { message.source = sources[mode] message.mode = mode } @@ -64,6 +64,13 @@ events.dataset.inputAvailable = String(event.data.capabilities?.input === true) send('load') } + // Version 4: while Python is busy (e.g. "while True: pass" typed into + // the REPL), Run becomes Stop. + if (event.data?.type === 'glpython:busy') { + const runButton = document.querySelector('[data-command="run"], [data-command="stop"]') + runButton.dataset.command = event.data.busy ? 'stop' : 'run' + runButton.textContent = event.data.busy ? '■ Stop' : 'Run' + } if (event.data?.type === 'glpython:resize' && Number.isFinite(event.data.height)) { iframe.style.height = `${Math.max(400, event.data.height)}px` } diff --git a/scripts/check-embed-protocol.mjs b/scripts/check-embed-protocol.mjs index 3ee7070..26993df 100644 --- a/scripts/check-embed-protocol.mjs +++ b/scripts/check-embed-protocol.mjs @@ -80,10 +80,10 @@ assert.equal(parseParentMessage({ }), null) // Version 1 (a single source) is still accepted, without files. -assert.equal(GLPYTHON_EMBED_PROTOCOL_VERSION, 3) +assert.equal(GLPYTHON_EMBED_PROTOCOL_VERSION, 4) assert.deepEqual(parseParentMessage({ type: 'glpython:run', version: 1, source: 'print(1)', mode: 'eval' }), { type: 'glpython:run', version: 1, source: 'print(1)', mode: 'eval' }) assert.equal(parseParentMessage({ type: 'glpython:run', version: 1, source: 'x', files: [{ path: 'main.py', source: 'x' }], mainFile: 'main.py' }), null) -assert.equal(parseParentMessage({ type: 'glpython:reset', version: 4 }), null) +assert.equal(parseParentMessage({ type: 'glpython:reset', version: 5 }), null) // Version 2: a program of several files and the one to run. const program = { @@ -116,6 +116,14 @@ assert.equal(parseParentMessage({ ...tests, version: 2 }), null) assert.equal(parseParentMessage({ type: 'glpython:test', version: 1, source: 'x' }), null) assert.deepEqual(parseParentMessage({ ...program, version: 3 }), { ...program, version: 3 }) +// Version 4: stopping what Python runs; not before version 4. Earlier +// messages are accepted in version 4 too. +assert.deepEqual(parseParentMessage({ type: 'glpython:stop', version: 4 }), { type: 'glpython:stop', version: 4 }) +assert.equal(parseParentMessage({ type: 'glpython:stop', version: 3 }), null) +assert.equal(parseParentMessage({ type: 'glpython:stop', version: 1 }), null) +assert.deepEqual(parseParentMessage({ ...tests, version: 4 }), { ...tests, version: 4 }) +assert.deepEqual(parseParentMessage({ type: 'glpython:reset', version: 4 }), { type: 'glpython:reset', version: 4 }) + const readyMessage = { type: 'glpython:ready', version: GLPYTHON_EMBED_PROTOCOL_VERSION, diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index 1d1f55f..f9f0143 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -1,7 +1,7 @@ import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react' import Editor, { useMonaco } from '@monaco-editor/react' import type { Monaco } from '@monaco-editor/react' -import { AlertTriangle, BookOpen, Copy, FileCode2, FlaskConical, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronRight, Ellipsis, Play, RotateCcw, SkipBack, SkipForward, Sparkles, SquareTerminal, Terminal, Workflow, X } from 'lucide-react' +import { AlertTriangle, BookOpen, Copy, Square, FileCode2, FlaskConical, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronRight, Ellipsis, Play, RotateCcw, SkipBack, SkipForward, Sparkles, SquareTerminal, Terminal, Workflow, X } from 'lucide-react' import { projectImportsGturtle, type Project } from './project' import { GraphicsWindow, type TurtleCommand } from './GraphicsWindow' import { buildAnnotationsUpTo, buildEvaluationChain, buildExecutedLinesUpTo, buildLocalsUpTo, buildSubstitutionsUpTo, collapseDeepCallChain, hasEvaluationChain, hiddenRangesWithin, removedLines, computeLineOffsets, computeOpenFramesUpTo, indexAstNodes, lineNumberAtOffset, locateNodeRenderedRange, nodeRange, renderFrameSource, substitutionModeSteps, type AstNode, type CollapsedCalls, type OpenFrame, type TraceResult, type TraceStep, type VisualizerMode } from './visualizer' @@ -47,6 +47,10 @@ type CoreStrings = { // visualizerCollapsedCalls. replVisualizeEntry?: string replReplayNote?: (reason: ReplReplayNote) => string + // The button that replaces the run button while Python is busy, and the + // note it leaves in the console. Optional, like visualizerCollapsedCalls. + stop?: string + stoppedNote?: string // Accessible name of the REPL's input line, and the note shown after a // "def"/"class" typed into the REPL. Optional like visualizerCollapsedCalls. replInputLabel?: string @@ -156,7 +160,7 @@ const defaultWorkspaceCapabilities: WorkspaceCapabilities = { type WorkspaceCommand = { id: number - type: 'run' | 'visualize' | 'reset' | 'test' + type: 'run' | 'visualize' | 'reset' | 'test' | 'stop' } // What the worker reports for a Tests run (see doctests.py). @@ -190,8 +194,16 @@ type GlPythonWorkspaceProps = { // Presets the visualizer's mode (e.g. from a LiaScript block). Without it // the mode last chosen in this browser is used, and «substitution» at first. visualizerMode?: VisualizerMode + // Called when Python starts or stops being busy for more than a moment + // (a program, a REPL input, Tests, a visualization) - while it is, the run + // button is a stop button. The embed passes it on to its parent. + onBusyChange?: (busy: boolean) => void } +// Busy for less than this, the run button stays as it is, so a quick REPL +// input doesn't make it flicker into a stop button and back. +const STOP_BUTTON_DELAY = 300 + const animationsStorageKey = 'glpython-visualizer-animations' const visualizerModeStorageKey = 'glpython-visualizer-mode' const chainCommentsStorageKey = 'glpython-chain-comments' @@ -239,6 +251,25 @@ type ReplEntry = { source: string result: { output: string; value?: string; isNone?: boolean; error?: string; errorHint?: ErrorHint | null; hint?: 'definition' | null; unparsed?: boolean } | null note?: ReplReplayNote + // What the input has printed so far, while it still runs. + live?: string + // From before Python was restarted (Stop): it can't be replayed anymore. + stale?: boolean +} + +// The console is one chronological transcript, like Python in a terminal: +// the program's output (see `output`), then in order every REPL input, Tests +// report and note (e.g. that Python was stopped). Run and Reset clear it. +type ConsoleEntry = + | ({ kind: 'repl' } & ReplEntry) + | { kind: 'tests'; id: number; report: TestReport } + | { kind: 'note'; id: number; text: string } + +// The console keeps the last part of a long output: a program printing in an +// endless loop must not fill the page's memory before it is stopped. +const CONSOLE_MAX_LENGTH = 100_000 +function keepConsoleTail(text: string): string { + return text.length <= CONSOLE_MAX_LENGTH ? text : `\u2026\n${text.slice(text.length - CONSOLE_MAX_LENGTH)}` } type ReplReplayNote = 'input' | 'differs' @@ -818,7 +849,7 @@ function ErrorHintNote({ hint, t, context = 'other' }: { hint: ErrorHint | null // while Python is running something. function ReplTranscriptEntry({ entry, definitionHint, t, onVisualize, busy }: { entry: ReplEntry; definitionHint: string; t: CoreStrings; onVisualize?: () => void; busy: boolean }) { const result = entry.result - const printed = result?.output.replace(/\n$/, '') + const printed = (result ? result.output : entry.live ?? '').replace(/\n$/, '') const visualizeLabel = t.replVisualizeEntry ?? 'Visualize' return ( @@ -848,6 +879,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { compact = false, command = null, visualizerMode: visualizerModePreset, + onBusyChange, } = props const workspaceCapabilities = { ...defaultWorkspaceCapabilities, ...capabilities } const [output, setOutput] = useState(() => t.readyOutput) @@ -883,11 +915,10 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { class: t.valueKindClass ?? 'class', module: t.valueKindModule ?? 'module', }), [t]) - const [testReport, setTestReport] = useState(null) // While a failing doctest example is visualized: the texts doctest compared. const [exampleExpectation, setExampleExpectation] = useState<{ got: string; want: string } | null>(null) const [isTesting, setIsTesting] = useState(false) - const [replEntries, setReplEntries] = useState([]) + const [consoleEntries, setConsoleEntries] = useState([]) // Every submitted input, oldest first, for arrow up/down. // Unlike the transcript it survives a Run. const [replHistory, setReplHistory] = useState([]) @@ -895,8 +926,31 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const [replInput, setReplInput] = useState('') const [isReplBusy, setIsReplBusy] = useState(false) const replIdRef = useRef(0) + // Set at a stop, so the next worker's "ready" doesn't clear the console; + // and whether the running program has printed anything yet (its first + // output replaces the «… wird ausgeführt» line). + const keepConsoleOnReadyRef = useRef(false) + const runStreamStartedRef = useRef(false) + // Streamed output waiting to be shown: applied together every 40 ms rather + // than one render per printed line. + const streamedOutputRef = useRef>([]) + const streamFlushTimerRef = useRef(null) + const flushStreamedOutput = () => { + streamFlushTimerRef.current = null + const chunks = streamedOutputRef.current + streamedOutputRef.current = [] + const program = chunks.filter((chunk) => chunk.id === undefined).map((chunk) => chunk.text).join('') + if (program) { + const append = runStreamStartedRef.current + runStreamStartedRef.current = true + setOutput((current) => keepConsoleTail(append ? current + program : program)) + } + const byInput = new Map() + for (const chunk of chunks) if (chunk.id !== undefined) byInput.set(chunk.id, (byInput.get(chunk.id) ?? '') + chunk.text) + if (byInput.size > 0) setConsoleEntries((entries) => entries.map((entry) => entry.kind === 'repl' && !entry.result && byInput.has(entry.id) ? { ...entry, live: keepConsoleTail((entry.live ?? '') + byInput.get(entry.id)) } : entry)) + } const replSourcesRef = useRef(new Map()) - const outputRef = useRef(null) + const outputRef = useRef(null) // Set while the visualizer shows a REPL input rather than a project file: // the path its own pseudo-file is traced under (see REPL_PATH in the worker). const [visualRootPath, setVisualRootPath] = useState(null) @@ -923,16 +977,28 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { localStorage.setItem(animationsStorageKey, String(animationsEnabled)) }, [animationsEnabled]) - useEffect(() => { + // Starts Python in a new worker, at mount and after a stop. + const startWorker = () => { const worker = new Worker(new URL('./pyodide-worker.js', import.meta.url)) - worker.onmessage = (event: MessageEvent } & Partial>) => { + worker.onmessage = (event: MessageEvent } & Partial>) => { const currentTranslation = stringsRef.current - if (event.data.type === 'ready') { setOutput(currentTranslation.readyOutput); setRuntimeState('ready') } + // After a stop the console keeps its transcript. + if (event.data.type === 'ready') { if (!keepConsoleOnReadyRef.current) setOutput(currentTranslation.readyOutput); keepConsoleOnReadyRef.current = false; setRuntimeState('ready') } + // What a program or a REPL input (by its id) prints while it runs, + // shown in batches (see flushStreamedOutput). + if (event.data.type === 'output') { + streamedOutputRef.current.push({ id: event.data.id, text: event.data.text ?? '' }) + if (streamFlushTimerRef.current === null) streamFlushTimerRef.current = window.setTimeout(flushStreamedOutput, 40) + } + // A final result brings the whole output; what is still waiting to be + // shown from the stream would come twice. + if (event.data.type === 'result' || event.data.type === 'error') streamedOutputRef.current = streamedOutputRef.current.filter((chunk) => chunk.id !== undefined) + if (event.data.type === 'repl-result') streamedOutputRef.current = streamedOutputRef.current.filter((chunk) => chunk.id !== event.data.id) if (event.data.type === 'error') setRuntimeState((state) => state === 'loading' ? 'failed' : state) if (event.data.type === 'result' || event.data.type === 'error') { setOutput(event.data.output || currentTranslation.noOutput); setOutputErrorHint(event.data.type === 'error' ? event.data.errorHint ?? null : null); setGraphics(event.data.graphics ?? []); setIsRunning(false) } if (event.data.type === 'input_request') { setInputValue(''); setInputRequest(event.data.prompt ?? '') } if (event.data.type === 'test-result') { - setTestReport(event.data as TestReport) + setConsoleEntries((entries) => [...entries, { kind: 'tests', id: ++replIdRef.current, report: event.data as TestReport }]) setIsTesting(false) } if (event.data.type === 'repl-result') { @@ -940,9 +1006,9 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const result = { output, value, isNone, error: error ?? undefined, errorHint, hint, unparsed } // A visualized doctest example that doesn't parse comes back here // too, and has no transcript entry yet. - setReplEntries((entries) => entries.some((entry) => entry.id === id) - ? entries.map((entry) => entry.id === id ? { ...entry, result } : entry) - : [...entries, { id, source: replSourcesRef.current.get(id) ?? '', result }]) + setConsoleEntries((entries) => entries.some((entry) => entry.kind === 'repl' && entry.id === id) + ? entries.map((entry) => entry.kind === 'repl' && entry.id === id ? { ...entry, result, live: undefined } : entry) + : [...entries, { kind: 'repl', id, source: replSourcesRef.current.get(id) ?? '', result }]) replSourcesRef.current.delete(id) if (graphics) setGraphics(graphics) setIsReplBusy(false) @@ -950,7 +1016,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { } if (event.data.type === 'repl-replay-note') { const { id = -1, note } = event.data - setReplEntries((entries) => entries.map((entry) => entry.id === id ? { ...entry, note } : entry)) + setConsoleEntries((entries) => entries.map((entry) => entry.kind === 'repl' && entry.id === id ? { ...entry, note } : entry)) setIsVisualizerLoading(false) } if (event.data.type === 'trace-result') { @@ -969,7 +1035,13 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { worker.postMessage({ type: 'init-input-channel', buffer }) } workerRef.current = worker + return worker + } + useEffect(() => { + const worker = startWorker() return () => worker.terminate() + // Once: a stop starts the next worker itself. + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) useEffect(() => { @@ -996,7 +1068,8 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const runCode = () => { if (!workerRef.current) return if (workspaceCapabilities.graphics && projectImportsGturtle(project)) onGraphicsOpenChange(true) - setIsRunning(true); setOutput(t.runningOutput(project.mainFile)); setOutputErrorHint(null); setReplEntries([]); setTestReport(null) + runStreamStartedRef.current = false + setIsRunning(true); setOutput(t.runningOutput(project.mainFile)); setOutputErrorHint(null); setConsoleEntries([]) workerRef.current.postMessage({ type: 'run', project: { @@ -1038,6 +1111,27 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { setIsVisualizerLoading(true) workerRef.current.postMessage({ type: 'run-visual-repl', id: ++replIdRef.current, source, namespace: 'test' }) } + // Stops whatever Python is running by ending its worker and starting a new + // one - the only way to end e.g. "while True:" without input()'s shared + // memory. The code stays, the console keeps what was printed and says that + // Python restarted; earlier REPL inputs can't be visualized anymore, as + // their session is gone. + const stopPython = () => { + workerRef.current?.terminate() + // What was printed until now stays in the console. + if (streamFlushTimerRef.current !== null) window.clearTimeout(streamFlushTimerRef.current) + flushStreamedOutput() + keepConsoleOnReadyRef.current = true + setIsRunning(false); setIsReplBusy(false); setIsTesting(false); setIsVisualizerLoading(false) + setInputRequest(null); setInputValue('') + setRuntimeState('loading') + setOutput((current) => current === t.runningOutput(project.mainFile) ? '' : current) + setConsoleEntries((entries) => [ + ...entries.map((entry) => entry.kind === 'repl' ? { ...entry, stale: true, result: entry.result ?? { output: entry.live ?? '' }, live: undefined } : entry), + { kind: 'note' as const, id: ++replIdRef.current, text: t.stoppedNote ?? 'Stopped. Python was restarted.' }, + ]) + startWorker() + } const exitVisualizer = () => { setExampleExpectation(null) setVisualTrace(null) @@ -1057,7 +1151,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (!workerRef.current || !source.trim() || isRunning || isReplBusy) return if (visualTrace) exitVisualizer() const id = rememberReplInput(source) - setReplEntries((entries) => [...entries, { id, source, result: null }]) + setConsoleEntries((entries) => [...entries, { kind: 'repl', id, source, result: null }]) setIsReplBusy(true) workerRef.current.postMessage({ type: 'repl', id, source }) } @@ -1066,7 +1160,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // entry ran in, or the entry gets a note why it can't. const visualizeReplEntry = (id: number) => { if (!workerRef.current || isRunning || isReplBusy || isVisualizerLoading) return - setReplEntries((entries) => entries.map((entry) => entry.id === id && entry.note ? { ...entry, note: undefined } : entry)) + setConsoleEntries((entries) => entries.map((entry) => entry.kind === 'repl' && entry.id === id && entry.note ? { ...entry, note: undefined } : entry)) setExampleExpectation(null) setIsVisualizerLoading(true) workerRef.current.postMessage({ type: 'visualize-repl-entry', id }) @@ -1084,10 +1178,13 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { setReplInput(index < replHistory.length ? replHistory[index] : '') } } + // The console follows new output, unless the learner scrolled up to read + // something earlier. + const consoleAtBottomRef = useRef(true) useEffect(() => { const element = outputRef.current - if (element) element.scrollTop = element.scrollHeight - }, [replEntries]) + if (element && consoleAtBottomRef.current) element.scrollTop = element.scrollHeight + }, [consoleEntries, output]) useEffect(() => { if (!command) return // A host's run ends a visualization, so the program's own output shows @@ -1101,9 +1198,10 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { exitVisualizer() runTests() } + if (command.type === 'stop') stopPython() if (command.type === 'reset') { exitVisualizer() - setReplEntries([]) + setConsoleEntries([]) setOutput(t.readyOutput) setOutputErrorHint(null) setGraphics([]) @@ -1113,6 +1211,16 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [command?.id]) const isVisualizing = visualTrace !== null + const isBusy = isRunning || isReplBusy || isTesting || isVisualizerLoading + const [showStopButton, setShowStopButton] = useState(false) + useEffect(() => { + if (!isBusy) { setShowStopButton(false); return } + const timer = window.setTimeout(() => setShowStopButton(true), STOP_BUTTON_DELAY) + return () => window.clearTimeout(timer) + }, [isBusy]) + const onBusyChangeRef = useRef(onBusyChange) + onBusyChangeRef.current = onBusyChange + useEffect(() => { onBusyChangeRef.current?.(showStopButton) }, [showStopButton]) // Without emptyOutput, a visualization of a program that prints nothing // and raises no error has no output panel - decided for the whole trace, // so the panel doesn't appear mid-way at the first print. The REPL's input @@ -1826,7 +1934,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { return (
-
{project.name}/{selectedFile.path}
{isVisualizing ? null : workspaceCapabilities.actions ? <>{workspaceCapabilities.tests && } : null}
{isVisualizing &&
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
}{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
}{(workspaceCapabilities.editor || isVisualizing) &&
{openFiles.map((path) => { const file = project.files[path]; return })}
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false }} />}
}{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{!isVisualizing && testReport ? : null}{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
}
{isGraphicsOpen ? : }
+
{project.name}/{selectedFile.path}
{isVisualizing ? null : workspaceCapabilities.actions ? <>{workspaceCapabilities.tests && }{showStopButton ? : } : null}
{isVisualizing &&
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
}{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
}{(workspaceCapabilities.editor || isVisualizing) &&
{openFiles.map((path) => { const file = project.files[path]; return })}
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false }} />}
}{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ? : entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}
{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
}
{isGraphicsOpen ? : }
{inputRequest !== null &&
respondToInput(true)}>
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); respondToInput(false) }}>
{t.inputDialogTitle}

{inputRequest || t.inputDialogFallbackPrompt}

setInputValue(event.target.value)} placeholder={t.inputPlaceholder} />
}
) diff --git a/src/embed/App.tsx b/src/embed/App.tsx index 3943e1d..41b9b3e 100644 --- a/src/embed/App.tsx +++ b/src/embed/App.tsx @@ -106,7 +106,13 @@ export function App() { } parentVersionRef.current = message.version + if (message.type === 'glpython:stop') { + setCommand({ id: ++commandIdRef.current, type: 'stop' }) + return + } if (message.type === 'glpython:reset') { + // The new workspace starts idle; the parent's stop button must go. + if (parentVersionRef.current >= 4) sendToParent({ type: 'glpython:busy', busy: false }) setCommand(null) setWorkspaceGeneration((generation) => generation + 1) return @@ -133,6 +139,7 @@ export function App() { input: typeof SharedArrayBuffer !== 'undefined', files: true, tests: true, + stop: true, }, }) return () => window.removeEventListener('message', onMessage) @@ -151,6 +158,12 @@ export function App() { return () => observer.disconnect() }, [configuredParentOrigin, sendToParent]) + // Version 4: the parent's run button becomes a stop button while Python is + // busy. Older parents don't know the message. + const reportBusy = useCallback((busy: boolean) => { + if (parentVersionRef.current >= 4) sendToParent({ type: 'glpython:busy', busy }) + }, [sendToParent]) + if (!configuredParentOrigin && window.parent !== window) { return

This embed is missing a valid parentOrigin.

} @@ -167,6 +180,7 @@ export function App() { project={project} command={command} visualizerMode={visualizerModeFromLocation()} + onBusyChange={reportBusy} activeFile={project.mainFile} openFiles={[project.mainFile]} onOpenFile={() => undefined} diff --git a/src/embed/style.css b/src/embed/style.css index 92ff53b..b472289 100644 --- a/src/embed/style.css +++ b/src/embed/style.css @@ -80,13 +80,12 @@ body, min-height: 140px; } -/* The Tests report and the output are as tall as their content too (the - host's iframe follows them), instead of scrolling inside a fixed height - as in the IDE - so a hint below a traceback is never scrolled away. */ -.embed-app .test-report, -.embed-app .output-panel pre { - max-height: none; - overflow: visible; +/* The console is as tall as its content too (the host's iframe follows it), + so a hint below a traceback is never scrolled away - up to 480px: a program + printing in a loop scrolls there, following its newest line, rather than + stretching the course page. */ +.embed-app .console { + max-height: 480px; } /* "?size=large": bigger code (20px, line height 1.6), the bar's text 16px, diff --git a/src/embedProtocol.ts b/src/embedProtocol.ts index 9b18e0d..4c1e991 100644 --- a/src/embedProtocol.ts +++ b/src/embedProtocol.ts @@ -1,10 +1,13 @@ // Version 2 adds programs of several files ("files" and "mainFile" on load, // run and visualize); version 3 adds "glpython:test", which runs the -// doctests of the file to run. The embed still accepts every earlier version +// doctests of the file to run; version 4 adds "glpython:stop", which stops +// what Python is running (by restarting it), and "glpython:busy", with which +// the embed tells the parent when Python is busy, so its run button can turn +// into a stop button. The embed still accepts every earlier version // and answers every parent in the version that parent last used - starting // with 1 - so courses with an older macro keep working unchanged. -export const GLPYTHON_EMBED_PROTOCOL_VERSION = 3 -export const GLPYTHON_SUPPORTED_PROTOCOL_VERSIONS = [1, 2, 3] as const +export const GLPYTHON_EMBED_PROTOCOL_VERSION = 4 +export const GLPYTHON_SUPPORTED_PROTOCOL_VERSIONS = [1, 2, 3, 4] as const export type GlPythonProtocolVersion = typeof GLPYTHON_SUPPORTED_PROTOCOL_VERSIONS[number] export const GLPYTHON_MAX_SOURCE_LENGTH = 500_000 export const GLPYTHON_MAX_FILES = 20 @@ -38,12 +41,19 @@ export type GlPythonResetMessage = { version: GlPythonProtocolVersion } +// Version 4: stop what Python is running. The code stays; Python restarts. +export type GlPythonStopMessage = { + type: 'glpython:stop' + version: GlPythonProtocolVersion +} + export type GlPythonParentMessage = | GlPythonLoadMessage | GlPythonRunMessage | GlPythonVisualizeMessage | GlPythonTestMessage | GlPythonResetMessage + | GlPythonStopMessage export type GlPythonReadyMessage = { type: 'glpython:ready' @@ -54,9 +64,21 @@ export type GlPythonReadyMessage = { files?: boolean // Whether version 3 "glpython:test" is understood. tests?: boolean + // Whether version 4 "glpython:stop" is understood (and "glpython:busy" + // sent). + stop?: boolean } } +// Version 4, sent to a parent that speaks it: Python started (busy: true) or +// finished (false) something that runs for more than a moment - a program, +// a REPL input, Tests or a visualization. +export type GlPythonBusyMessage = { + type: 'glpython:busy' + version: GlPythonProtocolVersion + busy: boolean +} + export type GlPythonResizeMessage = { type: 'glpython:resize' version: GlPythonProtocolVersion @@ -73,6 +95,7 @@ export type GlPythonErrorMessage = { export type GlPythonChildMessage = | GlPythonReadyMessage | GlPythonResizeMessage + | GlPythonBusyMessage | GlPythonErrorMessage export function isEmbedMode(value: unknown): value is EmbedMode { @@ -110,6 +133,7 @@ export function parseParentMessage(value: unknown): GlPythonParentMessage | null if (!GLPYTHON_SUPPORTED_PROTOCOL_VERSIONS.includes(message.version as GlPythonProtocolVersion)) return null const version = message.version as GlPythonProtocolVersion if (message.type === 'glpython:reset') return message as GlPythonResetMessage + if (message.type === 'glpython:stop') return version >= 4 ? message as GlPythonStopMessage : null if ( (message.type !== 'glpython:load' && message.type !== 'glpython:run' && message.type !== 'glpython:visualize' && message.type !== 'glpython:test') || (message.type === 'glpython:test' && version < 3) diff --git a/src/i18n.ts b/src/i18n.ts index 41cce71..d07d583 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -62,7 +62,9 @@ export const coreStrings: Record = { hintLabel: 'Hint:', testsExamplesError: (message) => `The examples could not be read: ${message}`, closeFile: (path) => `Close ${path}`, - output: 'Output', + output: 'Console', + stop: 'Stop', + stoppedNote: 'Stopped. Python was restarted.', pyodideRuntime: 'Pyodide runtime', runsFile: (mainFile) => `Runs ${mainFile}`, noOutput: '(No output)', @@ -132,7 +134,9 @@ export const coreStrings: Record = { hintLabel: 'Hinweis:', testsExamplesError: (message) => `Die Beispiele konnten nicht gelesen werden: ${message}`, closeFile: (path) => `${path} schliessen`, - output: 'Ausgabe', + output: 'Konsole', + stop: 'Stoppen', + stoppedNote: 'Gestoppt. Python wurde neu gestartet.', pyodideRuntime: 'Pyodide-Laufzeit', runsFile: (mainFile) => `Führt ${mainFile} aus`, noOutput: '(Keine Ausgabe)', diff --git a/src/index.ts b/src/index.ts index 16b94cf..f42807a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export type { GlPythonErrorMessage, GlPythonLoadMessage, GlPythonResetMessage, + GlPythonStopMessage, GlPythonRunMessage, GlPythonSourceMessage, GlPythonVisualizeMessage, @@ -29,6 +30,7 @@ export type { GlPythonParentMessage, GlPythonReadyMessage, GlPythonResizeMessage, + GlPythonBusyMessage, } from './embedProtocol' export type { Project, ProjectFile, ProjectFileKind } from './project' diff --git a/src/pyodide-worker.js b/src/pyodide-worker.js index a6255fd..d61ab3a 100644 --- a/src/pyodide-worker.js +++ b/src/pyodide-worker.js @@ -171,6 +171,30 @@ async function replayReplEntry(pyodide, entryId) { } } +// What a program or REPL input prints, passed on while it runs, so a long or +// endless program shows its output - and can be seen and stopped. At most 50 +// messages per 50 ms; beyond that the text waits for the next print, so a +// printing loop doesn't flood the page. The final result brings the whole +// output again. `id` is the REPL input's, undefined for a program. +function outputStream(id) { + let pending = '' + let windowStart = 0 + let sent = 0 + return (text) => { + pending += text + const now = Date.now() + if (now - windowStart >= 50) { + windowStart = now + sent = 0 + } + if (sent < 50) { + self.postMessage({ type: 'output', id, text: pending }) + pending = '' + sent += 1 + } + } +} + self.onmessage = async (event) => { if (event.data.type === 'init-input-channel') { inputControl = new Int32Array(event.data.buffer, 0, 2) @@ -185,8 +209,9 @@ self.onmessage = async (event) => { await mountProjectFiles(pyodide, event.data.project.files) lastRunFiles = event.data.project.files lastRunMainFile = event.data.project.mainFile - pyodide.setStdout({ batched: (text) => { runOutput += `${text}\n` } }) - pyodide.setStderr({ batched: (text) => { runOutput += `${text}\n` } }) + const stream = outputStream(undefined) + pyodide.setStdout({ batched: (text) => { runOutput += `${text}\n`; stream(`${text}\n`) } }) + pyodide.setStderr({ batched: (text) => { runOutput += `${text}\n`; stream(`${text}\n`) } }) hasRun = true lastRunOutcome = null replLog = [] @@ -229,8 +254,12 @@ self.onmessage = async (event) => { let output = '' try { const pyodide = await getPyodide() - pyodide.setStdout({ batched: (text) => { output += `${text}\n` } }) - pyodide.setStderr({ batched: (text) => { output += `${text}\n` } }) + // Nothing may have been run yet in this worker (e.g. right after a + // stop), and the drawing below imports gturtle. + if (!pyodide.FS.analyzePath('/gturtle.py').exists) pyodide.FS.writeFile('/gturtle.py', turtleModule) + const stream = outputStream(event.data.id) + pyodide.setStdout({ batched: (text) => { output += `${text}\n`; stream(`${text}\n`) } }) + pyodide.setStderr({ batched: (text) => { output += `${text}\n`; stream(`${text}\n`) } }) const resultJson = String(callPython(pyodide, '_glpython_repl', event.data.source)) replLog.push({ id: event.data.id, source: event.data.source, output, resultJson }) const result = JSON.parse(resultJson) diff --git a/src/runtime/session.py b/src/runtime/session.py index c9589d9..bbbebb2 100644 --- a/src/runtime/session.py +++ b/src/runtime/session.py @@ -9,6 +9,12 @@ # the next Run replaces it with a fresh one. _glpython_repl_namespace = {"__name__": "__main__", "__builtins__": builtins} +# The project's files are mounted at "/" (and gturtle.py next to them), so a +# REPL input can import them even before the first Run - e.g. right after a +# stop restarted Python. +if "/" not in sys.path: + sys.path.insert(0, "/") + _GLPYTHON_REPL_PATH = "" _GLPYTHON_REPL_MAX_REPR = 10000 diff --git a/src/style.css b/src/style.css index 1cb1a35..24ab8f4 100644 --- a/src/style.css +++ b/src/style.css @@ -135,7 +135,11 @@ .runtime-badge--failed { color: var(--glp-error); background: var(--glp-error-bg); } .runtime-badge--failed .pulse { background: var(--glp-error); } .output-hint { color: var(--glp-text-muted); font-size: 13px; } -.output-panel pre { min-height: 90px; margin: 0; padding: 12px; color: var(--glp-text); background: var(--glp-surface); white-space: pre-wrap; font: 15px/1.6 var(--glp-font-code); } +/* The console: one transcript of
 parts (the program's output, each REPL
+   input) with Tests reports and notes in between, scrolling as a whole. */
+.console { min-height: 90px; max-height: 240px; overflow-y: auto; padding: 12px; color: var(--glp-text); background: var(--glp-surface); font: 15px/1.6 var(--glp-font-code); }
+.console pre { margin: 0; color: inherit; white-space: pre-wrap; font: inherit; }
+.console-note { margin: 6px 0 0; color: var(--glp-text-muted); font: italic 13px/1.5 var(--glp-font-ui); }
 
 .statusbar { min-height: 29px; justify-content: space-between; padding: 0 31px; color: var(--glp-text-muted); background: var(--glp-bg); border-top: 1px solid var(--glp-border); font-size: 13px; }
 .statusbar span { display: inline-flex; align-items: center; gap: 6px; }
@@ -307,7 +311,6 @@
 .frame-collapsed-calls-label { color: var(--glp-text-muted); font: italic 13px/1.5 var(--glp-font-code); white-space: nowrap; }
 .frame-collapsed-calls > .frame-box { margin-left: 0; }
 
-.output-panel pre { max-height: 240px; overflow-y: auto; }
 .repl-entry { display: block; margin-top: 6px; }
 .repl-entry > span { display: block; }
 .repl-entry > .repl-echo { display: flex; align-items: baseline; gap: 12px; }
@@ -371,7 +374,7 @@
 .frame-hint { padding: 2px 12px 4px; color: var(--glp-text-muted); font: italic 13px/1.5 var(--glp-font-ui); white-space: normal; }
 
 
-.test-report { max-height: 360px; overflow-y: auto; padding: 12px; font: 15px/1.6 var(--glp-font-code); color: var(--glp-text); background: var(--glp-surface); }
+.test-report { margin-top: 6px; }
 .test-report pre { min-height: 0; max-height: none; margin: 0; padding: 0; background: transparent; white-space: pre-wrap; font: inherit; }
 .test-output { margin-bottom: 8px !important; color: var(--glp-text-muted); }
 .test-summary { margin: 0 0 8px; font-weight: 700; }

From a060afc39742dc7e4bc0891f33f06013df00268a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=C3=A9dric=20Vogel?= 
Date: Sun, 25 Oct 2026 23:30:25 +0100
Subject: [PATCH 2/2] Run and Stop are one button that is never disabled, so
 keyboard focus stays

Starting with Enter and stopping with Enter again kept failing: the button
was swapped for another element, and disabled in the 300 ms before Stop
shows, and either drops keyboard focus.

Co-Authored-By: Claude Opus 5.5 
---
 src/GlPythonWorkspace.tsx | 6 ++++--
 1 file changed, 4 insertions(+), 2 deletions(-)

diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx
index f9f0143..44defd4 100644
--- a/src/GlPythonWorkspace.tsx
+++ b/src/GlPythonWorkspace.tsx
@@ -48,7 +48,9 @@ type CoreStrings = {
   replVisualizeEntry?: string
   replReplayNote?: (reason: ReplReplayNote) => string
   // The button that replaces the run button while Python is busy, and the
-  // note it leaves in the console. Optional, like visualizerCollapsedCalls.
+  // note it leaves in the console (one button that is never disabled, so
+  // keyboard focus stays on it).
+  // Optional, like visualizerCollapsedCalls.
   stop?: string
   stoppedNote?: string
   // Accessible name of the REPL's input line, and the note shown after a
@@ -1934,7 +1936,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) {
 
   return (
     
-
{project.name}/{selectedFile.path}
{isVisualizing ? null : workspaceCapabilities.actions ? <>{workspaceCapabilities.tests && }{showStopButton ? : } : null}
{isVisualizing &&
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
}{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
}{(workspaceCapabilities.editor || isVisualizing) &&
{openFiles.map((path) => { const file = project.files[path]; return })}
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false }} />}
}{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ? : entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}
{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
}
{isGraphicsOpen ? : }
+
{project.name}/{selectedFile.path}
{isVisualizing ? null : workspaceCapabilities.actions ? <>{workspaceCapabilities.tests && } : null}
{isVisualizing &&
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
}{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
}{(workspaceCapabilities.editor || isVisualizing) &&
{openFiles.map((path) => { const file = project.files[path]; return })}
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false }} />}
}{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ? : entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}
{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
}
{isGraphicsOpen ? : }
{inputRequest !== null &&
respondToInput(true)}>
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); respondToInput(false) }}>
{t.inputDialogTitle}

{inputRequest || t.inputDialogFallbackPrompt}

setInputValue(event.target.value)} placeholder={t.inputPlaceholder} />
}
)