From 527dc0256321cec217a6a8a986ab1c0d38886a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:53:56 +0100 Subject: [PATCH] =?UTF-8?q?=C2=ABBaum=C2=BB:=20parameter=20names=20on=20a?= =?UTF-8?q?=20call's=20edges;=20the=20box=20in=20the=20call=20node's=20pla?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A call of the program's own labels each argument's edge with the parameter it binds to (positional, by keyword; none after *args, for **kwargs, for a default, or for built-in functions), until the call is replaced or its box opens. The layout keeps room for the labels in each node's place, so they touch no node and no other label. A call that stays a box now sits in its node's place: a tree node of the box's measured size (ExpressionTreeBox, growth-only), several rows high (the van der Ploeg port takes a node's height into account). The tree is laid out again when the box opens and closes, and when it grows. Checks: test:tree (labels, keywords, builtins, the box in place), test:layout (flextree with tall boxes and label room on 500 call trees, no label overlapping), test:console (labels, the box under +, no overlaps, one height, no movement while the box is open). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 25 +++++++-- scripts/check-console-browser.mjs | 62 ++++++++++++++++++++++ scripts/check-tree-layout.mjs | 49 ++++++++++++++++-- scripts/check-tree-mode.mjs | 33 ++++++++++-- src/GlPythonWorkspace.tsx | 86 +++++++++++++++++++++++-------- src/TreeView.tsx | 48 +++++++++++------ src/style.css | 5 +- src/treeLayout.ts | 64 ++++++++++++++++++----- src/treeMode.ts | 75 +++++++++++++++++++++++---- src/treeSvg.ts | 2 +- 11 files changed, 377 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a8d3908..46b4735 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,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. 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`. +- **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. - **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 cb4d4b4..8344821 100644 --- a/README.md +++ b/README.md @@ -143,15 +143,32 @@ or `while`'s condition; not a bare name or literal): 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). + `return ` 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 - with the + note why it isn't replaced at its top; inside it, each line has its own + tree and calls of their own boxes. 3. **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 (and where a call is -replaced) with room for the widest value each node takes. With the +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 diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index a344c5d..5cd6c17 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -646,6 +646,68 @@ try { console.error(`Baum: ${label}: ${error.message}`) } } + // P44: a call of the program's own labels its arguments' edges with the + // parameters; a call that stays a box sits in its node's place. Every + // step: one height, nothing overlapping, and positions that change only + // where the box opens and closes. + const callState = () => frame.locator('.embed-app').evaluate((app) => { + const tree = app.querySelector('.visualizer-canvas > .frame-box > .frame-code .value-tree') + if (!tree) return { height: Math.round(app.getBoundingClientRect().height), labels: null, nodes: null, overlap: null, box: null } + const items = [...tree.querySelectorAll(':scope > [data-tree-key], :scope > .value-tree-edge-label')] + const rects = items.map((item) => ({ name: item.dataset.treeKey ?? `label ${item.textContent}`, rect: (item.dataset.treeBoxKey ? item.firstElementChild ?? item : item).getBoundingClientRect() })) + const overlap = rects.flatMap((a, i) => rects.slice(i + 1).filter((b) => a.rect.left < b.rect.right - 0.5 && b.rect.left < a.rect.right - 0.5 && a.rect.top < b.rect.bottom - 0.5 && b.rect.top < a.rect.bottom - 0.5).map((b) => `${a.name} / ${b.name}`)) + const box = tree.querySelector(':scope > [data-tree-box-key]') + return { + height: Math.round(app.getBoundingClientRect().height), + labels: [...tree.querySelectorAll(':scope > .value-tree-edge-label')].map((label) => label.textContent), + nodes: Object.fromEntries([...tree.querySelectorAll(':scope > [data-tree-key]')].map((node) => [node.dataset.treeKey, `${node.style.left} ${node.style.top}`])), + overlap, + box: box ? { top: box.style.top, besides: [...tree.querySelectorAll(':scope > [data-tree-key]:not([data-tree-box-key])')].filter((node) => node.style.top === box.style.top).map((node) => node.textContent) } : null, + } + }) + const allStates = async (program) => { + await page.evaluate((source) => window.send('visualize', source), program) + await frame.locator('.visualizer-step-count', { hasText: 'Vor der' }).waitFor({ timeout: 60000 }) + await measured(frame) + await mode('Baum') + await measured(frame) + const first = frame.locator('.visualizer-controls button').nth(0) + if (await first.isEnabled()) await first.click() + const states = [await callState()] + const next = frame.locator('.visualizer-controls button').nth(2) + while (await next.isEnabled()) { + await next.click() + // After the step's animations (a box grows in, nodes glide). + await new Promise((resolve) => setTimeout(resolve, 600)) + states.push(await callState()) + } + return states + } + try { + const states = await allStates('def summe(x, y):\n return x + y\n\nsumme(2 + 2, 3 * 4)\nlen([1, 2]) + max(2, 5)\n') + const labelled = states.map((state) => state.labels?.join(',') ?? null).filter((labels) => labels !== null).filter((labels, index, all) => labels !== all[index - 1]) + assert.deepEqual(labelled, ['x,y', '', ], 'x and y until the call is replaced, then none (nor for len and max)') + assert.deepEqual(states.filter((state) => state.labels?.join(',') === 'x,y').length, 3, 'on the three steps before the call is replaced') + assert.deepEqual(states.flatMap((state) => state.overlap ?? []), [], 'nothing overlaps') + assert.deepEqual([...new Set(states.map((state) => state.height))], [states[0].height], 'one height') + console.log('Baum: parameter names on the edges: ok') + } catch (error) { + failed += 1 + console.error(`Baum: parameter names on the edges: ${error.message}`) + } + try { + const states = await allStates('def notenschnitt(noten):\n summe = sum(noten)\n anzahl = len(noten)\n return summe / anzahl\n\nnotenschnitt([4, 5, 4.5]) + 1\n') + const withBox = states.filter((state) => state.box) + assert.ok(withBox.length > 10, `the box is open for ${withBox.length} steps`) + assert.deepEqual([...new Set(withBox.map((state) => JSON.stringify(state.box)))], [JSON.stringify({ top: '2.8em', besides: ['1'] })], 'the box on the row below +, the leaf 1 beside it') + assert.deepEqual([...new Set(withBox.map((state) => JSON.stringify(state.nodes)))].length, 1, 'nothing moves while the box is open') + assert.deepEqual(states.flatMap((state) => state.overlap ?? []), [], 'nothing overlaps') + assert.deepEqual([...new Set(states.map((state) => state.height))], [states[0].height], 'one height') + console.log('Baum: the box in the call node\'s place: ok') + } catch (error) { + failed += 1 + console.error(`Baum: the box in the call node's place: ${error.message}`) + } // The hint at a call that stays a box, in both modes. for (const name of ['Ersetzung', 'Baum']) { try { diff --git a/scripts/check-tree-layout.mjs b/scripts/check-tree-layout.mjs index 45407e1..82a4eed 100644 --- a/scripts/check-tree-layout.mjs +++ b/scripts/check-tree-layout.mjs @@ -11,7 +11,7 @@ import assert from 'node:assert/strict' import { existsSync, readdirSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' -import { layoutTree, nodeWidth } from '../src/treeLayout.ts' +import { edgeLabelPlacement, layoutTree, NODE_HEIGHT, nodeWidth, ROW_HEIGHT } from '../src/treeLayout.ts' const { flextree } = createRequire(import.meta.url)('d3-flextree') // The same gap between neighbouring nodes as treeLayout.ts. @@ -23,9 +23,13 @@ const treeOf = (value) => Array.isArray(value) && typeof value[0] === 'string' ? { kind: 'node', label: value[0], children: value.slice(1).map(treeOf) } : { kind: 'leaf', text: typeof value === 'string' ? `'${value}'` : JSON.stringify(value) } +// The room an edge label takes (as treeLayout's edgeLabelRoom). +const labelRoom = (node) => node.edgeLabel ? 2 * (0.4 + [...node.edgeLabel].length * 0.75) : 0 // flextree's positions, moved so that the tree's left edge is at 0, by key. +// A box is as many rows high as it says (only leaves are boxes, so every +// row starts where flextree starts it: below a parent one row high). function oracle(tree) { - const layout = flextree({ children: (node) => (node.kind === 'node' && node.children.length > 0 ? node.children : null), nodeSize: (node) => [nodeWidth(node.data), 1], spacing: SIBLING_GAP }) + const layout = flextree({ children: (node) => (node.kind === 'node' && node.children.length > 0 ? node.children : null), nodeSize: (node) => [Math.max(nodeWidth(node.data), labelRoom(node.data)), node.data.kind === 'box' ? node.data.rows : 1], spacing: SIBLING_GAP }) const root = layout.hierarchy(tree) layout(root) let left = Infinity @@ -35,9 +39,9 @@ function oracle(tree) { right = Math.max(right, node.x + node.xSize / 2) }) const positions = new Map() - const visit = (node, key) => { - positions.set(key, { x: node.x - left, depth: node.depth }) - node.children?.forEach((child, index) => visit(child, key === '' ? String(index) : `${key}.${index}`)) + const visit = (node, path) => { + positions.set(node.data.key ?? path, { x: node.x - left, depth: node.depth }) + node.children?.forEach((child, index) => visit(child, path === '' ? String(index) : `${path}.${index}`)) } visit(root, '') return { positions, width: right - left } @@ -85,6 +89,40 @@ const randomTree = (depth) => { const RANDOM_TREES = 500 for (let index = 0; index < RANDOM_TREES; index++) compare(`random tree ${index}`, randomTree(0)) +// The tree mode's trees: some leaves are call boxes several rows high, some +// edges have labels (a parameter's name). Still flextree's positions, and +// no label touches a node or another label. +const randomCallTree = (depth) => { + const arity = depth >= 4 ? 0 : Math.floor(random() * 4) + const edgeLabel = random() < 0.5 ? 'x'.repeat(1 + Math.floor(random() * 6)) : undefined + if (arity === 0) return random() < 0.3 + ? { kind: 'box', key: `box${Math.floor(random() * 1e9)}`, width: 10 + Math.floor(random() * 40), rows: 1 + Math.floor(random() * 50) / 10, edgeLabel } + : { kind: 'leaf', text: randomText(), edgeLabel } + return { kind: 'node', label: randomText(), children: Array.from({ length: arity }, () => randomCallTree(depth + 1)), edgeLabel } +} +const rectsOf = (layout) => { + const byKey = new Map(layout.nodes.map((placed) => [placed.key, placed])) + const nodes = layout.nodes.map((placed) => ({ key: placed.key, left: placed.x - nodeWidth(placed.node) / 2, right: placed.x + nodeWidth(placed.node) / 2, top: placed.depth * ROW_HEIGHT, bottom: placed.depth * ROW_HEIGHT + (placed.node.kind === 'box' ? placed.rows * ROW_HEIGHT - (ROW_HEIGHT - NODE_HEIGHT) : NODE_HEIGHT) })) + const labels = layout.nodes.flatMap((placed) => { + const parent = byKey.get(placed.parentKey) + const place = parent ? edgeLabelPlacement(placed, parent) : null + return place ? [{ key: `label:${placed.key}`, ...place }] : [] + }) + return { nodes, labels } +} +const overlaps = (a, b) => a.left < b.right - 1e-9 && b.left < a.right - 1e-9 && a.top < b.bottom - 1e-9 && b.top < a.bottom - 1e-9 +let labelled = 0 +for (let index = 0; index < RANDOM_TREES; index++) { + const tree = randomCallTree(0) + compare(`random call tree ${index}`, tree) + const { nodes, labels } = rectsOf(layoutTree(tree)) + labelled += labels.length + for (const label of labels) { + for (const other of [...nodes, ...labels]) if (other !== label) assert.ok(!overlaps(label, other), `random call tree ${index}: ${label.key} overlaps ${other.key}`) + } +} +assert.ok(labelled > RANDOM_TREES, 'labels were checked') + // Not in the builds, nor in the notices. const maps = [ ...(existsSync('dist/index.js.map') ? ['dist/index.js.map'] : []), @@ -96,4 +134,5 @@ for (const map of maps) { } assert.ok(!/flextree|d3-hierarchy|WTFPL/i.test(readFileSync('THIRD_PARTY_NOTICES.md', 'utf8')), 'nothing of it in THIRD_PARTY_NOTICES.md') +console.log(`Tree layout checks passed (boxes and edge labels in ${RANDOM_TREES} call trees too)`) console.log(`Tree layout checks passed (${courseTrees.length} trees of the course, ${RANDOM_TREES} random ones; ${maps.length} source maps without d3).`) diff --git a/scripts/check-tree-mode.mjs b/scripts/check-tree-mode.mjs index 6d3c79c..943017a 100644 --- a/scripts/check-tree-mode.mjs +++ b/scripts/check-tree-mode.mjs @@ -24,6 +24,10 @@ const programs = { 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', + labels: 'def summe(x, y):\n return x + y\n\nsumme(2 + 2, 3 * 4)\n', + builtins: 'len([1, 2]) + max(2, 5)\n', + keywords: 'def teile(a, b=2, *rest):\n return a / b\n\nteile(b=4, a=8)\nteile(6)\nteile(*[9, 3])\n', + boxInPlace: 'def notenschnitt(noten):\n summe = sum(noten)\n anzahl = len(noten)\n return summe / anzahl\n\nnotenschnitt([4, 5, 4.5]) + 1\n', } const python = ` @@ -52,7 +56,8 @@ const check = (label, test) => { } } -const describeTree = (tree) => tree.kind === 'leaf' ? tree.text : `${tree.label}(${tree.children.map(describeTree).join(', ')})` +// A node with its edge label as "label:", a call box as "BOX". +const describeTree = (tree) => `${tree.edgeLabel ? `${tree.edgeLabel}:` : ''}${tree.kind === 'box' ? 'BOX' : 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)) { @@ -121,13 +126,13 @@ check('(2 + 3) * 4: only the shape differs, no 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(results.shortForm.shown[0].map((built) => describeTree(built.tree)).filter((text, index, all) => index === 0 || text !== all[index - 1]), ['quadrat(x:+(2, 1))', 'quadrat(x: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.equal(describeTree(outer[0].tree), 'notenschnitt(noten:[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']) }) @@ -136,5 +141,27 @@ check('[x, -x, 2]: nodes without a step become their value with their last child assert.equal(describeTree(results.stepless.shown[0].at(-1).tree), '[3, -3, 2]') }) +const shapes = (name, episode = 0) => results[name].shown[episode].map((built) => describeTree(built.tree)).filter((text, index, all) => index === 0 || text !== all[index - 1]) +check('summe(2 + 2, 3 * 4): the parameters on the edges until the call is replaced', () => { + assert.deepEqual(shapes('labels'), ['summe(x:+(2, 2), y:*(3, 4))', 'summe(x:4, y:*(3, 4))', 'summe(x:4, y:12)', '+(4, 12)', '16']) + assert.deepEqual(texts('labels'), chain('labels'), 'the text, as the chain') +}) +check('len([1, 2]) + max(2, 5): built-in functions get no labels', () => { + assert.equal(describeTree(results.builtins.shown[0][0].tree), '+(len([1, 2]), max(2, 5))') +}) +check('keywords and defaults: the parameter bound; none for *args or a default', () => { + assert.equal(describeTree(results.keywords.shown[0][0].tree), 'teile(b:4, a:8)', 'by keyword, no "b=" node') + assert.equal(describeTree(results.keywords.shown[1][0].tree), 'teile(a:6)', 'the default b has no edge') + assert.equal(describeTree(results.keywords.shown[2][0].tree), 'teile(*([9, 3]))', 'no labels after *') +}) +check('notenschnitt([4, 5, 4.5]) + 1: the box in the call node\'s place, 1 beside it', () => { + const shown = shapes('boxInPlace') + assert.deepEqual(shown, ['+(notenschnitt(noten:[4, 5, 4.5]), 1)', '+(BOX, 1)', '+(4.5, 1)', '5.5']) + const episode = treeModeSteps(results.boxInPlace.trace, substitutionModeSteps(results.boxInPlace.trace), new Map(Object.entries(results.boxInPlace.ast).map(([path, file]) => [path, indexAstNodes(file)]))).episodes[0] + const enter = results.boxInPlace.trace.find((step) => step.kind === 'call-enter') + const close = results.boxInPlace.trace.find((step) => step.closesFrameId === enter.frameId) + assert.ok(episode.expansions.includes(enter.step) && episode.expansions.includes(close.step), 'laid out again where the box opens and closes') +}) + if (failed > 0) process.exit(1) console.log('Tree mode checks passed.') diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index ce6076a..8494a82 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -11,7 +11,7 @@ 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 TreeLayout, type TreeNode, type ValueTree } from './treeLayout' +import { layoutTree, NODE_HEIGHT, ROW_HEIGHT, treeFromValue, type TreeLayout, type TreeNode, type ValueTree } from './treeLayout' import { activeTreesAt, buildExpressionTree, episodeTreeLayout, treeKeyOf, treeModeSteps, type TreeEpisode } from './treeMode' type CoreStrings = { @@ -913,16 +913,21 @@ function Frame(props: FrameProps) { // 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). + // calls) or draws as their boxes in their place. The boxes' sizes come + // from ExpressionTreeBox, which measures them. 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 + let expressionTree: ((boxSize: BoxSizes) => BuiltExpressionTree | null) | 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 buildWith = (boxSize: BoxSizes) => { + const withSizes = { ...context, boxSize } + const built = buildExpressionTree(withSizes, episode, uptoStep) + return built && { ...built, layout: episodeTreeLayout(withSizes, episode, uptoStep, built.tree) } + } + if (buildWith(() => undefined)) { + expressionTree = buildWith 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) @@ -1022,11 +1027,7 @@ function Frame(props: FrameProps) { ) 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 })))} - - ) + expressionTree && renderChild(frame, frame.frameId)} /> ) : segment.kind === 'child' ? renderChild(segment.frame!, segmentIndex) : segment.kind === 'group' ? ( @@ -1118,14 +1119,23 @@ 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 }) { +type BoxSizes = (key: string) => { width: number; rows: number } | undefined +type BuiltExpressionTree = { tree: TreeNode; layout: TreeLayout; text: string; boxes: OpenFrame[] } + +// The tree mode's tree in a code line, at the place of its expression. A +// call that stays a box is drawn in its node's place, as large as it is: +// its size is measured here (in characters and rows of the tree) and the +// tree laid out with it - only ever growing while the box is open, so +// stepping back and forth doesn't move the tree. The tree 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({ build, renderBoxFrame }: { build: (boxSize: BoxSizes) => BuiltExpressionTree | null; renderBoxFrame: (frame: OpenFrame) => ReactNode }) { const viewRef = useRef(null) const [maxWidth, setMaxWidth] = useState(null) + const [sizes, setSizes] = useState>(() => new Map()) + const [measureTick, setMeasureTick] = useState(0) + const built = build((key) => sizes.get(key)) useLayoutEffect(() => { const element = viewRef.current const container = element?.closest('.visualizer-measure') ?? element?.closest('.visualizer-view') @@ -1140,12 +1150,42 @@ function ExpressionTreeBox({ tree, layout, label, children }: { tree: TreeNode; observer.observe(container) return () => observer.disconnect() }, []) + // The boxes as drawn: a larger one is laid out again (right away, before + // the browser paints). After every render, as a box's content changes with + // each step; it sets state only when a box grew, and sizes only grow, so + // it settles. + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + const tree = viewRef.current?.querySelector(':scope > .value-tree-frame > .value-tree-scroll > .value-tree') + if (!tree) return + const boxes = [...tree.querySelectorAll(':scope > [data-tree-box-key]')] + if (boxes.length === 0) return + const em = parseFloat(getComputedStyle(tree).fontSize) + const probe = tree.querySelector(':scope > .value-tree-probe') + const ch = probe ? probe.offsetWidth / 10 : em * 0.6 + let next: Map | null = null + for (const box of boxes) { + const key = box.dataset.treeBoxKey! + const width = Math.ceil(box.offsetWidth / ch * 10) / 10 + const rows = Math.ceil((box.offsetHeight + (ROW_HEIGHT - NODE_HEIGHT) * em) / (ROW_HEIGHT * em) * 100) / 100 + const old = (next ?? sizes).get(key) + if (old && width <= old.width && rows <= old.rows) continue + next ??= new Map(sizes) + next.set(key, { width: Math.max(width, old?.width ?? 0), rows: Math.max(rows, old?.rows ?? 0) }) + } + if (next) setSizes(next) + const observer = new ResizeObserver(() => setMeasureTick((tick) => tick + 1)) + for (const box of boxes) observer.observe(box) + return () => observer.disconnect() + }) + void measureTick + if (!built) return null + const boxFrames = new Map(built.boxes.map((frame) => [`box:${frame.frameId}`, frame])) return ( - + { const frame = boxFrames.get(key); return frame ? renderBoxFrame(frame) : null }} /> - {children && {children}} ) } @@ -2502,8 +2542,12 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { useLayoutEffect(() => { const container = steadyMeasureRef.current if (!container || !steadyMeasureCurrent || steadyMeasure.pending.length === 0) return - for (const step of container.querySelectorAll('[data-measure-step]')) raiseSteadyHeight(step.offsetHeight) - const frame = requestAnimationFrame(() => setSteadyMeasure((measure) => ({ ...measure, pending: measure.pending.slice(STEADY_HEIGHT_BATCH) }))) + // Read a frame later: a tree mode's call box is measured and its tree + // laid out again in a render of its own first. + const frame = requestAnimationFrame(() => { + for (const step of container.querySelectorAll('[data-measure-step]')) raiseSteadyHeight(step.offsetHeight) + setSteadyMeasure((measure) => ({ ...measure, pending: measure.pending.slice(STEADY_HEIGHT_BATCH) })) + }) return () => cancelAnimationFrame(frame) // eslint-disable-next-line react-hooks/exhaustive-deps }, [steadyMeasure]) diff --git a/src/TreeView.tsx b/src/TreeView.tsx index ef6d74d..0f7056b 100644 --- a/src/TreeView.tsx +++ b/src/TreeView.tsx @@ -1,10 +1,6 @@ -import { useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react' -import { layoutTree, type PlacedNode, type TreeLayout, type TreeNode } from './treeLayout' +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react' +import { EDGE_LABEL_SIZE, edgeLabelPlacement, layoutTree, NODE_HEIGHT, nodeWidth, ROW_HEIGHT, type PlacedNode, type TreeLayout, type TreeNode } from './treeLayout' -// The height of one row of the tree, in em: a node (1.6em) and the room for -// the edges below it. -const ROW_HEIGHT = 2.8 -const NODE_HEIGHT = 1.6 // How far an arrow key scrolls a tree wider than its place, in pixels. const KEY_SCROLL_STEP = 40 @@ -25,12 +21,14 @@ function leafClass(text: string): string { } function nodeClass(placed: PlacedNode): string { - const base = placed.node.kind === 'node' ? 'value-tree-node' : leafClass(placed.node.text) + const base = placed.node.kind === 'node' ? 'value-tree-node' : placed.node.kind === 'box' ? 'value-tree-box' : leafClass(placed.node.text) return placed.node.className ? `${base} ${placed.node.className}` : base } +const textOf = (node: TreeNode) => node.kind === 'node' ? node.label : node.kind === 'leaf' ? node.text : '' + // What a tree looks like, to notice when it changes. -const signatureOf = (layout: TreeLayout) => layout.nodes.map((placed) => `${placed.key}=${placed.node.kind === 'node' ? placed.node.label : placed.node.text}@${placed.x},${placed.depth}.${placed.node.className ?? ''}`).join('|') +const signatureOf = (layout: TreeLayout) => layout.nodes.map((placed) => `${placed.key}=${textOf(placed.node)}:${placed.node.edgeLabel ?? ''}@${placed.x},${placed.depth},${nodeWidth(placed.node)}x${placed.rows}.${placed.node.className ?? ''}`).join('|') // A node that just left the tree (a subtree reduced to its value), moving // into the node that took its place while it fades out. @@ -48,8 +46,10 @@ type Ghost = { key: string; placed: PlacedNode; dx: number; dy: number } // `layout` places the nodes instead of laying out `tree` (the tree mode keeps // them where they stood); with `animate`, a changed tree moves there: // nodes glide to their new place, nodes that left move into their nearest -// remaining ancestor and fade, new ones fade in. -export function TreeView({ tree, label, className, layout: placedLayout, animate = false }: { tree: TreeNode; label: string; className?: string; layout?: TreeLayout; animate?: boolean }) { +// remaining ancestor and fade, new ones fade in. An edge label (a node's +// `edgeLabel`) sits just above the node, beside the edge's end. A 'box' node +// is drawn by `renderBox` (the tree mode's call box), as tall as its rows. +export function TreeView({ tree, label, className, layout: placedLayout, animate = false, renderBox }: { tree: TreeNode; label: string; className?: string; layout?: TreeLayout; animate?: boolean; renderBox?: (key: string) => ReactNode }) { const computedLayout = useMemo(() => placedLayout ? null : layoutTree(tree), [tree, placedLayout]) const signature = signatureOf(placedLayout ?? computedLayout!) // The same layout object while the tree looks the same, so the effects @@ -71,7 +71,7 @@ export function TreeView({ tree, label, className, layout: placedLayout, animate const before = new Map(previous.nodes.map((placed) => [placed.key, placed])) const left: Ghost[] = [] for (const placed of previous.nodes) { - if (now.has(placed.key)) continue + if (now.has(placed.key) || placed.node.kind === 'box') continue let target: PlacedNode | undefined for (let parentKey = placed.parentKey; parentKey !== null && !target; parentKey = before.get(parentKey)?.parentKey ?? null) target = now.get(parentKey) if (target) left.push({ key: placed.key, placed, dx: target.x - placed.x, dy: (target.depth - placed.depth) * ROW_HEIGHT }) @@ -79,7 +79,7 @@ export function TreeView({ tree, label, className, layout: placedLayout, animate setGhosts(left) setEntering(new Set(layout.nodes.filter((placed) => !before.has(placed.key)).map((placed) => placed.key))) }, [layout, animate]) - const height = layout.depth * ROW_HEIGHT - (ROW_HEIGHT - NODE_HEIGHT) + const height = layout.rows * ROW_HEIGHT - (ROW_HEIGHT - NODE_HEIGHT) const byKey = new Map(layout.nodes.map((placed) => [placed.key, placed])) const frameRef = useRef(null) const scrollRef = useRef(null) @@ -127,6 +127,8 @@ export function TreeView({ tree, label, className, layout: placedLayout, animate { scrolledRef.current = true }} onWheel={() => { scrolledRef.current = true }} onTouchStart={() => { scrolledRef.current = true }}> + {/* Ten characters of the tree's font, to measure a box in characters. */} + {layout.nodes.some((placed) => placed.node.kind === 'box') && } + {/* In the label's own (smaller) ch and em, hence the division. */} + {layout.nodes.map((placed) => { + const parent = placed.parentKey === null ? undefined : byKey.get(placed.parentKey) + const place = parent ? edgeLabelPlacement(placed, parent) : null + if (!place) return null + return + })} {ghosts.map((ghost) => ( -