Embeddable Python editor + step-by-step execution visualizer + turtle graphics panel, extracted from the glpython IDE (repo) so it can be reused in different host applications ("shells").
This package owns:
- The Monaco-based code editor and tabs for a
Project's files. - The "Run" / "Visualize" workflow, backed by a Pyodide web worker.
- The step-by-step AST execution visualizer (
Frame, substitutions, animated value flights, closing boxes, etc). - The turtle graphics panel (
GraphicsWindow), shown when the running program importsgturtle.
It deliberately does not own: top bar / navigation, sidebar / file tree, authentication, locale switching, or project persistence/sharing — those are shell concerns and stay in the host application.
This package is not published to the npm registry. Install it directly from a tagged commit on GitHub:
{
"dependencies": {
"glpython-editor": "git+https://github.com/glpython-edu/glpython-editor.git#v0.1.9"
}
}Installing from git automatically runs npm run build via the prepare
script, so dist/ is generated on install.
The host application must provide these (they are not bundled):
react/react-dom(^19)@monaco-editor/react(^4.7)lucide-react(^1.47)
import { GlPythonWorkspace, initialProject, type CoreStrings } from 'glpython-editor'
import 'glpython-editor/style.css'
function Shell() {
const [project, setProject] = useState(initialProject)
const [activeFile, setActiveFile] = useState(project.mainFile)
const [openFiles, setOpenFiles] = useState([project.mainFile])
const [isGraphicsOpen, setIsGraphicsOpen] = useState(false)
return (
<main className="main-area">
<GlPythonWorkspace
project={project}
activeFile={activeFile}
openFiles={openFiles}
onOpenFile={(path) => {
setActiveFile(path)
setOpenFiles((files) => (files.includes(path) ? files : [...files, path]))
}}
onCloseFile={(path) => {
setOpenFiles((files) => files.filter((f) => f !== path))
}}
onFileCodeChange={(path, code) => {
setProject((p) => updateProjectFile(p, path, code))
}}
isGraphicsOpen={isGraphicsOpen}
onGraphicsOpenChange={setIsGraphicsOpen}
t={coreStrings}
/>
</main>
)
}t must implement the CoreStrings interface (all UI strings used inside
the workspace), so the host application controls localization.
visualizerCollapsedCalls is optional; without it, an English fallback is
used.
When a program raises an exception, the visualizer still shows every step up
to the failure. The trace ends in one error step at the expression that
raised, in the call frame it was raised in. It still starts before the first
step; a red marker on the scrubber shows where the failure is. At that step the
expression is marked red with Python's untranslated last traceback line below
it, and the output panel shows a traceback of the learner's own lines.
TraceResult carries that traceback as error and its last line as
errorMessage. A SyntaxError produces no steps and keeps the warning banner.
Deep chains of open calls, such as a recursion without a base case, collapse
their middle boxes into one "… N more calls of f(x) …" marker
(visualizerCollapsedCalls), so the layout stays usable.
A call ends as its value, None too: also a call that is a whole statement
(a line of the program, a REPL input, a doctest example), when it calls the
learner's own function - a def or lambda of the project's files, of a
library loaded with it, a method of their class - so a function without a
matching return visibly gives None. Only a built-in, a method of a
built-in type or a function of a module that comes with Python or glpython
(print(...), xs.append(x), random.shuffle(xs), gturtle) keeps its call
text as a statement, where None would only be noise. The console still
shows nothing for None, as CPython's REPL.
The visualizer's toolbar is one line: first step, previous step, next step,
last step, the scrubber, the step counter, the box/substitution switch, the
⋯ Options menu (animated steps, and with export the → chain's comments and
copy button) and ✕ to exit (visualizerFirstStep, visualizerLastStep,
visualizerOptions; ✕ uses exitVisualizer).
A call box lists its variables in a row below its code; the box of the current
call, when it's taller than the view, keeps that row at the view's bottom edge
while the box is on screen. The file's (global) variables are in a footer below
the code that doesn't scroll with it, at every width, so the code has the full
width: name and value (auswerten Funktion, x 3) in a row that wraps, at
most about three lines high (then it scrolls). A program without global
variables has no footer; one with them has it from the start, as tall as at
the last step. Hovering over an entry, tapping it (touch) or focusing it opens
a box over the code, upwards from the pointer and inside the visualizer (at
most its width and ⅔ of its height, scrolling inside): a function's (or
class's) whole definition, highlighted, also while it's open as a call box; any
other value in full. The box stays while the pointer moves into it; it closes
when the pointer leaves both, on a tap elsewhere or with Escape. Arrows from a
call to a global function, and between a global variable and the code, end at
its entry in the footer. Text: visualizerGlobals (the footer's label for
screen readers), optional.
The toolbar switches between two ways of showing a function call.
In substitution mode (the default), a call to a function whose body is a
single return <expression> (optionally after a docstring) is replaced at its
call site by that expression, with its parameters already replaced by their
values, as in the course's paper notation:
quadrat(a + 2) → quadrat(5 + 2) → quadrat(7) → 7 * 7 → 49. Every click is one
substitution, and a replacing expression gets parentheses where an operator
works on it (1 * (3 * 2)). Any other function still opens a box, and the
outermost box says why at its top: notenschnitt hat lokale Variablen – der Aufruf wird als Kasten gezeigt. (visualizerBoxReason, one sentence per
reason: local variables or a loop, an if choosing what runs, several
statements, no return <expression>, an expression that can't be written out;
the runtime's boxReason on the call's step). In box mode every call opens
a box.
The mode last chosen is remembered per browser. A host can preset it with the
visualizerMode prop ('box', 'substitution' or 'tree'), and the embed with
the visualizer=box, visualizer=substitution or visualizer=tree query
parameter. The toggle's texts are visualizerModeLabel, visualizerModeBox,
visualizerModeSubstitution and visualizerModeTree, all optional like
visualizerBoxHint (the box's note without a reason).
With the capability tree (off by default; the embed's features=tree), the
switch has a third mode, Baum: an expression is shown as its syntax tree
while it is evaluated. Without the capability there is no third button, and a
stored or preset 'tree' shows the substitution mode. For an expression e
(the value of an expression statement, an assignment or a return, an if's
or while's condition; not a bare name or literal):
- Open: one step replaces
eby its tree, in a box at its place in the line. - Reductions: exactly the substitution mode's steps, each one a subtree
becoming its value; a node without a step of its own (a display,
-x) becomes its value with its last child. A call with a singlereturn <expression>is replaced by that expression's tree. Any other call, once its box opens, has the box in its node's place - the parent's edge leads to the box's top, its siblings stay on their row - without the note of the substitution mode (the box in the call's place shows it stays a box); inside it, each line has its own tree and calls of their own boxes. - Close: one step from the trivial tree to the value in the line.
For a call of a function of the program's own, each edge to an argument is
labelled with the parameter it binds to (summe(2 + 2, 3 * 4) with
def summe(x, y): x on the edge to 2 + 2, y on the one to 3 * 4),
while the node keeps the function's name. A keyword argument is labelled with
its parameter, a parameter left to its default has no edge, and there are no
labels after a *args or for **kwargs; built-in and library functions
(len, max) have none. The labels stay while the arguments are reduced,
until the call is replaced or its box opens. They sit in the gap above the
argument, beside the edge's end, in a smaller code font; the layout keeps
room for them, so they touch no node and no other label.
So x = 2 + 3 * 4 takes 5 steps instead of 3. Labels are Python's (+,
quadrat, [ ], a name is a leaf); there are no parentheses, so
(2 + 3) * 4 and 2 + 3 * 4 differ only in shape. A tree keeps its nodes in
place from step to step: it is laid out where it opens, where a call is
replaced and where a call's box opens and closes, with room for the widest
value each node takes. A box is measured as drawn; while it is open, the tree
moves only if the box grows (a line of it gets longer, a tree opens in it)
and never when it shrinks again. With the
visualizer's animations, a reduced subtree's nodes move up into it and fade
(not with reduced motion). For screen readers each tree is described by its
text at that step, as the → chain writes it; a tree wider than the visualizer
scrolls as in listTree (root in the middle, hints at the edges, arrow keys).
The steps come from treeModeSteps, the trees from buildExpressionTree
(src/treeMode.ts); npm run test:tree checks them on traces of the runtime.
Indexing a variable is one step to the element, in both modes:
dauer[0] + dauer[1] → 1 + dauer[1] → 1 + 2 → 3, while the list itself stays in
the variables panel. The same holds for a tuple, a string or a dict, and for a
slice (w[1:3]). A variable passed to a call is still replaced by its value
(notenschnitt(noten) → notenschnitt([5, 4.5, 6])), and so is a parameter in a
substituted function's expression. A list written out and indexed directly
is one step too: [1, 2, 30][1] → 2.
A list, tuple, set or dict written out has no step of its own: once its parts
are values, it reads as its value, as on paper. So a display of literals has
no steps at all, nested at any depth and with negative numbers (-3, -2.5):
baum = ["+", ["*", 2, ["-", 7, -3]], 4] is one step, the assignment. Otherwise
only the parts that change have steps: [1 + 2, 3, 4] → [3, 3, 4],
["+", 1, ["*", n, 3]] → ["+", 1, ["*", 2, 3]]. A display keeps its step only
where its value reads differently: unpacking ([*a, 2] → [1, 2]) and a set or
dict whose duplicates go ({1, 1} → {1}). Likewise -x with x = 7 is one
step, to -7; the minus has a step of its own only where it changes what is
shown (-x with x = -7 reads --7, then 7).
A long value is abbreviated, in the step and in the variables panel alike,
keeping its first elements and its last one: [1, 2, 3, 4, 5, …, 100].
- Shown completely: a list, tuple, set or dict of up to 10 elements, in a
value of up to 160 characters (so are the course's expression trees, e.g.
['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]), and a string of up to 80 characters. - Abbreviated: a longer container keeps its first 5 elements and its last. If that is still longer than 160 characters, it keeps fewer and shortens long elements too. A long string is shortened in the middle.
- In full: an abbreviated value is underlined with dots. A click (or Enter)
shows it in full, with its number of elements or characters (
100 Werte, also below it in a call box's variables). A global variable's box (in the footer) shows it in full, with its length. The runtime keeps up to 50 000 characters of it.
A long value in a line wraps within the code instead of running on below the
variables panel. Texts: visualizerShowFullValue, visualizerValueLength,
visualizerFullValueTitle, visualizerFullValueCut, visualizerCloseFullValue,
all optional.
With the capability listTree (off by default; the embed's features=listtree),
a list that reads as a tree is drawn as one, below its text: a non-empty list
whose first element is a string is a node with that label, its other
elements are its children, a subtree where they read as a tree too, else a
leaf. ['+', ['var', 'x'], ['f', 1, 2]]:
+
╱ ╲
var f
│ ╱ ╲
'x' 1 2
Labels have no quotes, string leaves do. The rule is generic, so ['Anna', 'Ben']
is drawn too; the text stays as it is. Where:
- Variables of a call box: below the value; a tree wider than the panel
(48 characters) has a link «Baum zeigen» (
showTree) to the full-value dialog instead. A global variable's tree is in its box (hover or tap its entry in the footer), below the full value. The options menu's «Listen als Baum zeigen» (visualizerListTrees) switches the trees off and on. - Full-value dialog: below the value's text.
- REPL: a value that reads as a tree has a «Baum» button (
replShowTree) next to «Visualisieren»; it shows the tree below the value and hides it again.
A tree of more than 100 nodes, deeper than 10 or containing itself isn't
drawn; long labels and leaves are shortened in their middle. For screen
readers each tree is an image described by the list's text. A tree wider
than its place (the console, the dialog, a global variable's box, a narrow
panel) scrolls sideways:
it starts with its root in the middle, a ‹ or › at an edge shows that more is
hidden there, and it takes the focus, so the arrow keys, Home and End scroll
it. The positions come from the package's own implementation of van der Ploeg's
tidy-tree algorithm for nodes of different widths (src/treeLayout.ts,
checked against d3-flextree), in
characters of the code font, so a tree scales with the text around it.
For slides, handouts and quiz options, a list that reads as a tree can also be
drawn as a static SVG file, without a browser: the same tree rule and layout as
listTree, the same look (solid node boxes, dashed leaf boxes, the light
theme's colours) on a light card with rounded corners, so the picture reads the
same on a light and a dark page.
import { renderTreeSvg } from 'glpython-editor/tree-svg'
const svg = renderTreeSvg("['+', 2, ['*', 3, 4]]", { mark: ["['*', 3, 4]"], lang: 'de' })npx glpython-tree "['+', 2, ['*', 3, 4]]" --mark "['*', 3, 4]" -o baum.svg- Input: the list as Python writes it: numbers (also negative and
decimal), strings in single or double quotes with Python's escapes,
True,False,None, lists and tuples. Leaves read as Python's repr writes them (2.50→2.5,"it's"). A single value is a tree of one leaf. mark: subtrees to highlight, written the same way (the accent colour on their boxes and the edges between them; the whole tree marks all of it). A mark that isn't a subtree, or occurs more than once, is an error.markRoot: the root node's box alone (not its edges or children), e.g. for the operator computed last; it combines withmark, and marks the leaf of a single value. In the description: «– markiert: Wurzel» / «– marked: root» (first, before the marked subtrees).glpython-tree --mark-root.fontSizein pixels (default 16),lang(en,de) for the description. The picture isrole="img"with a<title>: the list, and «– markiert: …» / «– marked: …» for the marks.- Text and errors: widths are in characters of the code font, as on screen;
each text is fitted to its characters' width (
textLength), so a monospace font with other proportions doesn't overflow its box. Invalid input throws aTreeSvgErrornaming the position;glpython-treeprints it and exits with 1 (2 for wrong usage). Without a list it reads one from stdin. - Deterministic: the same input and options always give the same bytes.
Long labels aren't shortened, unlike the visualizer's trees.
npm run test:svg checks the pictures against scripts/tree-svg/.
The Copy → chain item in the toolbar's ⋯ Options menu copies the whole evaluation of the
statement the scrubber is on - from its first step to its value, at whichever of its steps the
scrubber is (for a visualized REPL entry: that input) - in the course's
paper notation: its expression, then one line per change, each starting with
→, taking the same steps as the visualizer. A call to a function with a single
return <expression> is replaced as in substitution mode. Any other call is
replaced by the expression of the return it executed, with its parameters and
local variables already filled in, and a comment naming the condition that
chose that return (# 2 == 0 is False) and whether its body has local
variables. A failing run ends with Python's error line. Definitions are never
exported. The menu's Comments item turns the comments off; it is remembered per
browser. Texts: copyChain, copyChainDone, copyChainFailed,
copyChainComments, chainConditionComment, chainLocalsComment.
The Tests button (capability tests, a test command for editor-free
hosts) starts as Run does - an empty console and a fresh session from the
current files, whose output shows first and whose definitions the REPL has
afterwards - and then runs the doctests in the docstrings of the file that would
run, as python -m doctest does: the file is loaded once more as a module named
after it (its output there isn't shown again). If the file itself fails, its
traceback is the answer and no report follows. The console then shows how many
examples passed, then each failing example in doctest's own words (Failed example:, Expected:, Got: stay untranslated) with a button that visualizes
that example step by step against the same definitions. If the example didn't
raise, the visualization's last step shows the two texts doctest compared next
to the final value (exampleGotExpected, e.g. «Erhalten: 4.5 · Erwartet: 4»).
Unlike Python's doctest, an example compares values, not their display:
when the expected text and the output are each one Python literal (numbers,
strings, lists, tuples, dicts, sets, True/False/None, nested), it passes
if the two values are equal with == - 4 for 4.0, [1,2,30] for
[1, 2, 30], ["+", 1] for ['+', 1], and, as == says, 1 for True
(no tolerance: 0.3 for 0.1 + 0.2 fails). Anything else - exceptions,
several lines of output, objects such as <function f at 0x…>, other text -
and an example with a comparison flag of its own (ELLIPSIS,
NORMALIZE_WHITESPACE, …) is compared as doctest does. The report shows both
texts as written. An example that printed
what was expected but returned None passes, as in doctest, with a separate
glpython hint that it probably should return instead. Texts: runTests,
testsRunning, testsSummary, testsNone, testsLoadError,
visualizeExample, testsPrintHint.
Coverage (capability coverage, off by default): when all examples pass,
the report also lists which lines of the tested file's functions no example
executed, each with its code («Zeile 11: return "ungenügend"»; consecutive
lines of one function as «Zeilen 8–12:»), in a pale orange box like their
marks in the editor. Counted are the statements in
function bodies (also methods and nested functions), as Python's own trace
module counts them during the examples - not def lines, docstrings, code
outside functions, or other files such as libraries. With the editor, the same
lines are marked in it (line background and a bar in the gutter) until the
file is edited or the program runs again. When every line ran, a light blue
note (--glp-info, never green like «bestanden») says so and that this doesn't show whether the examples check the edge cases:
coverage finds untested code, not wrong code. Recorded with sys.monitoring,
each line once, so it doesn't slow the examples down. Texts:
testsUncoveredHeading, testsUncoveredLines, testsAllCovered.
Call boxes in the visualizer show a function without its comments and docstring, which don't change what runs; the file itself is shown as written.
Below an error, glpython adds one short hint for the unit's common mistakes:
an undefined name, missing or too many arguments, a None operand (a
function that prints instead of returning), text plus a number, a division
by zero, endless recursion, a list index out of range, a doctest example
without a blank after >>>, and input() where the page can't wait for an
answer (see input() below). Python's message and traceback stay exactly as
they are, and a failing test still fails. A hint is matched on the
exception's type and message (src/runtime/hints.py); anything else gets no
hint. It shows in the Run output, the REPL, the Tests report and below the
visualizer's error marking, worded by errorHint and labelled by
hintLabel («Hinweis:» / "Hint:"); a host without errorHint shows none.
In the visualizer, a call with the wrong arguments fails as in Python, with
Python's own TypeError.
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 restart Python with an empty console,
as Reset does, with { id, type: 'reset' }), and learn when Python is busy through the
onBusyChange prop.
Below the console, a >>> input line runs Python in the namespace of the
last Run, so learners can call the file's functions directly. As in
python3 -i main.py, that namespace is the module __main__: the file runs
with __name__ == "__main__", and import __main__; __main__.x sees its
variables, in the REPL, the visualizer and the Tests' Run too (before any
Run, the REPL's own namespace is __main__). An expression
shows its value, except None, as in CPython's REPL (so a call of a function
without return, print(...) or help(...) shows only what it printed); a
statement such as a = 5 shows
nothing; a def runs but adds the hint replDefinitionHint; an error shows
Python's last traceback line. Run starts a fresh namespace and clears the
transcript, while arrow up/down still recalls earlier inputs. Every entry of
the transcript has a Visualize button (replVisualizeEntry) that traces
that input step by step, an entry with an error up to its error, with call
boxes drawn from the file as it was at the last Run. It replays rather than
stores Python's state: the worker runs that file and the inputs before the
entry again, in a namespace of its own, and checks that each prints and
evaluates to what it did the first time. If the replay needs input() or
comes out differently (e.g. random numbers), the entry shows a note
(replReplayNote) instead of a visualization. The live namespace stays as
it was. An input that doesn't compile has no button. Disable the REPL with
capabilities={{ repl: false }}; the embed shows it in eval mode only, and
resetting the embed discards the namespace.
A program (or a REPL input such as repl()) that calls input(prompt) asks in
the console, as in a terminal: while it waits, the >>> line becomes the
program's input line, with the prompt in front of it and the cursor in it
(programInputLabel for screen readers). Enter sends the line; the prompt and
the answer then stay in the transcript as one line (> 2 * 3), followed by
what the program prints. Python's input() and the transcript otherwise behave
as in a terminal: the answer comes without the newline, a recursive REPL of
the learner's own works, and Stop (or Ctrl+D in an empty line) ends the
wait with EOFError: EOF when reading a line, which the program can catch -
Python isn't restarted then. Tests ask the same way (the file runs first, as
with Run), and the visualizer asks while it records the run, then shows the
steps; the input(...) call is one step with the typed text as its value.
Only a host that hides the console (no REPL and no emptyOutput, during a
visualization without output) asks in a dialog (inputDialogTitle, …) instead.
input() needs cross-origin isolation (see below). Without it, the program
stops with RuntimeError: input() is not available here and the hint
input-unavailable («input() funktioniert hier nicht … In der IDE fragt die
Konsole danach.»). That is the case in an embed inside LiaScript.
Without compact, the workspace is stacked: the editor (or, while
visualizing, the visualizer) in full width on top, a divider, and the console
in full width below. The divider can be dragged (mouse, pen or finger) or
moved with the arrow keys, Page Up/Down, Home and End (consoleSize names
it); the console's share of the height (a third at first) is remembered in
the browser. The file tabs and, while visualizing, the visualizer's bar take
the same strip above the editor, so nothing below moves when switching. The
graphics show beside the editor only while the program draws (for a
visualization: if it draws at all); their ✕ hides them until the next drawing.
An empty editor shows editorPlaceholder («Code hier eingeben»).
The buttons come in the embed's order and labels - ▶ Run · Tests ·
Visualize · Reset (runProject, runTests, visualize, resetOutput) -
and stay while visualizing (they leave the visualizer first). Reset
restarts Python and clears the console, the visualization and the drawing, as
the embed's Reset does. The run button keeps its width when it becomes
Stop. A host can put the buttons into its own bar with
actionsContainer={element} (the IDE's top bar); without it they sit above
the editor. capabilities={{ closeFileTabs: false }} drops the ✕ on the file
tabs, for a host that shows every file of the project as a tab.
A file with readOnly: true (ProjectFile) is one to read, not to change,
e.g. a course's library that learners use as a black box: the editor shows it
read-only (on an iPad the keyboard doesn't open), its tab has a lock
(readOnlyFile, also the message when typing into it), and
updateProjectFile, renameProjectFile and deleteProjectFile leave it
as it is. It runs, imports and visualizes like any other file.
GlPythonWorkspace accepts optional compact and capabilities props. The
existing behavior remains the default. A compact host can explicitly disable
the editor, built-in action buttons, breadcrumbs, file tabs, output reset,
visualization, visualizer exit action, or graphics. An editor-free host can
send immutable command events to run or visualize a source snapshot supplied
by its parent:
<GlPythonWorkspace
{...props}
compact
command={{ id: commandId, type: 'run' }}
capabilities={{
actions: false,
editor: false,
exitVisualizer: false,
breadcrumbs: false,
fileTabs: false,
resetOutput: false,
visualization: true,
graphics: false,
emptyOutput: false,
}}
/>Each of the built-in buttons has its own switch: run (▶; without it, the
button still appears as ■ Stop while Python is busy, e.g. with Tests),
tests, visualization and resetOutput; repl: false hides the REPL's
input line but keeps the console (output, and input()'s line when a program
asks). A host can thus show e.g. «Tests» without ▶, or ▶ without «Tests».
With emptyOutput: false (the embed's visual mode), a visualization whose
program prints nothing and raises no error has no output panel instead of
«(Keine Ausgabe)», so short visualizations take less space. It is decided for
the whole run, so the panel doesn't appear in the middle of stepping. With the
REPL on, the panel always stays, since it holds the REPL's input line.
This repository also contains a standalone iframe application:
npm run dev:embed
npm run build:embed
npm run preview:embedThe built app is emitted to dist-embed/. embed/demo.html is a small parent
page that exercises the eval, turtle, and visualization-only modes.
The iframe requires a parentOrigin query parameter containing the exact
origin that is allowed to communicate with it:
https://example.test/embed/?parentOrigin=https%3A%2F%2Fcourse.example
Optional query parameters adapt the embed to a host such as the LiaScript
macro: features=run,repl,visualize,… hides the REPL (repl), the visualizer
(visualize) or its → chain export (export) unless listed (capabilities
repl, visualization, export); coverage in that list switches on the
coverage after Tests (capability coverage), listtree lists drawn as
trees (capability listTree) and tree the visualizer's mode «Baum»
(capability tree), which a missing features doesn't; and size=large with
visualizerHeight=<px> shows bigger code and a visualizer at least that tall.
windowHeight=<px> is the host window's height, which the iframe can't see:
the visualizer is at most 80% of it (without it: of the screen's height).
A steady height. The embed's height follows its content, so the page
around it would move with every step. While visualizing, it doesn't: the
visualizer is as tall as its tallest step from the start, and the console as
tall as the output at the last step. The workspace draws the likely tallest
steps once, hidden, and measures them: every step of a short trace, else the
120 with the most open calls and some spread over the trace. A step taller
still makes it grow when it comes, and it never shrinks while stepping. A
step taller than the cap scrolls inside the visualizer, which follows the
current step. A new visualization, a switch of the mode, and a new width
start again. This applies to compact hosts (the embed); in the IDE the
visualizer has a fixed height and scrolls already.
The UI language is the optional lang query parameter (e.g. the LiaScript
course's language), else the browser's language, else English; only the
primary subtag counts (de-CH → German), and an unsupported language (e.g.
fr) falls through to the next step. The UI strings live in src/i18n.ts
(coreStrings, German and English), which the glpython IDE imports too;
resolveUiLocale implements this order for any host. Python's own values and
error messages are never translated.
It sends glpython:ready with runtime capabilities, accepts versioned
glpython:load, glpython:run, glpython:visualize, and glpython:reset
messages, and sends glpython:resize as its content size changes. run and
visualize carry the current source snapshot so a parent such as LiaScript can
remain the authoritative editor and restore its own PWA state. The public TypeScript types and validators are exported from
glpython-editor.
Protocol version 2 adds programs of several files: load, run and visualize
may carry files ({ path, source }, each path a Python module name such as
parser.py, at most GLPYTHON_MAX_FILES) and mainFile, the file to run;
source stays the code of that file. Only the file to run is traced; imported
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.
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.
iframe.contentWindow?.postMessage({
type: 'glpython:run',
version: GLPYTHON_EMBED_PROTOCOL_VERSION,
source: 'print("Hello")',
mode: 'eval',
}, iframeOrigin)Use mode: 'turtle' for the graphics layout. The eval layout does not show or
reserve space for turtle graphics. Use mode: 'visual' with a
glpython:visualize message for an editor-free visualizer that cannot exit to
the normal workspace.
Every colour, font and the corner radius in style.css come from CSS
variables defined on :root with glpython's own look (the course design:
white panels, light grey code areas, ink accent #1F3A5F, the system's UI
and monospace fonts, no web fonts). A host can override any of them:
| Variable | Role |
|---|---|
--glp-bg |
panel background |
--glp-surface |
code in the visualizer, output, REPL |
--glp-border |
decorative borders and separator lines |
--glp-border-control |
borders of controls, call boxes in box mode |
--glp-text, --glp-text-muted |
text and code; line numbers, step counter, secondary info, connector lines |
--glp-accent, --glp-on-accent |
active mode, scrubber, focus, primary button; text on it |
--glp-current, --glp-current-outline |
current subexpression and new value, current line; next step (dashed) |
--glp-error, --glp-error-bg |
errors, failing tests, «Erhalten · Erwartet» |
--glp-warning, --glp-warning-bg |
hints; lines no example ran (coverage after Tests, and their marks in the editor) |
--glp-info, --glp-info-bg |
information that isn't a result: every line ran (coverage after Tests) - never green |
--glp-success |
passed tests, runtime ready |
--glp-syntax-keyword, -string, -number, -comment |
syntax colours (names, operators, brackets use --glp-text) |
--glp-font-code, --glp-font-ui, --glp-radius |
fonts and corner radius |
The embed is flat: no cards, its sections only separated by --glp-border
lines, so the host draws the one frame around it.
The Monaco editor uses the theme glpython (src/monacoTheme.ts), built from
the same tokens when the editor mounts (later changes to the variables need a
reload), and glpython's own Python language glpython-python
(src/pythonLanguage.ts): Monaco's Python definition, but only Python's
keywords as keywords (not builtins such as print or type), True,
False and None as values, and comments after code. The visualizer
highlights code with the same language.
npm install
npm run build # tsc -b && vite build, emits dist/
npm run lint # oxlintnpm run dev runs vite build --watch for iterating against a locally
linked copy (e.g. via npm link or a workspace/yalc setup) in a consuming
shell project.
The embed development and preview servers set
Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp. The production build also ships a
vendored coi-serviceworker fallback for static hosts that cannot configure
headers. It registers only when the embed runs as the top-level page, avoiding
reload loops in a cross-origin iframe.
Cross-origin isolation is controlled by the complete browsing context, not only
the iframe response. A glpython iframe inside a non-isolated course host cannot
use SharedArrayBuffer, even if the iframe itself ships the service worker.
The glpython:ready message therefore reports capabilities.input; hosts must
not assume synchronous input() support. Normal execution, visualization, and
turtle graphics do not require the synchronous input channel.
- The library builds to a single ESM bundle (
dist/index.js) plus per-module.d.tsdeclaration files underdist/src/. - The Pyodide worker (
src/pyodide-worker.js) is loaded at runtime vianew Worker(new URL('./pyodide-worker.js', import.meta.url)). Vite detects this pattern automatically and emits the worker as a content-hashed asset underdist/assets/, rewriting the reference to match — no manual copy step is needed for it. src/style.cssis not imported by any module in the component tree (the original app only imported it from its own entry point, not from the component itself), so Vite's build never touches it. It's copied verbatim todist/style.cssby thebuildscript and exposed via theglpython-editor/style.cssexport; consuming shells must import it explicitly.
MIT – see LICENSE. Third-party code it contains or bundles (Monaco Editor's Python definition, coi-serviceworker, and the libraries bundled into the embed) is listed with its notices in THIRD_PARTY_NOTICES.md.