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..44defd4 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,12 @@ 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 (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 // "def"/"class" typed into the REPL. Optional like visualizerCollapsedCalls. replInputLabel?: string @@ -156,7 +162,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 +196,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 +253,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 +851,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 +881,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 +917,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 +928,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 +979,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 +1008,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 +1018,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 +1037,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 +1070,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 +1113,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 +1153,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 +1162,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 +1180,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 +1200,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 +1213,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 +1936,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 && } : 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; }