Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Worker messages:
- `substitutionModeSteps` filters the trace down to visible substitutions.
- **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.
- **Steady height (compact hosts):** while visualizing, the canvas keeps the height of the tallest step from the start (`steadyHeightCandidates`: up to 120 likely tallest steps, drawn hidden in `.visualizer-measure` through the same `rootFrame(stepViewAt(…))` as the visible step, and measured), and only grows; the console reserves the last step's output (`.visual-output-steady`). The embed caps the visualizer at 80% of `windowHeight` (the macro's). A step's view is `StepView`; compute it with `stepViewAt`, not inline, so the measurement and the visible canvas stay the same.
- **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 - except while `input()` waits: then Stop only cancels the wait (`EOFError`). The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`).
- **One Monaco model for all files:** a tab switch replaces the editor's text. `updateCode` ignores flush events (`setValue`), since `@monaco-editor/react` shows a read-only file (`ProjectFile.readOnly`) with `setValue` before its `onChange` knows the new tab - the text would land in the previous file.
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,20 @@ macro: `features=run,repl,visualize,…` hides the REPL (`repl`), the visualizer
coverage after Tests (capability `coverage`), and `listtree` lists drawn as
trees (capability `listTree`), 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
Expand Down
53 changes: 53 additions & 0 deletions scripts/check-console-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
// their parent, the parent centred over them. In a narrow embed (420px) a
// wide tree starts with its root in the middle, shows a hint at each edge
// where more is hidden, and scrolls with the keyboard.
// A steady height: while stepping through a visualization the embed keeps
// one height (the visualizer the tallest step's, at most 80% of the window,
// the console the last step's output), and the current line stays in view.
//
// Drives the built embed (dist-embed/, from npm run build:embed) in Chrome
// through its protocol, as the LiaScript macro does; Pyodide comes from its
Expand Down Expand Up @@ -182,6 +185,9 @@ try {
for (const button of buttons) button.style.display = ''
return text
})
// A visualization is shown once its steps are measured: until then the
// embed draws some steps a second time, hidden, to know their height.
const measured = (frame) => frame.locator('.visualizer-view[data-steady-height="done"]').waitFor({ timeout: 30000 })
const programLine = (frame) => frame.locator('.repl-line--program .repl-input')
// A REPL input, once the REPL is free again (until then it keeps the text).
const enterRepl = async (frame, text) => {
Expand Down Expand Up @@ -238,6 +244,7 @@ try {
await page.evaluate((source) => window.send('visualize', source), 'text = input("> ")\nprint(eval(text))\n')
await answer(frame, '2 * 3')
await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 })
await measured(frame)
console.log('input(): the visualizer asks, then shows the steps: ok')
} catch (error) {
failed += 1
Expand All @@ -258,6 +265,7 @@ try {
try {
await page.evaluate((source) => window.send('visualize', source), 'def notenschnitt(noten):\n return sum(noten) / len(noten)\n\ndauer = [1, 2, 30]\nx = dauer[0] + dauer[1]\ny = [1, 2, 30][1]\nschnitt = notenschnitt(dauer)\n')
await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 })
await measured(frame)
const seen = { 5: [], 6: [], 7: [] }
while (!(await stepButton(2).isDisabled())) {
await stepButton(2).click()
Expand All @@ -270,6 +278,8 @@ try {
console.log('visualizer: a named list indexed in one step: ok')

await page.evaluate((source) => window.send('visualize', source), 'l = list(range(1, 101))\nbaum = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]]\n')
await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 60000 })
await measured(frame)
await stepButton(3).click()
await frame.locator('.locals-row[data-name="baum"]').waitFor({ timeout: 30000 })
const short = '[1, 2, 3, 4, 5, …, 100]'
Expand Down Expand Up @@ -298,6 +308,8 @@ try {
for (const mode of ['Kasten', 'Ersetzung']) {
await frame.locator('.visualizer-mode').getByRole('button', { name: mode }).click()
await page.evaluate((source) => window.send('visualize', source), displays)
await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 60000 })
await measured(frame)
await frame.locator('.frame-box--root > .frame-code .frame-line').filter({ hasText: 'baum = ' }).waitFor({ timeout: 30000 })
if (!(await stepButton(0).isDisabled())) await stepButton(0).click()
await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 5000 })
Expand Down Expand Up @@ -336,6 +348,7 @@ try {
try {
await page.evaluate((source) => window.send('visualize', source), 'baum = ["+", ["var", "x"], ["f", 1, 2]]\ngross = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], ["liste", "anna", "ben", "carla", 12345]]]\nnamen = ["Anna", "Ben"]\n')
await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 })
await measured(frame)
await frame.locator('.visualizer-controls button').nth(3).click()
await frame.locator('.locals-row[data-name="namen"]').waitFor({ timeout: 30000 })
const treeOf = (name) => frame.locator(`.locals-row[data-name="${name}"] .value-tree`)
Expand Down Expand Up @@ -500,6 +513,7 @@ try {
await checkNarrow('the console', frame.locator('.repl-tree .value-tree-scroll'))
await page.evaluate((source) => window.send('visualize', source), `breit = ${breit}\n`)
await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 })
await measured(frame)
await frame.locator('.visualizer-controls button').nth(3).click()
await frame.locator('.locals-row[data-name="breit"] .locals-tree-more').click()
await checkNarrow('the dialog', frame.getByRole('dialog').locator('.value-tree-scroll'))
Expand All @@ -510,6 +524,45 @@ try {
}
await page.close()
}
// A steady height while stepping, in both modes: a recursion taller than
// the cap (windowHeight=900: the visualizer at most 720px) and a loop that
// prints. The current line is always in the visualizer's view.
for (const [label, mode, source] of [
['steady height: a recursion, Kasten', 'box', 'def fakultaet(n):\n if n == 0:\n return 1\n return n * fakultaet(n - 1)\n\nprint(fakultaet(4))\n'],
['steady height: a recursion, Ersetzung', 'substitution', 'def fakultaet(n):\n if n == 0:\n return 1\n return n * fakultaet(n - 1)\n\nprint(fakultaet(4))\n'],
['steady height: a loop that prints', 'substitution', 'for i in range(6):\n print("Zeile", i)\n'],
]) {
const page = await openEmbed({}, `?visualizer=${mode}&windowHeight=900`)
const frame = page.frameLocator('#embed')
try {
await page.evaluate((program) => window.send('visualize', program), source)
await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 })
await measured(frame)
const state = () => frame.locator('.embed-app').evaluate((app) => {
const view = app.querySelector('.visualizer-view')
const line = view.querySelector('.visual-current-line')
const box = view.getBoundingClientRect()
const lineBox = line?.getBoundingClientRect()
return { height: Math.round(app.getBoundingClientRect().height), view: Math.round(box.height), lineInView: !lineBox || (lineBox.top >= box.top - 1 && lineBox.bottom <= box.bottom + 1) }
})
const states = [await state()]
const next = frame.locator('.visualizer-controls button').nth(2)
while (await next.isEnabled()) {
await next.click()
await new Promise((resolve) => setTimeout(resolve, 50))
states.push(await state())
}
assert.ok(states.length > 10, 'some steps')
assert.deepEqual([...new Set(states.map((step) => step.height))], [states[0].height], 'one height for every step')
assert.ok(Math.max(...states.map((step) => step.view)) <= 720, `the visualizer at most 80% of the window (${Math.max(...states.map((step) => step.view))}px)`)
assert.deepEqual(states.map((step, index) => step.lineInView ? null : index).filter((index) => index !== null), [], 'the current line in view')
console.log(`${label}: ok`)
} catch (error) {
failed += 1
console.error(`${label}: ${error.message}`)
}
await page.close()
}
// P37: the run file is __main__, in Pyodide too.
{
const page = await openEmbed({})
Expand Down
Loading
Loading