diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec1ec58..e4d85c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,10 @@ jobs: # The trees' layout (our port of van der Ploeg's algorithm) against # d3-flextree, on the course's trees and random ones. - run: npm run test:layout + # The visualizer's mode «Baum» on traces of the runtime: its steps are + # «Ersetzung»'s plus open and close, its text the chain's, and a + # reduction moves no node. + - run: npm run test:tree # The console in Chrome (on the runner) with Pyodide from its CDN, # driving the embed built above: Tests → edit → Tests shows the new # report (P20). diff --git a/CLAUDE.md b/CLAUDE.md index 7512817..fe81be5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ npm run test:runtime # the runtime under CPython: scripts/check-runtime-{colum npm run test:hints # the error hints' wording (de/en) against the built dist/ (run a build first) npm run test:console # the console in Chrome with Pyodide, against dist-embed/ (run build:embed first) npm run test:layout # the trees' layout (src/treeLayout.ts) against d3-flextree (a devDependency, only for this check) +npm run test:tree # the visualizer's mode «Baum» (src/treeMode.ts) on traces of the runtime under CPython npm run build:embed # builds the iframe app + demo page into dist-embed/ (plus embed/public/: coi-serviceworker.js) npm run preview:embed # serves dist-embed/ with COOP/COEP headers; open /demo.html ``` @@ -75,7 +76,8 @@ Worker messages: - `collapseDeepCallChain` collapses the middle of 8+ open boxes into a "… N more calls" marker; deeply nested boxes used to freeze the page's layout. - `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. +- **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, with room for every later text, so reductions move nothing; `TreeView` then takes that `layout`. 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. - **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`). diff --git a/README.md b/README.md index 69c7168..6e51db3 100644 --- a/README.md +++ b/README.md @@ -117,13 +117,47 @@ 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 shows `visualizerBoxHint`. In **box** mode every call opens a box. +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 `, 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'` or `'substitution'`), and the embed with the -`visualizer=box` or `visualizer=substitution` query parameter. The toggle's -texts are `visualizerModeLabel`, `visualizerModeBox` and -`visualizerModeSubstitution`, all optional like `visualizerBoxHint`. +`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). + +### Tree mode (`tree`) + +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): +1. **Open:** one step replaces `e` by its tree, in a box at its place in the line. +2. **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 single + `return ` is replaced by that expression's tree, any other call + stays a node whose box hangs below the tree (with the note why). +3. **Close:** one step from the trivial tree to the value in the line. + +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 (and where a call is +replaced) with room for the widest value each node takes. 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. ### Lists and long values @@ -434,8 +468,9 @@ 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`), and `listtree` lists drawn as -trees (capability `listTree`), which a missing `features` doesn't; and `size=large` with +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=` shows bigger code and a visualizer at least that tall. `windowHeight=` 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). diff --git a/package.json b/package.json index 3e8c328..484766f 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test:hints": "node scripts/check-error-hints.mjs", "test:console": "node scripts/check-console-browser.mjs", "test:layout": "node --experimental-strip-types --no-warnings scripts/check-tree-layout.mjs", - "test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-coverage.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py && python3 -B scripts/check-runtime-input.py && python3 -B scripts/check-runtime-lists.py && python3 -B scripts/check-runtime-main.py && python3 -B scripts/check-runtime-trees.py", + "test:tree": "node --experimental-strip-types --no-warnings scripts/check-tree-mode.mjs", + "test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-coverage.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py && python3 -B scripts/check-runtime-input.py && python3 -B scripts/check-runtime-lists.py && python3 -B scripts/check-runtime-main.py && python3 -B scripts/check-runtime-trees.py && python3 -B scripts/check-runtime-box-reasons.py", "prepare": "npm run build" }, "peerDependencies": { diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index 7346418..a344c5d 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -24,6 +24,9 @@ // 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. +// The mode «Baum» (with "tree"): «Ersetzung»'s steps plus open and close +// for each tree, nodes that stay in place, one height, and the hint at a +// call that stays a box (also in «Ersetzung»); without "tree" no button. // // 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 @@ -563,6 +566,107 @@ try { } 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 + // open and one to close each tree; a reduction moves no node of the tree; + // each tree is described by its text, step by step as the chain; the embed + // keeps one height; and a call that stays a box says why, in «Ersetzung» + // and in «Baum». + { + const page = await openEmbed({}, '?visualizer=tree') + const frame = page.frameLocator('#embed') + try { + await page.evaluate((source) => window.send('visualize', source), 'x = 2 + 3 * 4\n') + await measured(frame) + assert.deepEqual(await frame.locator('.visualizer-mode button').allInnerTexts(), ['Kasten', 'Ersetzung'], 'the mode buttons') + assert.equal(await frame.locator('.visualizer-mode button[aria-pressed="true"]').innerText(), 'Ersetzung', 'the mode shown') + console.log('Baum: without "tree" no mode button: ok') + } catch (error) { + failed += 1 + console.error(`Baum: without "tree": ${error.message}`) + } + await page.close() + } + { + 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 frame = page.frameLocator('#embed') + const stepCount = () => frame.locator('.visualizer-step-count').innerText() + const mode = (name) => frame.locator('.visualizer-mode button', { hasText: name }).click() + // A mode switch stays at the same point of the program, maybe already at + // the last step. + const lastStep = async () => { + const button = frame.locator('.visualizer-controls button').nth(3) + if (await button.isEnabled()) await button.click() + return Number((await stepCount()).match(/von (\d+)/)[1]) + } + for (const [label, source, ersetzung, trees, texts] of [ + ['2 + 3 * 4', 'x = 2 + 3 * 4\n', 3, 1, ['2 + 3 * 4', '2 + 12', '14']], + ['(2 + 3) * 4', 'x = (2 + 3) * 4\n', 3, 1, ['(2 + 3) * 4', '5 * 4', '20']], + ['quadrat(2 + 1)', 'def quadrat(x):\n return x * x\n\ny = quadrat(2 + 1)\n', 5, 1, ['quadrat(2 + 1)', 'quadrat(3)', '3 * 3', '9']], + ['notenschnitt([4, 5, 4.5])', notenschnitt, 13, 4, null], + ]) { + try { + await page.evaluate((program) => window.send('visualize', program), source) + await frame.locator('.visualizer-step-count', { hasText: 'Vor der' }).waitFor({ timeout: 60000 }) + await measured(frame) + await mode('Ersetzung') + assert.equal(await lastStep(), ersetzung, 'the steps of «Ersetzung»') + await mode('Baum') + await measured(frame) + assert.equal(await lastStep(), ersetzung + 2 * trees, '«Ersetzung» plus open and close') + await frame.locator('.visualizer-controls button').nth(0).click() + const state = () => frame.locator('.embed-app').evaluate((app) => { + const tree = app.querySelector('.visualizer-canvas > .frame-box > .frame-code .value-tree-scroll') + return { + height: Math.round(app.getBoundingClientRect().height), + text: tree?.getAttribute('aria-label') ?? null, + // Each node's centre (a value takes its subtree's place, whatever its width) and row. + nodes: tree ? Object.fromEntries([...tree.querySelectorAll('[data-tree-key]')].map((node) => [node.dataset.treeKey, `${parseFloat(node.style.left) + parseFloat(node.style.width) / 2} ${node.style.top}`])) : null, + } + }) + const states = [await state()] + const next = frame.locator('.visualizer-controls button').nth(2) + while (await next.isEnabled()) { + await next.click() + states.push(await state()) + } + assert.deepEqual([...new Set(states.map((step) => step.height))], [states[0].height], 'one height for every step') + if (texts) assert.deepEqual(states.map((step) => step.text).filter((text, index, all) => text !== null && text !== all[index - 1]), texts, 'the tree described by its text, as the chain') + // A reduction (no new node) leaves every node where it was. + states.forEach((step, index) => { + const before = states[index - 1]?.nodes + if (!before || !step.nodes || !Object.keys(step.nodes).every((key) => key in before)) return + for (const [key, place] of Object.entries(step.nodes)) assert.equal(place, before[key], `node ${key} moved at step ${index}`) + }) + console.log(`Baum: ${label}: ok`) + } catch (error) { + failed += 1 + console.error(`Baum: ${label}: ${error.message}`) + } + } + // The hint at a call that stays a box, in both modes. + for (const name of ['Ersetzung', 'Baum']) { + try { + await mode(name) + await measured(frame) + await frame.locator('.visualizer-controls button').nth(0).click() + const next = frame.locator('.visualizer-controls button').nth(2) + for (let index = 0; index < 6; index++) await next.click() + // Not a closing box's fading copy (.visualizer-closing-boxes). + const hint = frame.locator('.visualizer-view > .visualizer-canvas > .frame-box .frame-hint[data-box-reason="locals"]') + await hint.waitFor({ timeout: 5000 }) + assert.equal(await hint.innerText(), 'notenschnitt hat lokale Variablen – der Aufruf wird als Kasten gezeigt.') + assert.equal(await frame.locator('.visualizer-mode button', { hasText: name }).getAttribute('aria-pressed'), 'true') + console.log(`Baum: the hint at the box in «${name}»: ok`) + } catch (error) { + failed += 1 + console.error(`Baum: the hint in «${name}»: ${error.message}`) + } + } + await page.close() + } // P37: the run file is __main__, in Pyodide too. { const page = await openEmbed({}) diff --git a/scripts/check-runtime-box-reasons.py b/scripts/check-runtime-box-reasons.py new file mode 100644 index 0000000..8c20459 --- /dev/null +++ b/scripts/check-runtime-box-reasons.py @@ -0,0 +1,52 @@ +"""Regression test: why a call stays a box in the substitution and tree modes +(P34). A call-enter step of a function without the short form names the +reason ("boxReason"), for the note at its box: local variables (or a loop), +an "if" choosing what runs, several statements, no "return ", a +return expression that can't be written out with its values. A function with +the short form has none. + +Runs the runtime with CPython (no Pyodide needed): + python3 -B scripts/check-runtime-box-reasons.py +""" +import json +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RUNTIME = os.path.join(ROOT, "src", "runtime") +sys.path.insert(0, RUNTIME) # for "import gturtle" + +runtime = {"__name__": "glpython_runtime"} +for name in ("hints.py", "session.py", "visualizer.py"): + with open(os.path.join(RUNTIME, name), encoding="utf-8") as handle: + exec(compile(handle.read(), "", "exec"), runtime) + +failures = [] + +cases = [ + ("locals", "def f(noten):\n summe = sum(noten)\n return summe / len(noten)\n\nf([4, 5])\n", "locals"), + ("a loop", "def f(n):\n for i in range(n):\n pass\n return n\n\nf(2)\n", "locals"), + ("an if", "def f(n):\n if n == 0:\n return 1\n return n * f(n - 1)\n\nf(1)\n", "branches"), + ("several statements", "def f(n):\n print(n)\n return n + 1\n\nf(1)\n", "statements"), + ("no return", "def f(n):\n print(n)\n\nf(1)\n", "no-return"), + ("a bare return", "def f(n):\n return\n\nf(1)\n", "no-return"), + ("a comprehension", "def f(n):\n return [i * i for i in range(n)]\n\nf(3)\n", "expression"), + ("a docstring and if", 'def f(n):\n """Doc."""\n if n:\n print(n)\n return n\n\nf(1)\n', "branches"), + ("a nested def", "def f(n):\n def g():\n x = 1\n return x\n return g()\n\nf(1)\n", "statements"), +] +for label, source, expected in cases: + result = json.loads(runtime["_glpython_trace_program"]("main.py", {"main.py": source}, None)) + enter = next(step for step in result["trace"] if step["kind"] == "call-enter" and step["func"] == "f") + if enter.get("boxReason") != expected or enter.get("shortForm") is not None: + failures.append(f"{label}: boxReason {enter.get('boxReason')!r}, expected {expected!r}") + +# The short form has no reason: it isn't a box there. +result = json.loads(runtime["_glpython_trace_program"]("main.py", {"main.py": 'def f(x):\n """Doc."""\n return x * x\n\nf(3)\n'}, None)) +enter = next(step for step in result["trace"] if step["kind"] == "call-enter") +if enter.get("boxReason") is not None or enter.get("shortForm") is None: + failures.append(f"short form: boxReason {enter.get('boxReason')!r}") + +if failures: + print("\n".join(failures)) + sys.exit(f"{len(failures)} box reason check(s) failed.") +print("Runtime box reason checks passed.") diff --git a/scripts/check-tree-mode.mjs b/scripts/check-tree-mode.mjs new file mode 100644 index 0000000..6d3c79c --- /dev/null +++ b/scripts/check-tree-mode.mjs @@ -0,0 +1,140 @@ +// Check of the visualizer's tree mode («Baum», P34 A, src/treeMode.ts) on +// traces of the real runtime (CPython runs src/runtime/, as test:runtime +// does): +// - its steps are exactly the substitution mode's (the same objects, in the +// same order), plus one step that opens each expression's tree and one +// that closes it - for 2 + 3 * 4, (2 + 3) * 4, quadrat(2 + 1) with a +// short-form quadrat, and a call with local variables; +// - each tree's labels are Python's (no parentheses: the two first examples +// differ only in shape), and its text step by step is the evaluation +// chain's, so tree and chain can't diverge; +// - a reduction moves no node of the tree: only a step that replaces an +// inline call by its return expression's tree lays it out afresh. +// +// npm run test:tree +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { buildEvaluationChain, computeOpenFramesUpTo, indexAstNodes, substitutionModeSteps } from '../src/visualizer.ts' +import { buildExpressionTree, episodeTreeLayout, treeModeSteps } from '../src/treeMode.ts' + +const programs = { + precedence: 'x = 2 + 3 * 4\n', + parentheses: 'x = (2 + 3) * 4\n', + shortForm: 'def quadrat(x):\n return x * x\n\ny = quadrat(2 + 1)\n', + locals: 'def notenschnitt(noten):\n summe = sum(noten)\n anzahl = len(noten)\n return summe / anzahl\n\nnotenschnitt([4, 5, 4.5])\n', + stepless: 'x = 3\nwerte = [x, -x, 2]\n', + recursion: 'def fak(n):\n if n == 0:\n return 1\n return n * fak(n - 1)\n\nfak(2)\n', +} + +const python = ` +import json, os, sys +runtime_dir = os.path.join(os.getcwd(), "src", "runtime") +sys.path.insert(0, runtime_dir) +runtime = {"__name__": "glpython_runtime"} +for name in ("hints.py", "session.py", "visualizer.py"): + with open(os.path.join(runtime_dir, name), encoding="utf-8") as handle: + exec(compile(handle.read(), "", "exec"), runtime) +programs = json.loads(sys.stdin.read()) +print(json.dumps({key: json.loads(runtime["_glpython_trace_program"]("main.py", {"main.py": source}, None)) for key, source in programs.items()})) +` +const run = spawnSync('python3', ['-B', '-c', python], { input: JSON.stringify(programs), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) +if (run.status !== 0) throw new Error(run.stderr) +const results = JSON.parse(run.stdout) + +let failed = 0 +const check = (label, test) => { + try { + test() + console.log(`${label}: ok`) + } catch (error) { + failed += 1 + console.error(`${label}: ${error.message}`) + } +} + +const describeTree = (tree) => tree.kind === 'leaf' ? tree.text : `${tree.label}(${tree.children.map(describeTree).join(', ')})` +const describeStep = (step) => step.treePhase ?? (step.kind === 'call-enter' ? `enter ${step.func}` : step.kind) + +for (const [name, result] of Object.entries(results)) { + const source = programs[name] + const nodeIndexByFile = new Map(Object.entries(result.ast).map(([path, file]) => [path, indexAstNodes(file)])) + const substitution = substitutionModeSteps(result.trace) + const { steps, episodes } = treeModeSteps(result.trace, substitution, nodeIndexByFile) + const context = (step) => ({ trace: result.trace, nodeIndexByFile, sources: { 'main.py': source }, openFrames: computeOpenFramesUpTo(result.trace, step), framesAt: (other) => computeOpenFramesUpTo(result.trace, other), nextKey: null, errorKey: null }) + // Each tree as the visualizer shows it, step by step. + const shown = episodes.map((episode) => episode.shownSteps.map((step) => buildExpressionTree(context(step), episode, step))) + result.shown = shown + + check(`${name}: «Baum» is «Ersetzung» plus open and close`, () => { + assert.deepEqual(steps.filter((step) => !step.treePhase), substitution) + // Every tree opens right before a step of its own and closes after it. + for (const episode of episodes) { + assert.equal(steps[episode.openIndex].treePhase, 'open') + assert.equal(steps[episode.closeIndex].treePhase, 'close') + assert.ok(episode.closeIndex > episode.openIndex + 1, 'a tree without a step') + } + assert.equal(steps.length, substitution.length + 2 * episodes.length) + }) + + check(`${name}: a reduction moves no node`, () => { + episodes.forEach((episode, index) => { + let previous = null + episode.shownSteps.forEach((step, position) => { + const built = shown[index][position] + const layout = episodeTreeLayout(context(step), episode, step, built.tree) + const places = new Map(layout.nodes.map((placed) => [placed.key, `${placed.x},${placed.depth}`])) + const isExpansion = episode.expansions.includes(step) && step !== episode.openStep + if (previous && !isExpansion) { + assert.ok([...places.keys()].every((key) => previous.has(key)), `a new node at step ${step}`) + for (const [key, place] of places) assert.equal(place, previous.get(key), `node ${key} moved at step ${step}`) + } + previous = places + }) + }) + }) +} + +const pattern = (name) => { + const result = results[name] + const nodeIndexByFile = new Map(Object.entries(result.ast).map(([path, file]) => [path, indexAstNodes(file)])) + return treeModeSteps(result.trace, substitutionModeSteps(result.trace), nodeIndexByFile).steps.map(describeStep) +} +const texts = (name, episode = 0) => results[name].shown[episode].map((built) => built.text).filter((text, index, all) => index === 0 || text !== all[index - 1]) +const chain = (name) => { + const result = results[name] + const nodeIndexByFile = new Map(Object.entries(result.ast).map(([path, file]) => [path, indexAstNodes(file)])) + const step = result.trace.at(-1) + return buildEvaluationChain({ trace: result.trace, ast: result.ast, nodeIndexByFile, sources: { 'main.py': programs[name] }, rootPath: 'main.py', step, errorMessage: null, comments: false, strings: { conditionComment: () => '', localsComment: '' } }).split('\n').map((line) => line.replace(/^→ /, '')) +} + +check('2 + 3 * 4: the steps', () => assert.deepEqual(pattern('precedence'), ['open', 'eval', 'eval', 'close', 'exec'])) +check('2 + 3 * 4: the tree and its text', () => { + assert.equal(describeTree(results.precedence.shown[0][0].tree), '+(2, *(3, 4))') + assert.deepEqual(texts('precedence'), ['2 + 3 * 4', '2 + 12', '14']) + assert.deepEqual(texts('precedence'), chain('precedence')) +}) +check('(2 + 3) * 4: only the shape differs, no parentheses', () => { + assert.deepEqual(pattern('parentheses'), ['open', 'eval', 'eval', 'close', 'exec']) + assert.equal(describeTree(results.parentheses.shown[0][0].tree), '*(+(2, 3), 4)') + assert.deepEqual(texts('parentheses'), ['(2 + 3) * 4', '5 * 4', '20']) + assert.deepEqual(texts('parentheses'), chain('parentheses')) +}) +check('quadrat(2 + 1): the call replaced by the tree of its body', () => { + assert.deepEqual(pattern('shortForm'), ['exec', 'open', 'eval', 'enter quadrat', 'eval', 'close', 'exec']) + assert.deepEqual(results.shortForm.shown[0].map((built) => describeTree(built.tree)).filter((text, index, all) => index === 0 || text !== all[index - 1]), ['quadrat(+(2, 1))', 'quadrat(3)', '*(3, 3)', '9']) + assert.deepEqual(texts('shortForm'), chain('shortForm')) +}) +check('notenschnitt([4, 5, 4.5]): a box below the tree, its lines with trees of their own', () => { + assert.deepEqual(pattern('locals'), ['exec', 'open', 'call-pending', 'enter notenschnitt', 'open', 'eval', 'eval', 'close', 'exec', 'open', 'eval', 'eval', 'close', 'exec', 'open', 'eval', 'eval', 'eval', 'close', 'eval', 'close']) + const outer = results.locals.shown[0] + assert.equal(describeTree(outer[0].tree), 'notenschnitt([4, 5, 4.5])') + assert.ok(outer.some((built) => built.boxes.some((frame) => frame.funcName === 'notenschnitt')), 'the box of notenschnitt') + assert.deepEqual(texts('locals', 3), ['summe / anzahl', '13.5 / anzahl', '13.5 / 3', '4.5']) +}) +check('[x, -x, 2]: nodes without a step become their value with their last child', () => { + assert.deepEqual(texts('stepless', 0), ['[x, -x, 2]', '[3, -x, 2]', '[3, -3, 2]']) + assert.equal(describeTree(results.stepless.shown[0].at(-1).tree), '[3, -3, 2]') +}) + +if (failed > 0) process.exit(1) +console.log('Tree mode checks passed.') diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index 7f90c4c..8e559c4 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -5,13 +5,14 @@ import type { Monaco, OnMount } from '@monaco-editor/react' import { AlertTriangle, BookOpen, Copy, Square, FileCode2, FlaskConical, Lock, 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, fullValuesUpTo, valueTreesUpTo, hiddenRangesWithin, removedLines, computeLineOffsets, computeOpenFramesUpTo, indexAstNodes, lineNumberAtOffset, locateNodeRenderedRange, nodeRange, renderFrameSource, substitutionModeSteps, type AstNode, type CollapsedCalls, type FullValue, type OpenFrame, type TraceResult, type TraceStep, type VisualizerMode } from './visualizer' +import { buildAnnotationsUpTo, buildEvaluationChain, buildExecutedLinesUpTo, buildLocalsUpTo, buildSubstitutionsUpTo, collapseDeepCallChain, hasEvaluationChain, fullValuesUpTo, valueTreesUpTo, hiddenRangesWithin, removedLines, computeLineOffsets, computeOpenFramesUpTo, indexAstNodes, lineNumberAtOffset, locateNodeRenderedRange, nodeRange, renderFrameSource, substitutionModeSteps, type AstNode, type CollapsedCalls, type FullValue, type OpenFrame, type BoxReason, type TraceResult, type TraceStep, type VisualizerMode } from './visualizer' import { tokenizePythonFragment } from './pyHighlight' import { GLPYTHON_PYTHON, registerGlpythonPython } from './pythonLanguage' import { defineGlpythonTheme, GLPYTHON_THEME } from './monacoTheme' import type { ErrorHint, ErrorHintContext } from './errorHints' import { TreeView } from './TreeView' -import { layoutTree, treeFromValue, type ValueTree } from './treeLayout' +import { layoutTree, treeFromValue, type TreeLayout, type TreeNode, type ValueTree } from './treeLayout' +import { activeTreesAt, buildExpressionTree, episodeTreeLayout, treeKeyOf, treeModeSteps, type TreeEpisode } from './treeMode' type CoreStrings = { exitVisualizer: string @@ -75,6 +76,11 @@ type CoreStrings = { visualizerModeBox?: string visualizerModeSubstitution?: string visualizerBoxHint?: string + // The third mode («Baum», with the `tree` capability), and why a call stays + // a box in the substitution and tree modes: the text after the function's + // name, e.g. "has local variables – the call is shown as a box." + visualizerModeTree?: string + visualizerBoxReason?: (reason: BoxReason) => string // The visualizer's "copy the evaluation as a → chain" button, its // confirmation, its failure message, the switch for the chain's reason // comments, and those comments ("2 == 0 is False", "body with local @@ -187,6 +193,9 @@ type WorkspaceCapabilities = { // variables and full-value dialog (with a switch in its options menu), and // on request («Baum») for a REPL input's value. listTree: boolean + // The visualizer's third mode, «Baum»: an expression as its tree while it + // is evaluated (see treeMode.ts). + tree: boolean } const defaultWorkspaceCapabilities: WorkspaceCapabilities = { @@ -205,6 +214,7 @@ const defaultWorkspaceCapabilities: WorkspaceCapabilities = { emptyOutput: true, closeFileTabs: true, listTree: false, + tree: false, } type WorkspaceCommand = { @@ -310,7 +320,8 @@ async function copyText(text: string): Promise { function storedVisualizerMode(): VisualizerMode { try { - return localStorage.getItem(visualizerModeStorageKey) === 'box' ? 'box' : 'substitution' + const stored = localStorage.getItem(visualizerModeStorageKey) + return stored === 'box' || stored === 'tree' ? stored : 'substitution' } catch { return 'substitution' } @@ -370,7 +381,8 @@ type ReplReplayNote = 'input' | 'differs' // where that text sat (see childRanges in buildLineSegments below). type LineSegment = | { kind: 'text'; text: string; className?: string; annotationNodeId?: number } - | { kind: 'child'; frame: OpenFrame } + // A nested box or inline call, or (frame null) the tree mode's tree. + | { kind: 'child'; frame: OpenFrame | null } | { kind: 'group'; className: string; parts: Array<{ text: string; className?: string; annotationNodeId?: number }> } function buildLineSegments( @@ -386,7 +398,7 @@ function buildLineSegments( // `param` marks a call box's "=value" next to a parameter name in its // header, which is styled more prominently than a loop variable's. annotationHighlights: Array<{ start: number; end: number; nodeId?: number; param?: boolean }>, - childRanges: Array<{ start: number; end: number; frame: OpenFrame }>, + childRanges: Array<{ start: number; end: number; frame: OpenFrame | null }>, nextStepRanges: Array<{ start: number; end: number }>, // The same box-drawing is reused for the failing node at an 'error' step // (which has no next step), just with its own class. @@ -404,7 +416,7 @@ function buildLineSegments( // `className`) so that touching next-step marks can later be detected and // merged into one continuous box instead of leaving every mark to draw // its own outline - see the grouping pass below. - const marks: Array<{ start: number; end: number; className?: string; frame?: OpenFrame; nextStep?: boolean; annotationNodeId?: number }> = [] + const marks: Array<{ start: number; end: number; className?: string; child?: true; frame?: OpenFrame | null; continued?: true; nextStep?: boolean; annotationNodeId?: number }> = [] for (const h of highlights) { if (insideChild(h.start, h.end)) continue const start = Math.max(h.start, lineStart) @@ -420,7 +432,8 @@ function buildLineSegments( for (const c of childRanges) { const start = Math.max(c.start, lineStart) const end = Math.min(c.end, lineEnd) - if (start < end) marks.push({ start: start - lineStart, end: end - lineStart, frame: c.frame }) + // A child that started on an earlier line is drawn there only. + if (start < end) marks.push({ start: start - lineStart, end: end - lineStart, child: true, frame: c.frame, continued: c.start < lineStart || undefined }) } // The exact AST node the *next* trace step (not yet applied, since // rendering is always clamped to uptoStep) is about to evaluate/execute - @@ -445,7 +458,7 @@ function buildLineSegments( const mStart = Math.max(m.start, cursor) const mEnd = Math.min(m.end, end) if (mStart >= mEnd) continue - if (m.frame) { + if (m.child) { if (mStart > cursor) marks.push({ start: cursor, end: mStart, nextStep: true }) cursor = Math.max(cursor, mEnd) continue @@ -463,10 +476,10 @@ function buildLineSegments( // single group here so they render as one continuous highlighted box // instead of two dashed outlines meeting at a seam. Child-frame marks // never participate - they're their own nested component, not text. - const grouped: Array<{ start: number; end: number; className?: string; frame?: OpenFrame; nextStep?: boolean; annotationNodeId?: number; parts?: Array<{ start: number; end: number; className?: string; annotationNodeId?: number }> }> = [] + const grouped: Array<{ start: number; end: number; className?: string; child?: true; frame?: OpenFrame | null; continued?: true; nextStep?: boolean; annotationNodeId?: number; parts?: Array<{ start: number; end: number; className?: string; annotationNodeId?: number }> }> = [] for (const mark of marks) { const prev = grouped[grouped.length - 1] - if (mark.nextStep && !mark.frame && prev && prev.nextStep && !prev.frame && prev.end === mark.start) { + if (mark.nextStep && !mark.child && prev && prev.nextStep && !prev.child && prev.end === mark.start) { if (!prev.parts) prev.parts = [{ start: prev.start, end: prev.end, className: prev.className, annotationNodeId: prev.annotationNodeId }] prev.parts.push({ start: mark.start, end: mark.end, className: mark.className, annotationNodeId: mark.annotationNodeId }) prev.end = mark.end @@ -479,8 +492,8 @@ function buildLineSegments( let cursor2 = 0 for (const mark of grouped) { if (mark.start > cursor2) segments.push({ kind: 'text', text: line.slice(cursor2, mark.start) }) - if (mark.frame) { - segments.push({ kind: 'child', frame: mark.frame }) + if (mark.child) { + if (!mark.continued) segments.push({ kind: 'child', frame: mark.frame ?? null }) } else if (mark.parts) { segments.push({ kind: 'group', className: nextStepClassName, parts: mark.parts.map((p) => ({ text: line.slice(p.start, p.end), className: p.className, annotationNodeId: p.annotationNodeId })) }) } else { @@ -621,6 +634,8 @@ type StepView = { isAtLastStep: boolean activeHost: InlineHost | null errorHost: InlineHost | null + // In the tree mode, the expression tree open in each frame. + activeTrees: Map } // When the pending next step is a plain "x = ..." assignment or a @@ -719,7 +734,7 @@ function steadyHeightCandidates(trace: TraceStep[], steps: TraceStep[]): number[ } // The view at the visualizer's step `index` (of `steps`, the mode's list). -function stepViewAt(trace: TraceStep[], steps: TraceStep[], index: number, astIndexByFile: Map>, substitutionMode: boolean): StepView { +function stepViewAt(trace: TraceStep[], steps: TraceStep[], index: number, astIndexByFile: Map>, substitutionMode: boolean, treeEpisodes: TreeEpisode[] | null): StepView { const currentStep = steps[index] ?? null const nextStep = steps[index + 1] ?? null const lastStep = steps[steps.length - 1] @@ -741,6 +756,7 @@ function stepViewAt(trace: TraceStep[], steps: TraceStep[], index: number, astIn isAtLastStep: steps.length > 0 && index === steps.length - 1, activeHost: inlineHostIn(framesForRender, astIndexByFile, substitutionMode, currentStep?.frameId), errorHost: isAtErrorStep && errorStep ? inlineHostIn(framesForRender, astIndexByFile, substitutionMode, errorStep.frameId) : null, + activeTrees: treeEpisodes ? activeTreesAt(treeEpisodes, index) : new Map(), } } @@ -781,11 +797,15 @@ type FrameProps = { // replaced by a marker, and the marker's text. collapsedCalls: CollapsedCalls | null collapsedCallsLabel: string - // The visualizer's substitution mode, and the note shown in the outermost - // box that's still drawn as a box in it (`insideBox` is false there). + // The visualizer's substitution mode (also in the tree mode), and the + // note shown in the outermost box that's still drawn as a box in it + // (`insideBox` is false there): why the call isn't replaced. substitutionMode: boolean - boxHint: string + boxHint: (reason: BoxReason | null, funcName: string) => ReactNode insideBox: boolean + // The tree mode: the expression tree open in each frame, and the frames + // open after any trace step (see episodeTreeLayout). + trees: { active: Map; framesAt: (step: number) => Map } | null // Set on an inline call that the next step (or the error) covers as a // whole, since it isn't text in its parent's line to mark directly. inlineMarkClass?: string @@ -811,7 +831,7 @@ type FrameProps = { // call site - real DOM in normal flow, so surrounding text/lines reflow // automatically. function Frame(props: FrameProps) { - const { frameId, path, scope, errorHint, openFrames, nodeIndexByFile, visualSources, trace, uptoStep, currentStep, nextStep, monaco, valueKindLabels, hiddenRanges, declareTarget, pendingCallLocals, errorStep, collapsedCalls, collapsedCallsLabel, substitutionMode, boxHint, insideBox, inlineMarkClass, activeHost, errorHost, finalNote } = props + const { frameId, path, scope, errorHint, openFrames, nodeIndexByFile, visualSources, trace, uptoStep, currentStep, nextStep, monaco, valueKindLabels, hiddenRanges, declareTarget, pendingCallLocals, errorStep, collapsedCalls, collapsedCallsLabel, substitutionMode, boxHint, insideBox, inlineMarkClass, activeHost, errorHost, finalNote, trees } = props const nodeIndex = nodeIndexByFile.get(path) const source = visualSources[path] if (!nodeIndex || source === undefined) return null @@ -868,7 +888,7 @@ function Frame(props: FrameProps) { // site is swallowed just like any other child's, since the substituted // value it would have shown is instead rendered one step early inside // its own box's header (see nextStepPendingCall/paramAnnotations). - const childRanges: Array<{ start: number; end: number; frame: OpenFrame }> = [] + const childRanges: Array<{ start: number; end: number; frame: OpenFrame | null }> = [] for (const child of children) { const anchorNode = child.anchorPath === path ? nodeIndex.get(child.anchorNodeId) : undefined if (!anchorNode) continue @@ -877,6 +897,25 @@ function Frame(props: FrameProps) { childRanges.push({ start: range.start - clipStart, end: range.end - clipStart, frame: child }) } + // The tree mode: the expression being evaluated in this frame drawn as its + // tree in its place - with the calls in it, which the tree shows (inline + // calls) or hangs below it (boxes). + const episode = trees?.active.get(frameId) + const treeRoot = episode && episode.path === path ? nodeIndex.get(episode.rootNodeId) : undefined + const treeRange = treeRoot ? locateNodeRenderedRange(lineOffsets, nodeIndex, substitutions, annotations, treeRoot, false, substitutionContext) : null + let expressionTree: { tree: TreeNode; layout: TreeLayout; text: string; boxes: OpenFrame[] } | null = null + if (trees && episode && treeRange) { + const context = { trace, nodeIndexByFile, sources: visualSources, openFrames, framesAt: trees.framesAt, nextKey: treeKeyOf(nextStep), errorKey: treeKeyOf(errorStep) } + const built = buildExpressionTree(context, episode, uptoStep) + if (built) { + expressionTree = { ...built, layout: episodeTreeLayout(context, episode, uptoStep, built.tree) } + const start = treeRange.start - clipStart + const end = treeRange.end - clipStart + for (let index = childRanges.length - 1; index >= 0; index--) if (childRanges[index].start >= start && childRanges[index].end <= end) childRanges.splice(index, 1) + childRanges.push({ start, end, frame: null }) + } + } + // The AST node the *next* trace step (uptoStep + 1) will touch, if that // step belongs to this exact frame - so the learner can see exactly the // span of code that's about to be mutated/replaced before clicking @@ -928,14 +967,14 @@ function Frame(props: FrameProps) { } // Children of a box (or of an inline call inside one) are inside a box. - const childFrameProps = { errorHint, openFrames, nodeIndexByFile, visualSources, trace, uptoStep, currentStep, nextStep, monaco, valueKindLabels, hiddenRanges, declareTarget, pendingCallLocals, errorStep, collapsedCalls, collapsedCallsLabel, substitutionMode, boxHint, activeHost, errorHost, insideBox: insideBox || scope.kind === 'call' } + const childFrameProps = { errorHint, openFrames, nodeIndexByFile, visualSources, trace, uptoStep, currentStep, nextStep, monaco, valueKindLabels, hiddenRanges, declareTarget, pendingCallLocals, errorStep, collapsedCalls, collapsedCallsLabel, substitutionMode, boxHint, activeHost, errorHost, trees, insideBox: insideBox || scope.kind === 'call' } const markedRanges = errorRanges.length > 0 ? errorRanges : nextStepRanges const inlineMarkFor = (frame: OpenFrame) => { - const range = childRanges.find((child) => child.frame.frameId === frame.frameId) + const range = childRanges.find((child) => child.frame?.frameId === frame.frameId) if (!range || !markedRanges.some((marked) => marked.start <= range.start && marked.end >= range.end)) return undefined return errorRanges.length > 0 ? 'visual-error-node' : 'visual-next-step' } - const renderSegments = (segments: LineSegment[]) => segments.map((segment, segmentIndex) => segment.kind === 'child' && collapsedCalls?.firstHiddenFrameId === segment.frame.frameId ? ( + const renderChild = (frame: OpenFrame, segmentIndex: number): ReactNode => collapsedCalls?.firstHiddenFrameId === frame.frameId ? ( {collapsedCallsLabel} @@ -949,25 +988,33 @@ function Frame(props: FrameProps) { /> - ) : segment.kind === 'child' && substitutionMode && segment.frame.shortForm ? ( + ) : substitutionMode && frame.shortForm ? ( - ) : segment.kind === 'child' ? ( + ) : ( - ) : segment.kind === 'group' ? ( + ) + const renderSegments = (segments: LineSegment[]): ReactNode[] => segments.map((segment, segmentIndex) => segment.kind === 'child' && segment.frame === null ? ( + expressionTree && ( + + {expressionTree.boxes.length > 0 && renderSegments(expressionTree.boxes.map((frame) => ({ kind: 'child', frame })))} + + ) + ) : segment.kind === 'child' ? renderChild(segment.frame!, segmentIndex) : segment.kind === 'group' ? ( + {segment.parts.map((part, partIndex) => )} @@ -997,9 +1044,11 @@ function Frame(props: FrameProps) { } const showBoxHint = substitutionMode && scope.kind === 'call' && !insideBox + const boxReason = showBoxHint ? trace.find((step) => step.kind === 'call-enter' && step.frameId === frameId)?.boxReason ?? null : null return (
+ {showBoxHint && openFrame &&
{boxHint(boxReason, openFrame.funcName)}
} {lines.map((line, index) => { const lineStart = cursor cursor += line.length + 1 @@ -1043,7 +1092,6 @@ function Frame(props: FrameProps) { ) })} - {showBoxHint &&
{boxHint}
}
{/* The variables panel only when there are variables to show. */} {localEntries.length > 0 && <> @@ -1056,6 +1104,43 @@ function Frame(props: FrameProps) { ) } +// The tree mode's tree in a code line, at the place of its expression, with +// the boxes of the calls in it below. It is at most as wide as the +// visualizer shows from where it starts, so a wide tree scrolls in itself +// (TreeView: root centred, hints at the hidden edges) instead of widening +// the canvas. +function ExpressionTreeBox({ tree, layout, label, children }: { tree: TreeNode; layout: TreeLayout; label: string; children?: ReactNode }) { + const viewRef = useRef(null) + const [maxWidth, setMaxWidth] = useState(null) + useLayoutEffect(() => { + const element = viewRef.current + const container = element?.closest('.visualizer-measure') ?? element?.closest('.visualizer-view') + if (!element || !container) return + const update = () => { + const offset = element.getBoundingClientRect().left - container.getBoundingClientRect().left + container.scrollLeft + const width = Math.max(TREE_MIN_WIDTH, Math.floor(container.clientWidth - offset - TREE_END_MARGIN)) + setMaxWidth((previous) => previous === width ? previous : width) + } + update() + const observer = new ResizeObserver(update) + observer.observe(container) + return () => observer.disconnect() + }, []) + return ( + + + + + {children && {children}} + + ) +} + +// The narrowest a tree gets in a line, and the room it leaves at the +// visualizer's right edge, in pixels. +const TREE_MIN_WIDTH = 160 +const TREE_END_MARGIN = 16 + // An element's position within the visualizer canvas, from layout offsets // rather than getBoundingClientRect: a box that just opened is still scaled // down by its "visual-frame-grow" animation when it's measured. @@ -1245,7 +1330,11 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { try { return localStorage.getItem(chainCommentsStorageKey) !== 'false' } catch { return true } }) const [chainFeedback, setChainFeedback] = useState<'copied' | 'failed' | null>(null) - const substitutionMode = visualizerMode === 'substitution' + // «Baum» only with its capability; else the substitution mode shows. + const effectiveMode: VisualizerMode = visualizerMode === 'tree' && !workspaceCapabilities.tree ? 'substitution' : visualizerMode + // The tree mode shows calls as the substitution mode does. + const substitutionMode = effectiveMode !== 'box' + const treeMode = effectiveMode === 'tree' const valueKindLabels = useMemo(() => ({ function: t.valueKindFunction ?? 'function', class: t.valueKindClass ?? 'class', @@ -1695,10 +1784,32 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // the trace in the box mode, only the visible substitutions in the // substitution mode (see substitutionModeSteps). Rendering always replays // the whole trace up to the current step's own step number. + const astIndexByFile = useMemo(() => { + const map = new Map>() + if (visualTrace) for (const [path, file] of Object.entries(visualTrace.ast)) map.set(path, indexAstNodes(file)) + return map + }, [visualTrace]) + + // In the tree mode, the substitution mode's steps plus those that open and + // close each expression's tree (see treeModeSteps). + const treeSteps = useMemo( + () => visualTrace && treeMode ? treeModeSteps(visualTrace.trace, substitutionModeSteps(visualTrace.trace), astIndexByFile) : null, + [visualTrace, treeMode, astIndexByFile], + ) const visualTraceSteps = useMemo( - () => !visualTrace ? [] : substitutionMode ? substitutionModeSteps(visualTrace.trace) : visualTrace.trace, - [visualTrace, substitutionMode], + () => !visualTrace ? [] : treeSteps ? treeSteps.steps : substitutionMode ? substitutionModeSteps(visualTrace.trace) : visualTrace.trace, + [visualTrace, substitutionMode, treeSteps], ) + // The frames open after a trace step, for laying out the trees shown + // before the current one (see episodeTreeLayout). + const treeFramesAt = useMemo(() => { + const cache = new Map>() + return (step: number) => { + let frames = cache.get(step) + if (!frames) cache.set(step, frames = computeOpenFramesUpTo(visualTrace?.trace ?? [], step)) + return frames + } + }, [visualTrace]) // Lower bound of -1 is the "before execution" state; upper bound is the // last recorded step (or -1 itself if no steps were recorded at all). const clampedStepIndex = Math.min(Math.max(visualStepIndex, -1), Math.max(0, visualTraceSteps.length - 1)) @@ -1726,12 +1837,6 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // see .visual-output-steady), so it doesn't grow with every print. const visualFinalOutput = visualTrace ? `${visualTrace.output}${visualTrace.error && errorTraceStep ? `${visualTrace.output && !visualTrace.output.endsWith('\n') ? '\n' : ''}${visualTrace.error}` : ''}` || t.noOutput : '' - const astIndexByFile = useMemo(() => { - const map = new Map>() - if (visualTrace) for (const [path, file] of Object.entries(visualTrace.ast)) map.set(path, indexAstNodes(file)) - return map - }, [visualTrace]) - // When the pending next step is a bare variable read (e.g. "n" about to // be looked up and substituted by its current value), note which frame // and name it reads from - so an arrow can be drawn from that variable's @@ -1962,22 +2067,29 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { : null const activeHost = inlineHostOf(currentTraceStep?.frameId) const errorHost = isAtErrorStep ? inlineHostOf(errorTraceStep.frameId) : null - const currentStepView: StepView = { currentStep: currentTraceStep, nextStep: nextTraceStep, openFrames, pendingCall: nextStepPendingCall, framesForRender, pendingCallLocals, collapsedCalls, declareTarget: nextStepDeclareTarget, isAtErrorStep, isAtLastStep, activeHost, errorHost } + const currentActiveTrees = useMemo(() => treeSteps ? activeTreesAt(treeSteps.episodes, clampedStepIndex) : new Map(), [treeSteps, clampedStepIndex]) + const currentStepView: StepView = { currentStep: currentTraceStep, nextStep: nextTraceStep, openFrames, pendingCall: nextStepPendingCall, framesForRender, pendingCallLocals, collapsedCalls, declareTarget: nextStepDeclareTarget, isAtErrorStep, isAtLastStep, activeHost, errorHost, activeTrees: currentActiveTrees } + // Why a call stays a box in the substitution and tree modes, at its box. + const boxHintFor = (reason: BoxReason | null, funcName: string): ReactNode => { + const text = reason ? t.visualizerBoxReason?.(reason) : undefined + if (!text) return t.visualizerBoxHint ?? 'The box shows which return is executed.' + return <>{funcName} {text} + } // The module's frame at a step, with the boxes and inline calls inside it. const rootFrame = (view: StepView) => { const collapsedLabel = view.collapsedCalls ? t.visualizerCollapsedCalls?.(view.collapsedCalls.hiddenCount, view.collapsedCalls.callText) ?? `\u2026 ${view.collapsedCalls.hiddenCount} more calls${view.collapsedCalls.callText ? ` of ${view.collapsedCalls.callText}` : ''} \u2026` : '' - return : null} /> + return : null} /> } const changeVisualizerMode = (mode: VisualizerMode) => { - if (mode === visualizerMode) return + if (mode === effectiveMode) return // Stay at the same point of the program: the last step of the new mode's // list that isn't past the current one. if (visualTrace) { const current = currentTraceStep?.step ?? -1 - const steps = mode === 'substitution' ? substitutionModeSteps(visualTrace.trace) : visualTrace.trace + const steps = mode === 'tree' ? treeModeSteps(visualTrace.trace, substitutionModeSteps(visualTrace.trace), astIndexByFile).steps : mode === 'substitution' ? substitutionModeSteps(visualTrace.trace) : visualTrace.trace let index = -1 steps.forEach((step, stepIndex) => { if (step.step <= current) index = stepIndex }) setVisualStepIndex(index) @@ -2336,8 +2448,8 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // wraps differently), starts again. const steadyHeightRef = useRef(0) // The steps still to measure, for which trace, mode and width. - const [steadyMeasure, setSteadyMeasure] = useState<{ trace: TraceResult | null; substitutionMode: boolean; width: number; pending: number[] }>({ trace: null, substitutionMode: false, width: 0, pending: [] }) - const steadyMeasureCurrent = steadyMeasure.trace === visualTrace && steadyMeasure.substitutionMode === substitutionMode + const [steadyMeasure, setSteadyMeasure] = useState<{ trace: TraceResult | null; mode: VisualizerMode; width: number; pending: number[] }>({ trace: null, mode: 'box', width: 0, pending: [] }) + const steadyMeasureCurrent = steadyMeasure.trace === visualTrace && steadyMeasure.mode === effectiveMode const raiseSteadyHeight = (height: number) => { const canvas = visualizerCanvasRef.current if (!canvas || height <= steadyHeightRef.current) return @@ -2355,14 +2467,14 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { width = canvas.offsetWidth steadyHeightRef.current = 0 canvas.style.minHeight = '' - if (compact && visualTrace) setSteadyMeasure({ trace: visualTrace, substitutionMode, width, pending: steadyHeightCandidates(visualTrace.trace, visualTraceSteps) }) + if (compact && visualTrace) setSteadyMeasure({ trace: visualTrace, mode: effectiveMode, width, pending: steadyHeightCandidates(visualTrace.trace, visualTraceSteps) }) } raiseSteadyHeight(canvas.offsetHeight) }) observer.observe(canvas) return () => observer.disconnect() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isVisualizing, visualTrace, substitutionMode, compact]) + }, [isVisualizing, visualTrace, effectiveMode, compact]) const steadyMeasureRef = useRef(null) useLayoutEffect(() => { const container = steadyMeasureRef.current @@ -2373,7 +2485,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [steadyMeasure]) const steadyMeasureNode = steadyMeasureCurrent && steadyMeasure.pending.length > 0 && visualTrace ?