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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ 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. The outermost box says why it isn't replaced (the call-enter step's `boxReason`, `visualizerBoxReason`).
- **Tree mode («Baum», capability `tree`, embed `features=tree`):** `src/treeMode.ts`. `treeModeSteps` takes the substitution mode's steps and adds a step of its own (`treePhase` `'open'`/`'close'`, a copy of a nearby step with `step` set to the state it shows) before and after each statement expression's reductions (`TreeEpisode`: open in one frame between `openIndex` and `closeIndex`). It is also a substitution mode (`substitutionMode` is true), so inline calls, pending boxes and hosts work as there. `Frame` splices the open tree into its line like a child (`childRanges` entry with `frame: null`, `ExpressionTreeBox`) and hangs the boxes of the calls in it below. `buildExpressionTree` builds the tree from the AST and `buildSubstitutionsUpTo` (so it can't diverge from the chain; node keys `frameId:nodeId`, an inline call's body with the callee's frame). `episodeTreeLayout` lays a tree out where it opened or last grew (an inline call replaced, a box opened or closed), with room for every later text, so reductions move nothing; `TreeView` then takes that `layout`. A call of the program's own labels its argument edges with the parameters (`edgeLabel`, from the episode's `calls`; `edgeLabelPlacement` in `treeLayout.ts`, room reserved by the layout). A call that stays a box is a `kind: 'box'` node of measured size (`ExpressionTreeBox` measures the drawn box in characters and rows, growth-only; the layout gives a box several rows), drawn by `TreeView`'s `renderBox`. Check changes with `test:tree` and the «Baum» cases in `test:console`.
- **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.
- **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 fills its iframe and reports its natural height (`naturalHeight` in `src/embed/App.tsx`: the root with the code area and the console at their content's height, so a clipped embed doesn't stay small) plus `visualizing`; the LiaScript macro caps the iframe at 80% of the course's visible height, and the code area scrolls between step bar and footer (`src/embed/style.css`: only it and the console give way). A step's view is `StepView`; compute it with `stepViewAt`, not inline, so the measurement and the visible canvas stay the same.
- **Global variables:** the module frame has no variables column; `GlobalsFooter` shows them in `.visualizer-globals`, below the scrolling `.visualizer-view`, both inside `.visualizer-frame` (from the start if `traceHasGlobals`; an invisible layer of the last step's entries reserves its height). Hover, tap or focus opens `.visualizer-globals-peek` (the definition via `globalDefinitionSource`, else the full value). An arrow with an end in the footer has `layer: 'frame'` and is drawn in `.visualizer-connectors--frame` over view and footer (`connectorBetween`, `localsValueElement`); all arrows are measured again when the view scrolls. The current call's box (`.frame-box--current`) keeps its variable row sticky at the view's bottom.
- **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`).
Expand Down
21 changes: 16 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,10 +565,21 @@ macro: `features=run,repl,visualize,…` hides the REPL (`repl`), the visualizer
`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).
(capability `tree`), which a missing `features` doesn't; and `size=large`
shows bigger code.

**Its height is the parent's.** The embed fills its iframe and reports the
height its content needs in `glpython:resize` (`height`; since v0.1.35 also
`visualizing`, whether the visualizer is open) - its natural height, also
while the iframe is smaller or taller. A parent sets the iframe to that
height, or less: the LiaScript macro gives it at most 80% of the course's
visible height (with `large` while visualizing exactly that). In a smaller
iframe the visualizer's code area scrolls, between the step bar and the
global variables' footer, which stay in view, and follows the current step;
without the visualizer the console scrolls. A taller iframe (`large`) is
filled by the code area. Scrolling past the code's end goes on with the
page around it. The old `windowHeight` and `visualizerHeight` parameters are
ignored.

**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
Expand All @@ -577,7 +588,7 @@ 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
step taller than the iframe 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.
Expand Down
5 changes: 4 additions & 1 deletion embed/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,11 @@
runButton.dataset.command = event.data.busy ? 'stop' : 'run'
runButton.textContent = event.data.busy ? '■ Stop' : 'Run'
}
// The height the embed's content needs, here at most 80% of the
// window (as the LiaScript macro does with the course's visible
// height): a taller visualization scrolls its code inside.
if (event.data?.type === 'glpython:resize' && Number.isFinite(event.data.height)) {
iframe.style.height = `${Math.max(400, event.data.height)}px`
iframe.style.height = `${Math.min(Math.max(400, event.data.height), Math.round(window.innerHeight * 0.8))}px`
}
})
</script>
Expand Down
98 changes: 89 additions & 9 deletions scripts/check-console-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,22 @@ process.on('exit', stopServer)

// The parent page, served on the embed's own origin: it loads the embed and
// passes messages on, version 4. Its own query (e.g. "?features=…") goes on
// to the embed.
// to the embed. As the LiaScript macro does, it sets the iframe to the
// height the embed reports, at most "?cap=" (default 900px; the macro: 80%
// of the course's visible height) - then the embed scrolls inside.
const harness = `<!doctype html><meta charset="utf-8"><body style="margin:0">
<iframe id="embed" style="width:760px;height:900px;border:0"></iframe>
<iframe id="embed" style="width:760px;height:170px;border:0"></iframe>
<script>
const iframe = document.querySelector('#embed')
let cap = Number(new URLSearchParams(location.search).get('cap')) || 900
const fit = () => { if (window.reported) iframe.style.height = Math.min(cap, window.reported.height) + 'px' }
window.setCap = (height) => { cap = height; fit() }
window.ready = false
window.send = (type, source) => iframe.contentWindow.postMessage({ type: 'glpython:' + type, version: 4, source, mode: 'eval' }, location.origin)
addEventListener('message', (event) => { if (event.data?.type === 'glpython:ready') window.ready = true })
addEventListener('message', (event) => {
if (event.data?.type === 'glpython:ready') window.ready = true
if (event.data?.type === 'glpython:resize') { window.reported = event.data; fit() }
})
iframe.src = './index.html?parentOrigin=' + encodeURIComponent(location.origin) + '&mode=eval&lang=de' + location.search.replace('?', '&')
</script>`

Expand Down Expand Up @@ -251,6 +259,8 @@ try {
await programLine(frame).waitFor({ timeout: 30000 })
await programLine(frame).press('Control+d')
await frame.locator('.repl-line:not(.repl-line--program)').waitFor({ timeout: 30000 })
// The error may arrive just after the line is back.
await frame.locator('.console', { hasText: /Name: \n[\s\S]*EOFError/ }).waitFor({ timeout: 10000 }).catch(() => {})
assert.match(await consoleText(frame), /Name: \n[\s\S]*EOFError/, 'Ctrl+D → EOFError')
console.log('input(): Stop and Ctrl+D end the wait with EOFError: ok')

Expand Down Expand Up @@ -557,7 +567,7 @@ try {
// variable row in sight above the footer. No footer without globals.
{
const label = 'global variables in the footer'
const page = await openEmbed({}, '?visualizer=box&windowHeight=600')
const page = await openEmbed({}, '?visualizer=box&cap=600')
const frame = page.frameLocator('#embed')
try {
const body = Array.from({ length: 30 }, (_, index) => ` s = s + ${index}`).join('\n')
Expand Down Expand Up @@ -602,14 +612,14 @@ 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
// the cap (the embed at most 720px: its code scrolls) 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 page = await openEmbed({}, `?visualizer=${mode}&cap=720`)
const frame = page.frameLocator('#embed')
try {
await page.evaluate((program) => window.send('visualize', program), source)
Expand All @@ -634,7 +644,7 @@ try {
}
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.ok(Math.max(...states.map((step) => step.height)) <= 720, `the embed at most as tall as its iframe may be (${Math.max(...states.map((step) => step.height))}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) {
Expand All @@ -643,6 +653,76 @@ try {
}
await page.close()
}
// The embed's height (P53b): it reports the height its content needs, also
// while its iframe is smaller (the LiaScript macro caps it at 80% of the
// course's visible height). Then the visualizer's code scrolls inside,
// between the step bar and the footer, both in view, and the embed's own
// page doesn't scroll; a larger cap lets it grow again. A short program
// scrolls nothing; a long output scrolls the console.
{
const label = 'the embed\'s height'
const page = await openEmbed({}, '?visualizer=box&cap=500')
const frame = page.frameLocator('#embed')
try {
const recursion = 'def fakultaet(n):\n if n == 0:\n return 1\n return n * fakultaet(n - 1)\n\nprint(fakultaet(5))\n'
await page.evaluate((program) => window.send('visualize', program), recursion)
await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 })
await measured(frame)
const last = frame.locator('.visualizer-controls button').nth(3)
await last.click()
const layout = async () => ({
iframe: await page.locator('#embed').evaluate((iframe) => iframe.getBoundingClientRect().height),
reported: await page.evaluate(() => window.reported),
...(await frame.locator('.embed-app').evaluate(async (app) => {
await Promise.all(app.ownerDocument.getAnimations().filter((animation) => animation.effect?.getTiming().iterations !== Infinity).map((animation) => animation.finished.catch(() => {})))
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const rect = (selector) => app.querySelector(selector)?.getBoundingClientRect().toJSON() ?? null
const view = app.querySelector('.visualizer-view')
const console = app.querySelector('.console')
return { inner: innerHeight, pageScrolls: document.scrollingElement.scrollHeight > innerHeight + 1, bar: rect('.visualizer-bar'), footer: rect('.visualizer-globals'), view: view && { client: view.clientHeight, scroll: view.scrollHeight }, console: { client: console.clientHeight, scroll: console.scrollHeight } }
})),
})
const settled = async (check) => {
let state
for (let tries = 0; tries < 20; tries++) {
state = await layout()
if (check(state)) return state
await new Promise((resolve) => setTimeout(resolve, 100))
}
return state
}
let state = await settled((s) => s.iframe === 500)
assert.equal(state.iframe, 500, `the iframe at the cap: ${JSON.stringify(state)}`)
assert.ok(state.reported.visualizing === true && state.reported.height > 700, `the content's own height reported: ${JSON.stringify(state.reported)}`)
assert.ok(state.view.scroll > state.view.client, 'the code scrolls')
assert.ok(state.bar.top >= 0 && state.footer.bottom <= state.inner + 1 && !state.pageScrolls, `step bar and footer in view, the page doesn't scroll: ${JSON.stringify(state)}`)
const current = await frame.locator('.visualizer-view').evaluate((view) => { const line = view.querySelector('.visual-current-line')?.getBoundingClientRect(); const box = view.getBoundingClientRect(); return !line || (line.top >= box.top - 1 && line.bottom <= box.bottom + 1) })
assert.ok(current, 'the current line in view')
const natural = state.reported.height
await page.evaluate(() => window.setCap(3000))
state = await settled((s) => s.iframe === natural)
assert.ok(state.iframe === natural && state.reported.height === natural && state.view.scroll <= state.view.client + 1, `a larger cap: the whole content (${JSON.stringify(state)})`)
await page.evaluate(() => window.setCap(400))
state = await settled((s) => s.iframe === 400)
assert.ok(state.iframe === 400 && state.reported.height === natural, `a smaller cap again: ${JSON.stringify(state)}`)
await page.evaluate(() => window.setCap(900))
await page.evaluate((program) => window.send('visualize', program), 'x = 1 + 2\nprint(x)\n')
await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 60000 })
await measured(frame)
state = await settled((s) => s.reported.height < 500 && s.iframe === s.reported.height)
assert.ok(state.iframe === state.reported.height && state.view.scroll <= state.view.client + 1 && !state.pageScrolls, `a short program: as tall as it needs (${JSON.stringify(state)})`)
await page.evaluate(() => window.setCap(400))
await page.evaluate((program) => window.send('run', program), 'for i in range(60):\n print(i)\n')
await frame.locator('.console', { hasText: '59' }).waitFor({ timeout: 60000 })
state = await settled((s) => s.reported.visualizing === false && s.reported.height > 400 && s.iframe === 400)
assert.ok(state.iframe === 400 && state.reported.visualizing === false && state.console.scroll > state.console.client && !state.pageScrolls, `a long output scrolls the console: ${JSON.stringify(state)}`)
console.log(`${label}: ok`)
} catch (error) {
failed += 1
console.error(`${label}: ${error.message}`)
}
await page.close()
}
// The visualizer's mode «Baum» (P34 A): only with "tree" in features= -
// without it no mode button, and a stored or preset «Baum» shows
// «Ersetzung». With it, each example takes «Ersetzung»'s steps plus one to
Expand All @@ -667,7 +747,7 @@ try {
}
{
const notenschnitt = 'def notenschnitt(noten):\n summe = sum(noten)\n anzahl = len(noten)\n return summe / anzahl\n\nnotenschnitt([4, 5, 4.5])\n'
const page = await openEmbed({}, '?features=visualize,tree&windowHeight=900')
const page = await openEmbed({}, '?features=visualize,tree')
const frame = page.frameLocator('#embed')
const stepCount = () => frame.locator('.visualizer-step-count').innerText()
const mode = (name) => frame.locator('.visualizer-mode button', { hasText: name }).click()
Expand Down Expand Up @@ -818,7 +898,7 @@ try {
// call text. The console prints nothing for None.
{
const anwenden = "def anwenden(op, x, y):\n \"\"\"\n >>> anwenden('*', 3, 4)\n 12\n \"\"\"\n if op == '+':\n return x + y\n"
const page = await openEmbed({}, '?features=run,repl,visualize,tests,tree&windowHeight=900')
const page = await openEmbed({}, '?features=run,repl,visualize,tests,tree')
const frame = page.frameLocator('#embed')
const mode = (name) => frame.locator('.visualizer-mode button', { hasText: name }).click()
// The root box's code at each step, a tree as its text.
Expand Down
Loading
Loading