diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9137ca3..ec1ec58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: # condition comments, readable types and argument checks, the doctest # runner and its coverage, and which errors get a hint. - run: npm run test:runtime + # 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 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 df17007..20bae3f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,9 +13,10 @@ npm run build # tsc -b && vite build && copy src/style.css -> dist/ (li npm run dev # vite build --watch, for use with npm link/yalc in a host shell npm run lint # oxlint npm run test:protocol # builds the library, then runs scripts/check-embed-protocol.mjs against dist/ -npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests,coverage,calls,hints,fidelity,replay,lists,…}.py +npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests,coverage,calls,hints,fidelity,replay,lists,main,trees,…}.py 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 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 ``` @@ -54,6 +55,7 @@ There is no unit test framework. The automated checks are `test:protocol` (plain - Columns: `ast` counts UTF-8 bytes, `tokenize` characters, the UI UTF-16 code units. Every offset into a line goes through `_glpython_text_before` (the text in front of it, for slicing) or `_glpython_ui_column` (a column for the UI); never slice a line by an `ast` offset directly. - Values are shown through `_glpython_safe_repr`, which writes types as learners do (`int`, not ``); the program's own output keeps Python's repr. - It abbreviates long values (`[1, 2, 3, 4, 5, …, 100]`; levels in `_GLPYTHON_ABBREVIATION_LEVELS`) and notes each full text. The next recorded step gets `fullValues` (`{shown text: index}`), the trace result `values` (`{text, cut, length, kind}`). The UI (`fullValuesUpTo`, `ValueText`, `LocalsValue`, `FullValueDialog`) opens one on click. + - Lists that read as a tree (`listtree`: first element a string): `session.py`'s `_glpython_tree_of` gives `{label, children}`/`{leaf}`, for the REPL's value (`tree`) and, noted like the full values, for the panel's values (step `trees`, result `trees`). The UI draws them with `TreeView` (`src/TreeView.tsx`: HTML nodes, SVG edges, a tree wider than its place scrolls with its root centred and hints at the hidden edges), behind capability `listTree`. The positions come from our port of van der Ploeg's tidy-tree algorithm (`src/treeLayout.ts`, in characters of the code font and in rows; every node one row high), with d3-flextree's changes; `test:layout` compares it with `d3-flextree`, which is a devDependency for that check only and must stay out of the builds. - A name indexed in a subscript (`dauer` in `dauer[1]`, `_glpython_indexed`) gets no step of its own, so `dauer[1]` is one step to the element. - A display (`[…]`, `(…)`, `{…}`) gets no step of its own (`_glpython_record_display`), only where its value reads differently (unpacking, duplicates); nor does a negative number literal (`_glpython_is_negative_number`), or `-name` where it already reads as its value. Every step must change something. diff --git a/README.md b/README.md index 4c5a88b..3ebf251 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,44 @@ variables panel. Texts: `visualizerShowFullValue`, `visualizerValueLength`, `visualizerFullValueTitle`, `visualizerFullValueCut`, `visualizerCloseFullValue`, all optional. +### Lists as trees (`listTree`) + +With the capability `listTree` (off by default; the embed's `features=listtree`), +a list that reads as a tree is drawn as one, below its text: a non-empty list +whose first element is a string is a node with that label, its other +elements are its children, a subtree where they read as a tree too, else a +leaf. `['+', ['var', 'x'], ['f', 1, 2]]`: + +``` + + + ╱ ╲ + var f + │ ╱ ╲ + 'x' 1 2 +``` + +Labels have no quotes, string leaves do. The rule is generic, so `['Anna', 'Ben']` +is drawn too; the text stays as it is. Where: +- **Variables panel:** below the value; a tree wider than the panel + (48 characters) has a link «Baum zeigen» (`showTree`) to the full-value + dialog instead. The options menu's «Listen als Baum zeigen» + (`visualizerListTrees`) switches the trees off and on. +- **Full-value dialog:** below the value's text. +- **REPL:** a value that reads as a tree has a «Baum» button (`replShowTree`) + next to «Visualisieren»; it shows the tree below the value and hides it + again. + +A tree of more than 100 nodes, deeper than 10 or containing itself isn't +drawn; long labels and leaves are shortened in their middle. For screen +readers each tree is an image described by the list's text. A tree wider +than its place (the console, the dialog, a narrow panel) scrolls sideways: +it starts with its root in the middle, a ‹ or › at an edge shows that more is +hidden there, and it takes the focus, so the arrow keys, Home and End scroll +it. The positions come from the package's own implementation of van der Ploeg's +tidy-tree algorithm for nodes of different widths (`src/treeLayout.ts`, +checked against [d3-flextree](https://github.com/Klortho/d3-flextree)), in +characters of the code font, so a tree scales with the text around it. + ### Copying the evaluation as a → chain The **Copy → chain** item in the toolbar's **⋯ Options** menu copies the evaluation of the statement @@ -396,8 +434,8 @@ 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`), which a missing `features` -doesn't; and `size=large` with +coverage after Tests (capability `coverage`), and `listtree` lists drawn as +trees (capability `listTree`), which a missing `features` doesn't; and `size=large` with `visualizerHeight=` shows bigger code and a visualizer at least that tall. The UI language is the optional `lang` query parameter (e.g. the LiaScript diff --git a/package-lock.json b/package-lock.json index e6bbe87..94c39bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,14 @@ "": { "name": "glpython-editor", "version": "0.1.0", + "license": "MIT", "devDependencies": { "@monaco-editor/react": "^4.7.0", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", + "d3-flextree": "2.1.2", "lucide-react": "^1.47.0", "oxlint": "^1.81.0", "playwright-core": "^1.48.2", @@ -1253,6 +1255,23 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-flextree": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/d3-flextree/-/d3-flextree-2.1.2.tgz", + "integrity": "sha512-gJiHrx5uTTHq44bjyIb3xpbmmdZcWLYPKeO9EPVOq8EylMFOiH2+9sWqKAiQ4DcFuOZTAxPOQyv0Rnmji/g15A==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "d3-hierarchy": "^1.1.5" + } + }, + "node_modules/d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", diff --git a/package.json b/package.json index 979abfb..3e8c328 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "test:protocol": "npm run build && node scripts/check-embed-protocol.mjs", "test:hints": "node scripts/check-error-hints.mjs", "test:console": "node scripts/check-console-browser.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", + "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", "prepare": "npm run build" }, "peerDependencies": { @@ -44,6 +45,7 @@ "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", + "d3-flextree": "2.1.2", "lucide-react": "^1.47.0", "oxlint": "^1.81.0", "playwright-core": "^1.48.2", diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index 745f117..dd62196 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -15,6 +15,12 @@ // A display of literals has no steps, in Ersetzung and Kasten (P40); every // step changes the code or the variables. // The run file is the module __main__ (P37), also in the REPL after it. +// With listtree, lists that read as a tree are drawn as one, in the +// variables, the full-value dialog and on request for a REPL value; nothing +// of it without. Their layout: no two nodes overlap, children in order below +// their parent, the parent centred over them. In a narrow embed (420px) a +// wide tree starts with its root in the middle, shows a hint at each edge +// where more is hidden, and scrolls with the keyboard. // // 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 @@ -160,17 +166,17 @@ try { // COOP/COEP, so the embed is cross-origin isolated and can wait, as the IDE. const isolated = { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp' } const language = 'def berechnen(text):\n return eval(text)\n\ndef repl():\n text = input("> ")\n if text == "ende":\n return\n print(berechnen(text))\n repl()\n' - const openEmbed = async (headers) => { + const openEmbed = async (headers, query = '', width = 760) => { const page = await browser.newPage({ viewport: { width: 800, height: 950 } }) - await page.route((url) => url.pathname === '/check-console.html', (route) => route.fulfill({ contentType: 'text/html', body: harness, headers })) - await page.goto(`${origin}/check-console.html`) + await page.route((url) => url.pathname === '/check-console.html', (route) => route.fulfill({ contentType: 'text/html', body: harness.replace('width:760px', `width:${width}px`), headers })) + await page.goto(`${origin}/check-console.html${query}`) await page.waitForFunction(() => window.ready, null, { timeout: 120000 }) return page } // The transcript as it reads, without the entries' Visualize buttons: next // to a ">>>" line or on a line of their own, depending on the width. const consoleText = (frame) => frame.locator('.console').evaluate((console) => { - const buttons = [...console.querySelectorAll('.repl-entry-visualize')] + const buttons = [...console.querySelectorAll('.repl-entry-visualize, .repl-entry-tree')] for (const button of buttons) button.style.display = 'none' const text = console.innerText for (const button of buttons) button.style.display = '' @@ -321,6 +327,189 @@ try { } await page.close() } + // listtree (P34): trees in the variables, the dialog and the REPL - and + // without the feature, none of it. + for (const query of ['?features=run,repl,visualize,listtree', '']) { + const label = query ? 'listtree' : 'listtree: not without it in features' + const page = await openEmbed({}, query) + const frame = page.frameLocator('#embed') + try { + await page.evaluate((source) => window.send('visualize', source), 'baum = ["+", ["var", "x"], ["f", 1, 2]]\ngross = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], ["liste", "anna", "ben", "carla", 12345]]]\nnamen = ["Anna", "Ben"]\n') + await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + await frame.locator('.visualizer-controls button').nth(3).click() + await frame.locator('.locals-row[data-name="namen"]').waitFor({ timeout: 30000 }) + const treeOf = (name) => frame.locator(`.locals-row[data-name="${name}"] .value-tree`) + const parts = (tree) => tree.locator('.value-tree-node, .value-tree-leaf').allInnerTexts() + await frame.getByRole('button', { name: 'Optionen' }).click() + const toggle = frame.getByRole('menuitemcheckbox', { name: 'Listen als Baum zeigen' }) + if (!query) { + assert.equal(await frame.locator('.value-tree, .locals-tree').count(), 0, 'no trees') + assert.equal(await toggle.count(), 0, 'no switch') + } else { + await page.keyboard.press('Escape') + assert.deepEqual(await parts(treeOf('baum')), ['+', 'var', "'x'", 'f', '1', '2'], 'the tree, labels without quotes') + assert.equal(await frame.locator('.locals-row[data-name="baum"] .value-tree-scroll').getAttribute('aria-label'), "['+', ['var', 'x'], ['f', 1, 2]]", 'its text alternative') + assert.equal(await frame.locator('.locals-row[data-name="baum"] .locals-value').innerText(), "['+', ['var', 'x'], ['f', 1, 2]]", 'the text stays') + // No two nodes overlap; each child is below its parent; the tree + // stays within the panel. + const boxes = await treeOf('baum').locator('.value-tree-node, .value-tree-leaf').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().toJSON())) + for (const [index, a] of boxes.entries()) for (const b of boxes.slice(index + 1)) assert.ok(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top, `nodes overlap: ${JSON.stringify([a, b])}`) + assert.ok(boxes[1].top > boxes[0].bottom, 'a child below its parent') + const row = await frame.locator('.locals-row[data-name="baum"]').boundingBox() + assert.ok(Math.max(...boxes.map((box) => box.right)) <= row.x + row.width, 'within the panel') + assert.deepEqual(await parts(treeOf('namen')), ['Anna', "'Ben'"], 'a list of names reads as a tree too') + // Too wide for the panel: in the dialog. + assert.equal(await treeOf('gross').count(), 0, 'a wide tree: not in the panel') + await frame.locator('.locals-row[data-name="gross"] .locals-tree-more').click() + const dialog = frame.getByRole('dialog') + assert.ok((await parts(dialog.locator('.value-tree'))).includes('liste'), 'a wide tree: in the dialog') + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'detached', timeout: 5000 }) + // The switch. + await frame.getByRole('button', { name: 'Optionen' }).click() + await toggle.click() + await frame.locator('.value-tree').first().waitFor({ state: 'detached', timeout: 5000 }) + if (!(await toggle.isVisible())) await frame.getByRole('button', { name: 'Optionen' }).click() + await toggle.click() + await treeOf('baum').waitFor({ timeout: 5000 }) + await page.keyboard.press('Escape') + } + // The REPL: a «Baum» button for a value that reads as a tree. + await frame.locator('.visualizer-exit').click() + await page.evaluate((source) => window.send('run', source), 'def parse(text):\n return ["+", 2, ["*", 3, 4]]\n') + await frame.locator('.console').getByText('(Keine Ausgabe)').waitFor({ timeout: 60000 }) + await enterRepl(frame, 'parse("2 + 3 * 4")') + await frame.locator('.repl-value').getByText("['+', 2, ['*', 3, 4]]").waitFor({ timeout: 30000 }) + const button = frame.getByRole('button', { name: 'Baum: parse("2 + 3 * 4")' }) + if (!query) { + assert.equal(await button.count(), 0, 'no «Baum» in the REPL') + } else { + assert.equal(await button.getAttribute('aria-pressed'), 'false', 'closed at first') + await button.click() + assert.deepEqual(await parts(frame.locator('.repl-tree .value-tree')), ['+', '2', '*', '3', '4'], 'the REPL value as a tree') + await button.click() + assert.equal(await frame.locator('.repl-tree').count(), 0, 'closed again') + await enterRepl(frame, '[1, 2]') + await frame.locator('.repl-value').getByText('[1, 2]', { exact: true }).waitFor({ timeout: 30000 }) + assert.equal(await frame.getByRole('button', { name: 'Baum: [1, 2]' }).count(), 0, 'no «Baum» for a list that is no tree') + } + console.log(`${label}: ok`) + } catch (error) { + failed += 1 + console.error(`${label}: ${error.message}`) + } + await page.close() + } + // The layout of trees as they come from a parser, and of uneven ones: no + // two nodes overlap, siblings in order from left to right, each parent + // above its children and centred over them (over the span from its first + // child's left edge to its last child's right edge, as d3-flextree does). + const treeProblems = (tree) => tree.evaluate((element) => { + const boxes = new Map([...element.querySelectorAll('[data-tree-key]')].map((node) => [node.dataset.treeKey, node.getBoundingClientRect()])) + const problems = [] + const entries = [...boxes] + for (const [index, [keyA, a]] of entries.entries()) for (const [keyB, b] of entries.slice(index + 1)) if (!(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top)) problems.push(`${keyA} overlaps ${keyB}`) + for (const [key, box] of boxes) { + const children = [] + for (let index = 0; boxes.has(key === '' ? String(index) : `${key}.${index}`); index++) children.push(boxes.get(key === '' ? String(index) : `${key}.${index}`)) + if (children.length === 0) continue + for (const [index, child] of children.entries()) { + if (child.top <= box.bottom) problems.push(`child ${index} of "${key}" not below it`) + if (index > 0 && child.left < children[index - 1].right) problems.push(`child ${index} of "${key}" not right of its sibling`) + } + const middle = (children[0].left + children.at(-1).right) / 2 + if (Math.abs((box.left + box.right) / 2 - middle) > 1) problems.push(`"${key}" not centred over its children`) + } + return problems + }) + const layoutTrees = [ + ['breit', "['f', ['g', 'erstes', 'zweites'], ['h', 'drittes', 'viertes'], ['k', 'fünftes']]"], + ['2 + 3 * 4', "['+', 2, ['*', 3, 4]]"], + ['(2 + 3) * 4', "['*', ['+', 2, 3], 4]"], + ['-(1 + 2)', "['-', ['+', 1, 2]]"], + ['x + 1', "['+', ['var', 'x'], 1]"], + ['f(1, 2)', "['f', 1, 2]"], + ['uneven, both sides', "['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]"], + ['uneven, to the left', "['+', ['+', ['+', 1, 2], 3], ['f', ['var', 'x']]]"], + ] + { + const page = await openEmbed({}, '?features=run,repl,visualize,listtree') + const frame = page.frameLocator('#embed') + try { + await page.evaluate((source) => window.send('run', source), 'pass\n') + await frame.locator('.console').getByText('(Keine Ausgabe)').waitFor({ timeout: 60000 }) + for (const [name, value] of layoutTrees) { + await enterRepl(frame, value) + await frame.getByRole('button', { name: `Baum: ${value}` }).click() + const tree = frame.locator('.repl-tree .value-tree').last() + assert.deepEqual(await treeProblems(tree), [], `the layout of ${name}`) + } + console.log('listtree: the layout: ok') + } catch (error) { + failed += 1 + console.error(`listtree: the layout: ${error.message}`) + } + await page.close() + } + // Narrow (420px): a tree wider than the console or the dialog starts with + // its root in the middle, hints where more is hidden, and the keyboard + // scrolls it (Home, End, arrows), the hints following. + { + const page = await openEmbed({}, '?features=run,repl,visualize,listtree', 420) + const frame = page.frameLocator('#embed') + const breit = layoutTrees[0][1] + const checkNarrow = async (where, scroll) => { + const state = () => scroll.evaluate((view) => { + const root = view.querySelector('[data-tree-key=""]').getBoundingClientRect() + const box = view.getBoundingClientRect() + const hints = [...view.parentElement.querySelectorAll('.value-tree-hint')].map((hint) => hint.classList.contains('value-tree-hint--left') ? 'left' : 'right') + return { offCentre: Math.abs((root.left + root.right) / 2 - (box.left + box.width / 2)), wider: view.scrollWidth > view.clientWidth, scrollLeft: view.scrollLeft, hints, focusable: view.tabIndex === 0 } + }) + const first = await state() + assert.ok(first.wider, `${where}: wider than its place`) + assert.ok(first.offCentre <= 2, `${where}: the root in the middle (${first.offCentre}px off)`) + assert.deepEqual(first.hints, ['left', 'right'], `${where}: a hint at both edges`) + assert.ok(first.focusable, `${where}: takes the focus`) + // A hint isn't a node: no border, over the tree's whole height, in a + // colour of none of the nodes. + const look = await scroll.evaluate((view) => { + const box = view.getBoundingClientRect() + const nodeColours = new Set([...view.querySelectorAll('[data-tree-key]')].map((node) => getComputedStyle(node).color)) + return [...view.parentElement.querySelectorAll('.value-tree-hint')].map((hint) => { + const style = getComputedStyle(hint) + const rect = hint.getBoundingClientRect() + return { border: style.borderTopStyle, fullHeight: Math.abs(rect.top - box.top) <= 1 && Math.abs(rect.bottom - box.bottom) <= 1, ownColour: !nodeColours.has(style.color) } + }) + }) + assert.deepEqual(look, [{ border: 'none', fullHeight: true, ownColour: true }, { border: 'none', fullHeight: true, ownColour: true }], `${where}: the hints don't look like nodes`) + await scroll.focus() + await scroll.press('Home') + await frame.locator('.value-tree-hint--left').waitFor({ state: 'detached', timeout: 5000 }) + assert.deepEqual((await state()).hints, ['right'], `${where}: at the left end, the right hint only`) + await scroll.press('ArrowRight') + assert.ok((await state()).scrollLeft > 0, `${where}: → scrolls`) + await scroll.press('End') + await frame.locator('.value-tree-hint--right').waitFor({ state: 'detached', timeout: 5000 }) + assert.deepEqual((await state()).hints, ['left'], `${where}: at the right end, the left hint only`) + } + try { + await page.evaluate((source) => window.send('run', source), `breit = ${breit}\n`) + await frame.locator('.console').getByText('(Keine Ausgabe)').waitFor({ timeout: 60000 }) + await enterRepl(frame, 'breit') + await frame.getByRole('button', { name: 'Baum: breit' }).click() + await checkNarrow('the console', frame.locator('.repl-tree .value-tree-scroll')) + await page.evaluate((source) => window.send('visualize', source), `breit = ${breit}\n`) + await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + await frame.locator('.visualizer-controls button').nth(3).click() + await frame.locator('.locals-row[data-name="breit"] .locals-tree-more').click() + await checkNarrow('the dialog', frame.getByRole('dialog').locator('.value-tree-scroll')) + console.log('listtree: narrow: ok') + } catch (error) { + failed += 1 + console.error(`listtree: narrow: ${error.message}`) + } + await page.close() + } // P37: the run file is __main__, in Pyodide too. { const page = await openEmbed({}) diff --git a/scripts/check-runtime-trees.py b/scripts/check-runtime-trees.py new file mode 100644 index 0000000..1d7902e --- /dev/null +++ b/scripts/check-runtime-trees.py @@ -0,0 +1,87 @@ +"""Regression test: lists that read as a tree (P34, "listtree"). A non-empty +list whose first element is a string is a node labelled with that string; +its other elements are subtrees where they read as a tree too, else leaves +(their repr). The REPL's value and the visualizer's variables carry the tree +for the UI to draw; too big a tree, one that contains itself and any other +value carry none. + +Runs the runtime with CPython (no Pyodide needed): + python3 -B scripts/check-runtime-trees.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 = [] + + +def expect(label, got, expected): + if got != expected: + failures.append(f"{label}: got {got!r}, expected {expected!r}") + + +tree_of = runtime["_glpython_tree_of"] + + +def node(label, *children): + return {"label": label, "children": list(children)} + + +def leaf(text): + return {"leaf": text} + + +# The rule, generic: the course's trees and anything else of that shape. +for value, expected in [ + (["+", 2, ["*", 3, 4]], node("+", leaf("2"), node("*", leaf("3"), leaf("4")))), + (["+", ["var", "x"], ["f", 1, 2]], node("+", node("var", leaf("'x'")), node("f", leaf("1"), leaf("2")))), + (["+", "a", "b"], node("+", leaf("'a'"), leaf("'b'"))), + (["-", ["+", 1, 2]], node("-", node("+", leaf("1"), leaf("2")))), + (["liste", 1, 2], node("liste", leaf("1"), leaf("2"))), + (["f"], node("f")), + (["Anna", "Ben"], node("Anna", leaf("'Ben'"))), + # Children that don't read as a tree are leaves, whatever they are. + (["f", [1, 2], [], True, None, -2.5, ("a", 1), {"k": 1}], node("f", leaf("[1, 2]"), leaf("[]"), leaf("True"), leaf("None"), leaf("-2.5"), leaf("('a', 1)"), leaf("{'k': 1}"))), + (["+", [3, "x"]], node("+", leaf("[3, 'x']"))), + # Long texts shortened in their middle. + (["a" * 30, "b" * 30], node("a" * 17 + "…" + "a" * 6, leaf("'" + "b" * 16 + "…" + "b" * 5 + "'"))), +]: + expect(f"tree of {value!r:.40}", tree_of(value), expected) + +# Not a tree: other values, and trees too big or containing themselves. +looped = ["f", 1] +looped.append(looped) +deep = ["x"] +for _ in range(12): + deep = ["x", deep] +for value in ([], [1, "+", 2], ("+", 1, 2), "+", 3, None, {"+": 1}, ["+"] + [["n", i] for i in range(60)], looped, deep): + expect(f"no tree: {value!r:.40}", tree_of(value), None) +expect("a tree just within the limits", tree_of(["+"] + [["n", i] for i in range(49)]) is not None, True) + +# The REPL's value carries its tree. +expect("REPL: a tree", runtime["_glpython_repl_value"](["+", 2, 3]), {"value": "['+', 2, 3]", "tree": node("+", leaf("2"), leaf("3"))}) +expect("REPL: no tree", runtime["_glpython_repl_value"]([1, 2]), {"value": "[1, 2]"}) + +# The visualizer: a step's variables name their trees, each stored once. +result = json.loads(runtime["_glpython_trace_program"]("main.py", {"main.py": 'baum = ["+", 2, ["*", 3, 4]]\nkopie = baum\nzahlen = [1, 2]\n'}, None)) +text = "['+', 2, ['*', 3, 4]]" +expect("visualizer: the trees", result["trees"], [node("+", leaf("2"), node("*", leaf("3"), leaf("4")))]) +last = result["trace"][-1] +expect("visualizer: the last step", (last["locals"]["baum"], last.get("trees")), (text, {text: 0})) +expect("visualizer: no full value for it", result["values"], []) +result = json.loads(runtime["_glpython_trace_program"]("main.py", {"main.py": "zahlen = [1, 2]\nnamen = ('a', 'b')\n"}, None)) +expect("visualizer: no trees", (result["trees"], [step.get("trees") for step in result["trace"] if step.get("trees")]), ([], [])) + +if failures: + print("\n".join(failures)) + sys.exit(f"{len(failures)} tree check(s) failed.") +print("Runtime tree checks passed.") diff --git a/scripts/check-tree-layout.mjs b/scripts/check-tree-layout.mjs new file mode 100644 index 0000000..45407e1 --- /dev/null +++ b/scripts/check-tree-layout.mjs @@ -0,0 +1,99 @@ +// Check of the trees' layout (src/treeLayout.ts, P34): our port of van der +// Ploeg's algorithm places every node where d3-flextree, a published +// implementation of the same paper, places it - on the trees of the course's +// parser, two uneven ones and a few hundred random trees (0 to 4 children, +// depth up to 5, labels 1 to 12 characters, a fixed seed). d3-flextree is a +// devDependency for this check only: nothing of it (or of d3-hierarchy) may +// be in the builds, which this checks in the builds' source maps where they +// exist (CI builds first). +// +// npm run test:layout +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' + +const { flextree } = createRequire(import.meta.url)('d3-flextree') +// The same gap between neighbouring nodes as treeLayout.ts. +const SIBLING_GAP = 2 +const TOLERANCE = 1e-6 + +// A list as the runtime sends it (_glpython_tree_of), as a TreeNode. +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) } + +// flextree's positions, moved so that the tree's left edge is at 0, by key. +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 root = layout.hierarchy(tree) + layout(root) + let left = Infinity + let right = -Infinity + root.each((node) => { + left = Math.min(left, node.x - node.xSize / 2) + 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}`)) + } + visit(root, '') + return { positions, width: right - left } +} + +function compare(name, tree) { + const ours = layoutTree(tree) + const theirs = oracle(tree) + assert.equal(ours.nodes.length, theirs.positions.size, `${name}: the number of nodes`) + assert.ok(Math.abs(ours.width - theirs.width) <= TOLERANCE, `${name}: width ${ours.width}, flextree ${theirs.width}`) + for (const node of ours.nodes) { + const expected = theirs.positions.get(node.key) + assert.ok(expected && Math.abs(node.x - expected.x) <= TOLERANCE && node.depth === expected.depth, `${name}: node "${node.key}" at ${node.x}/${node.depth}, flextree ${expected?.x}/${expected?.depth}`) + } + return ours.width +} + +const courseTrees = [ + ['breit', ['f', ['g', 'erstes', 'zweites'], ['h', 'drittes', 'viertes'], ['k', 'fünftes']], 62], + ['parse("2 + 3 * 4")', ['+', 2, ['*', 3, 4]], 10.5], + ['parse("(2 + 3) * 4")', ['*', ['+', 2, 3], 4], 10.5], + ['parse("-(1 + 2)")', ['-', ['+', 1, 2]], 8], + ['parse("x + 1")', ['+', ['var', 'x'], 1], 10], + ['parse("f(1, 2)")', ['f', 1, 2], 8], + ['uneven, both sides', ['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]], 23], + ['uneven, to the left', ['+', ['+', ['+', 1, 2], 3], ['f', ['var', 'x']]], 17.5], +] +for (const [name, value, width] of courseTrees) assert.equal(compare(name, treeOf(value)), width, `${name}: the width measured for P34`) + +// Random trees, the same ones every time (mulberry32). +let seed = 34 +const random = () => { + seed = (seed + 0x6d2b79f5) | 0 + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 +} +const randomText = () => 'x'.repeat(1 + Math.floor(random() * 12)) +const randomTree = (depth) => { + const arity = depth >= 5 ? 0 : Math.floor(random() * 5) + return arity === 0 && random() < 0.5 + ? { kind: 'leaf', text: randomText() } + : { kind: 'node', label: randomText(), children: Array.from({ length: arity }, () => randomTree(depth + 1)) } +} +const RANDOM_TREES = 500 +for (let index = 0; index < RANDOM_TREES; index++) compare(`random tree ${index}`, randomTree(0)) + +// Not in the builds, nor in the notices. +const maps = [ + ...(existsSync('dist/index.js.map') ? ['dist/index.js.map'] : []), + ...(existsSync('dist-embed/assets') ? readdirSync('dist-embed/assets').filter((name) => name.endsWith('.js.map')).map((name) => `dist-embed/assets/${name}`) : []), +] +for (const map of maps) { + const bundled = JSON.parse(readFileSync(map, 'utf8')).sources.filter((source) => /node_modules\/(d3-|@types\/d3)/.test(source)) + assert.deepEqual(bundled, [], `${map}: nothing of d3-flextree or d3-hierarchy`) +} +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 (${courseTrees.length} trees of the course, ${RANDOM_TREES} random ones; ${maps.length} source maps without d3).`) diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index 0616797..abc170c 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -5,11 +5,13 @@ 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, 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 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' type CoreStrings = { exitVisualizer: string @@ -143,6 +145,11 @@ type CoreStrings = { visualizerFullValueTitle?: (name: string | null) => string visualizerFullValueCut?: string visualizerCloseFullValue?: string + // listtree: the options menu's switch, the panel's link for a tree too + // wide for it, and a REPL value's button. + visualizerListTrees?: string + showTree?: string + replShowTree?: string inputDialogFallbackPrompt: string inputPlaceholder: string inputCancel: string @@ -175,6 +182,11 @@ type WorkspaceCapabilities = { // A ✕ on each file tab (closing it). Off when the host shows every file of // the project as a tab, as the IDE does. closeFileTabs: boolean + // Lists that read as a tree (["+", 2, ["*", 3, 4]]: a first element that is + // a string) drawn as one too, below their text: in the visualizer's + // variables and full-value dialog (with a switch in its options menu), and + // on request («Baum») for a REPL input's value. + listTree: boolean } const defaultWorkspaceCapabilities: WorkspaceCapabilities = { @@ -192,6 +204,7 @@ const defaultWorkspaceCapabilities: WorkspaceCapabilities = { coverage: false, emptyOutput: true, closeFileTabs: true, + listTree: false, } type WorkspaceCommand = { @@ -311,7 +324,7 @@ function storedVisualizerMode(): VisualizerMode { type ReplEntry = { id: number source: string - result: { output: string; value?: string; untraced?: boolean; error?: string; errorHint?: ErrorHint | null; hint?: 'definition' | null; unparsed?: boolean } | null + result: { output: string; value?: string; tree?: ValueTree; untraced?: boolean; error?: string; errorHint?: ErrorHint | null; hint?: 'definition' | null; unparsed?: boolean } | null note?: ReplReplayNote // What the input has printed so far, while it still runs. live?: string @@ -494,7 +507,9 @@ function TextSegment({ segment, monaco }: { segment: Extract; open: (value: FullValue, name: string | null) => void; t: CoreStrings } +// With listtree, also the trees of the panel's values (see valueTreesUpTo), +// `showTrees` while its switch is on. +type FullValueAccess = { values: Map; trees: Map; showTrees: boolean; open: (value: FullValue, name: string | null, tree?: ValueTree) => void; t: CoreStrings } const FullValueContext = createContext(null) // A loop variable's annotation reads "=[1, 2, …, 100]". @@ -532,14 +547,25 @@ function fullValueLength(value: FullValue, t: CoreStrings): string | null { function LocalsValue({ name, value, className }: { name: string; value: string; className: string }) { const access = useContext(FullValueContext) const full = lookupFullValue(access, value) - if (!access || !full) return {value} - const open = () => access.open(full, name) - const length = fullValueLength(full, access.t) - return openOnKey(event, open)}>{value}{length && {length}} + const tree = access?.showTrees ? access.trees.get(value) : undefined + const treeNode = useMemo(() => tree ? treeFromValue(tree) : null, [tree]) + const text = !access || !full + ? {value} + : access.open(full, name, tree)} onKeyDown={(event) => openOnKey(event, () => access.open(full, name, tree))}>{value}{fullValueLength(full, access.t) && {fullValueLength(full, access.t)}} + if (!access || !tree || !treeNode) return text + // A tree too wide for the panel opens in the dialog instead. + const openTree = () => access.open(full ?? { text: value, cut: false, length: null, kind: null }, name, tree) + return <>{text}{layoutTree(treeNode).width <= LOCALS_TREE_WIDTH + ? + : openOnKey(event, openTree)}>{access.t.showTree ?? 'Show tree'}} } +// The widest tree the variables panel shows itself, in characters. +const LOCALS_TREE_WIDTH = 48 + // A long value in full, readable and scrollable. -function FullValueDialog({ value, name, t, onClose }: { value: FullValue; name: string | null; t: CoreStrings; onClose: () => void }) { +function FullValueDialog({ value, name, tree, t, onClose }: { value: FullValue; name: string | null; tree?: ValueTree; t: CoreStrings; onClose: () => void }) { + const treeNode = useMemo(() => tree ? treeFromValue(tree) : null, [tree]) const closeRef = useRef(null) useEffect(() => { const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null @@ -554,6 +580,7 @@ function FullValueDialog({ value, name, t, onClose }: { value: FullValue; name:
{title}{length && {length}}
{value.text}{value.cut && '\u2026'}
{value.cut &&

{t.visualizerFullValueCut ?? 'Only the beginning of this value is shown.'}

} + {treeNode &&
} ) @@ -1003,15 +1030,22 @@ function ErrorHintNote({ hint, t, context = 'other' }: { hint: ErrorHint | null // `onVisualize` is set when the entry can be visualized: it ran (an error // included, which the visualization ends with), and it compiled; `busy` // while Python is running something. -function ReplTranscriptEntry({ entry, definitionHint, t, onVisualize, busy }: { entry: ReplEntry; definitionHint: string; t: CoreStrings; onVisualize?: () => void; busy: boolean }) { +// With `listTree`, a value that reads as a tree has a «Baum» button that +// shows the tree below it. +function ReplTranscriptEntry({ entry, definitionHint, t, onVisualize, busy, listTree }: { entry: ReplEntry; definitionHint: string; t: CoreStrings; onVisualize?: () => void; busy: boolean; listTree: boolean }) { const result = entry.result const printed = (result ? result.output : entry.live ?? '').replace(/\n$/, '') const visualizeLabel = t.replVisualizeEntry ?? 'Visualize' + const treeLabel = t.replShowTree ?? 'Tree' + const [isTreeShown, setIsTreeShown] = useState(false) + const tree = listTree && result?.value !== undefined ? result.tree : undefined + const treeNode = useMemo(() => tree ? treeFromValue(tree) : null, [tree]) return ( - {'>>> '}{entry.source}{onVisualize && } + {'>>> '}{entry.source}{treeNode && }{onVisualize && } {printed && {printed}} {result?.value !== undefined && {result.value}} + {treeNode && isTreeShown && } {result?.error && {result.error}} {result?.hint === 'definition' && {definitionHint}} @@ -1077,7 +1111,9 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { module: t.valueKindModule ?? 'module', }), [t]) // The abbreviated value shown in full (FullValueDialog), with its variable's name. - const [fullValueView, setFullValueView] = useState<{ value: FullValue; name: string | null } | null>(null) + const [fullValueView, setFullValueView] = useState<{ value: FullValue; name: string | null; tree?: ValueTree } | null>(null) + // The options menu's «Listen als Baum zeigen» (with listTree). + const [showListTrees, setShowListTrees] = useState(true) // While a failing doctest example is visualized: the texts doctest compared. const [exampleExpectation, setExampleExpectation] = useState<{ got: string; want: string } | null>(null) const [isTesting, setIsTesting] = useState(false) @@ -1163,7 +1199,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // Starts Python in a new worker, at mount and after a stop. const startWorker = () => { const worker = new Worker(new URL('./pyodide-worker.js', import.meta.url)) - worker.onmessage = (event: MessageEvent } & Partial>) => { + worker.onmessage = (event: MessageEvent } & Partial>) => { const currentTranslation = stringsRef.current // After a stop the console keeps its transcript. if (event.data.type === 'ready') { if (!keepConsoleOnReadyRef.current) setOutput(currentTranslation.readyOutput); keepConsoleOnReadyRef.current = false; setRuntimeState('ready') } @@ -1198,8 +1234,8 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { setIsTesting(false) } if (event.data.type === 'repl-result') { - const { id = -1, output = '', value, untraced, error, errorHint, hint, unparsed, graphics } = event.data - const result = { output, value, untraced, error: error ?? undefined, errorHint, hint, unparsed } + const { id = -1, output = '', value, tree, untraced, error, errorHint, hint, unparsed, graphics } = event.data + const result = { output, value, tree, untraced, error: error ?? undefined, errorHint, hint, unparsed } // A visualized doctest example that doesn't parse comes back here // too, and has no transcript entry yet. setConsoleEntries((entries) => entries.some((entry) => entry.kind === 'repl' && entry.id === id) @@ -1528,9 +1564,11 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const currentTraceStep = visualTraceSteps[clampedStepIndex] ?? null const fullValueAccess = useMemo(() => ({ values: fullValuesUpTo(visualTrace?.trace ?? [], currentTraceStep?.step ?? -1, visualTrace?.values), - open: (value, name) => setFullValueView({ value, name }), + trees: workspaceCapabilities.listTree ? valueTreesUpTo(visualTrace?.trace ?? [], currentTraceStep?.step ?? -1, visualTrace?.trees) : new Map(), + showTrees: workspaceCapabilities.listTree && showListTrees, + open: (value, name, tree) => setFullValueView({ value, name, tree: workspaceCapabilities.listTree && showListTrees ? tree : undefined }), t, - }), [visualTrace, currentTraceStep, t]) + }), [visualTrace, currentTraceStep, t, workspaceCapabilities.listTree, showListTrees]) // The step that will run next if the learner clicks "next" - previewed // via highlighting its AST span, including before any step has run yet // (clampedStepIndex === -1), so the very first statement is shown too. @@ -2218,11 +2256,11 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const testsFromActions = () => { if (isVisualizing) exitVisualizer(); runTests() } const actionsNode = workspaceCapabilities.actions ?
{workspaceCapabilities.tests && }{workspaceCapabilities.visualization && }{workspaceCapabilities.resetOutput && }
: null const breadcrumbsNode =
{project.name}/{selectedFile.path}
- const visualizerBarNode =
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
+ const visualizerBarNode =
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.listTree && }{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
const visualizerWarningNode = <>{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
} const editorTabsNode =
{openFiles.map((path) => { const file = project.files[path]; return })}
const editorWrapNode =
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} onMount={setNormalEditor} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false, placeholder: t.editorPlaceholder ?? 'Type code here', unicodeHighlight: EDITOR_UNICODE_HIGHLIGHT, readOnly: selectedFile.readOnly === true, domReadOnly: selectedFile.readOnly === true, readOnlyMessage: { value: t.readOnlyFile ?? 'Read-only: this file can be read but not changed.' } }} />}
- const consoleNode = <>{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ?
: entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}{answeredInputLines.length > 0 &&
{answeredInputLines.join('\n')}
}
{(workspaceCapabilities.repl || programInput) &&
{ event.preventDefault(); if (programInput) respondToInput(false); else submitRepl() }}> { if (programInput) { setInputValue(event.target.value) } else { setReplInput(event.target.value); setReplHistoryIndex(null) } }} onKeyDown={handleReplKeyDown} aria-label={programInput ? `${t.programInputLabel ?? 'Input for the program'}${inputRequest ? `: ${inputRequest}` : ''}` : t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" autoCorrect="off" enterKeyHint="send" />
}
} + const consoleNode = <>{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ?
: entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}{answeredInputLines.length > 0 &&
{answeredInputLines.join('\n')}
}
{(workspaceCapabilities.repl || programInput) &&
{ event.preventDefault(); if (programInput) respondToInput(false); else submitRepl() }}> { if (programInput) { setInputValue(event.target.value) } else { setReplInput(event.target.value); setReplHistoryIndex(null) } }} onKeyDown={handleReplKeyDown} aria-label={programInput ? `${t.programInputLabel ?? 'Input for the program'}${inputRequest ? `: ${inputRequest}` : ''}` : t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" autoCorrect="off" enterKeyHint="send" />
}
} // In the stacked layout the graphics show only while the program draws - // for a visualization: if it draws at all, so the panel doesn't appear in // the middle of stepping. Its ✕ hides it until the next drawing. @@ -2247,7 +2285,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { return (
{compact ?
{breadcrumbsNode}{isVisualizing || !actionsNode ?
: actionsNode}
{isVisualizing && visualizerBarNode}{visualizerWarningNode}{(workspaceCapabilities.editor || isVisualizing) &&
{editorTabsNode}{editorWrapNode}
}{consoleNode}
{isGraphicsOpen ? : }
: <>
{!actionsContainer && (actionsNode || workspaceCapabilities.breadcrumbs) &&
{workspaceCapabilities.breadcrumbs && breadcrumbsNode}{actionsNode}
}
{isVisualizing ? visualizerBarNode : workspaceCapabilities.fileTabs ? editorTabsNode : null}
{visualizerWarningNode}{editorWrapNode}
{stackedGraphicsNode}
{consoleNode}
{actionsContainer && actionsNode && createPortal(actionsNode, actionsContainer)}} - {isVisualizing && fullValueView && setFullValueView(null)} />} + {isVisualizing && fullValueView && setFullValueView(null)} />} {inputRequest !== null && !programInput &&
respondToInput(true)}>
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); respondToInput(false) }}>
{t.inputDialogTitle}

{inputRequest || t.inputDialogFallbackPrompt}

setInputValue(event.target.value)} placeholder={t.inputPlaceholder} />
}
) diff --git a/src/TreeView.tsx b/src/TreeView.tsx new file mode 100644 index 0000000..f944dfd --- /dev/null +++ b/src/TreeView.tsx @@ -0,0 +1,104 @@ +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react' +import { layoutTree, 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 + +// The colour behind the tree, for the hints' fade: the first ancestor's +// background that isn't transparent. +function backgroundBehind(element: HTMLElement): string | null { + for (let current = element.parentElement; current; current = current.parentElement) { + const colour = getComputedStyle(current).backgroundColor + if (colour && colour !== 'transparent' && !/^rgba\(.*,\s*0\)$/.test(colour)) return colour + } + return null +} + +function leafClass(text: string): string { + if (/^['"]/.test(text)) return 'value-tree-leaf value-tree-leaf--string' + if (/^-?\d/.test(text)) return 'value-tree-leaf value-tree-leaf--number' + return 'value-tree-leaf' +} + +// A small tree, drawn top-down (see layoutTree): inner nodes with their +// label, leaves with their text. Positions are in characters of the code +// font and in em, so the tree scales with the text around it. For screen +// readers it is an image described by `label` (e.g. the list's own text). +// A tree wider than its place scrolls sideways: it starts with its root in +// the middle (in an expression tree, the operation applied last), a hint at +// each edge shows that more is hidden there (a fade into the background +// with an arrow, over the tree's whole height, so it doesn't read as a +// node), and it takes the focus, so the arrow keys scroll it. +export function TreeView({ tree, label, className }: { tree: TreeNode; label: string; className?: string }) { + const layout = useMemo(() => layoutTree(tree), [tree]) + const height = layout.depth * 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) + const [fade, setFade] = useState(null) + const rootRef = useRef(null) + const scrolledRef = useRef(false) + const [hidden, setHidden] = useState({ left: false, right: false }) + const updateHidden = useCallback(() => { + const view = scrollRef.current + if (!view) return + const left = view.scrollLeft > 1 + const right = view.scrollLeft + view.clientWidth < view.scrollWidth - 1 + setHidden((previous) => (previous.left === left && previous.right === right ? previous : { left, right })) + }, []) + // The root in the middle - again when the place changes size, until the + // tree has been scrolled. + useLayoutEffect(() => { + const view = scrollRef.current + const root = rootRef.current + if (!view || !root) return + scrolledRef.current = false + const centre = () => { + if (!scrolledRef.current) view.scrollLeft = root.offsetLeft + root.offsetWidth / 2 - view.clientWidth / 2 + updateHidden() + } + centre() + if (frameRef.current) setFade(backgroundBehind(frameRef.current)) + const observer = new ResizeObserver(centre) + observer.observe(view) + return () => observer.disconnect() + }, [layout, updateHidden]) + const onScroll = () => { + updateHidden() + } + const onKeyDown = (event: KeyboardEvent) => { + const view = event.currentTarget + const target = { ArrowLeft: view.scrollLeft - KEY_SCROLL_STEP, ArrowRight: view.scrollLeft + KEY_SCROLL_STEP, Home: 0, End: view.scrollWidth }[event.key] + if (target === undefined) return + event.preventDefault() + scrolledRef.current = true + view.scrollLeft = target + } + const overflows = hidden.left || hidden.right + return ( + + { scrolledRef.current = true }} onWheel={() => { scrolledRef.current = true }} onTouchStart={() => { scrolledRef.current = true }}> + + + {layout.nodes.map((placed) => ( + + ))} + + + {hidden.left && } + {hidden.right && } + + ) +} diff --git a/src/embed/App.tsx b/src/embed/App.tsx index 821cc68..457e41e 100644 --- a/src/embed/App.tsx +++ b/src/embed/App.tsx @@ -39,8 +39,8 @@ function createEmbedProject(source: string, files?: GlPythonFile[], mainFile?: s // Optional "?features=run,repl,visualize,…" from the macro's features= // option: which of the embed's own parts to show (here: the REPL, the // visualizer and the → chain export). Without it, everything shows - except -// "coverage" (after Tests, what no example executed), which a course switches -// on explicitly. +// "coverage" (after Tests, what no example executed) and "listtree" (lists +// that read as a tree drawn as one), which a course switches on explicitly. const featureList = (() => { const list = new URLSearchParams(window.location.search).get('features') return list === null ? null : new Set(list.split(',').map((feature) => feature.trim())) @@ -204,6 +204,7 @@ export function App() { repl: mode === 'eval' && features('repl'), export: features('export'), coverage: featureList?.has('coverage') ?? false, + listTree: featureList?.has('listtree') ?? false, emptyOutput: mode !== 'visual', }} /> diff --git a/src/i18n.ts b/src/i18n.ts index f6631e9..7fe1bb7 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -35,6 +35,9 @@ export const coreStrings: Record = { visualizerFullValueTitle: (name) => name ?? 'Value', visualizerFullValueCut: 'Only the beginning of this value is shown: it is too long.', visualizerCloseFullValue: 'Close', + visualizerListTrees: 'Show lists as trees', + showTree: 'Show tree', + replShowTree: 'Tree', visualizerCollapsedCalls: (count, call) => `… ${count} more calls${call ? ` of ${call}` : ''} …`, replInputLabel: 'Python input', replDefinitionHint: 'Definitions belong in the file.', @@ -119,6 +122,9 @@ export const coreStrings: Record = { visualizerFullValueTitle: (name) => name ?? 'Wert', visualizerFullValueCut: 'Nur der Anfang dieses Werts wird gezeigt: Er ist zu lang.', visualizerCloseFullValue: 'Schliessen', + visualizerListTrees: 'Listen als Baum zeigen', + showTree: 'Baum zeigen', + replShowTree: 'Baum', visualizerCollapsedCalls: (count, call) => `… ${count} weitere Aufrufe${call ? ` von ${call}` : ''} …`, replInputLabel: 'Python-Eingabe', replDefinitionHint: 'Definitionen gehören in die Datei.', diff --git a/src/runtime/session.py b/src/runtime/session.py index 991ba69..3af06bf 100644 --- a/src/runtime/session.py +++ b/src/runtime/session.py @@ -164,7 +164,63 @@ def _glpython_repl_value(value): text = repr(value) if len(text) > _GLPYTHON_REPL_MAX_REPR: text = text[:_GLPYTHON_REPL_MAX_REPR] + "…" - return {"value": text} + result = {"value": text} + tree = _glpython_tree_of(value) + if tree is not None: + result["tree"] = tree + return result + +# A list that reads as a tree, for the UI to draw ("listtree"): a non-empty +# list whose first element is a string is a node labelled with that string +# (["+", 2, ["*", 3, 4]]); its other elements are its children - a subtree +# where they read as a tree too, else a leaf: their repr ("2", "'a'", +# "[1, 2]"). As JSON: {"label": "+", "children": [...]} and {"leaf": "2"}. +# Long texts are shortened in their middle. A tree of more than +# _GLPYTHON_TREE_MAX_NODES nodes, deeper than _GLPYTHON_TREE_MAX_DEPTH or +# containing itself has none (None), as has any other value. +_GLPYTHON_TREE_MAX_NODES = 100 +_GLPYTHON_TREE_MAX_DEPTH = 10 +_GLPYTHON_TREE_TEXT_LIMIT = 24 + +class _GlpythonTreeTooBig(Exception): + pass + +def _glpython_reads_as_tree(value): + return type(value) is list and len(value) > 0 and type(value[0]) is str + +def _glpython_tree_text(text): + if len(text) <= _GLPYTHON_TREE_TEXT_LIMIT: + return text + tail = _GLPYTHON_TREE_TEXT_LIMIT // 4 + return text[: _GLPYTHON_TREE_TEXT_LIMIT - tail - 1] + "…" + text[-tail:] + +def _glpython_tree_of(value): + if not _glpython_reads_as_tree(value): + return None + count = [0] + active = set() + + def build(item, depth): + count[0] += 1 + if count[0] > _GLPYTHON_TREE_MAX_NODES or depth > _GLPYTHON_TREE_MAX_DEPTH: + raise _GlpythonTreeTooBig() + if not _glpython_reads_as_tree(item): + try: + text = repr(item) + except Exception: + text = "" + return {"leaf": _glpython_tree_text(text)} + if id(item) in active: + raise _GlpythonTreeTooBig() + active.add(id(item)) + children = [build(child, depth + 1) for child in item[1:]] + active.discard(id(item)) + return {"label": _glpython_tree_text(item[0]), "children": children} + + try: + return build(value, 1) + except _GlpythonTreeTooBig: + return None # Whether an expression is a call of the builtin help or dir (not a function # of the learner's that happens to have that name). diff --git a/src/runtime/visualizer.py b/src/runtime/visualizer.py index 332f478..39a9450 100644 --- a/src/runtime/visualizer.py +++ b/src/runtime/visualizer.py @@ -180,7 +180,29 @@ def _glpython_describe_for_locals_panel(value): return "class" if isinstance(value, _glpython_types.ModuleType): return "module" - return _glpython_safe_repr(value) + shown = _glpython_safe_repr(value) + _glpython_note_tree(shown, value) + return shown + +# The trees of the panel's values that read as one (_glpython_tree_of), for +# "listtree": like the full values, the next recorded step gets them as +# "trees" ({shown text: index into the trace's "trees"}), each tree stored +# once per trace. +_glpython_pending_trees = {} +_glpython_trees = [] +_glpython_tree_index = {} + +def _glpython_note_tree(shown, value): + tree = _glpython_tree_of(value) + if tree is None: + return + key = json.dumps(tree) + index = _glpython_tree_index.get(key) + if index is None: + index = len(_glpython_trees) + _glpython_trees.append(tree) + _glpython_tree_index[key] = index + _glpython_pending_trees[shown] = index # --------------------------------------------------------------------------- # Scopes: a simple chain of dict-based frames. Each function call / class body @@ -311,6 +333,9 @@ def _glpython_append_step(entry): if _glpython_pending_abbreviations: entry["fullValues"] = dict(_glpython_pending_abbreviations) _glpython_pending_abbreviations.clear() + if _glpython_pending_trees: + entry["trees"] = dict(_glpython_pending_trees) + _glpython_pending_trees.clear() _glpython_trace_log.append(entry) def _glpython_record(node, kind, label, value_text=None, func_name="", extra_locals=None, reset_node_ids=None, annotate_node_id=None, annotate_text=None, closes_frame_id=None, force=False, extra=None): @@ -1651,6 +1676,9 @@ def _glpython_trace_program(main_path, sources, fill_scope, report_value=False): _glpython_pending_abbreviations.clear() _glpython_full_values.clear() _glpython_full_value_index.clear() + _glpython_pending_trees.clear() + _glpython_trees.clear() + _glpython_tree_index.clear() _glpython_stdout_len = 0 _glpython_call_depth = 0 _glpython_current_func_stack = [""] @@ -1749,4 +1777,5 @@ def _glpython_trace_program(main_path, sources, fill_scope, report_value=False): "truncated": truncated, "value": value, "values": _glpython_full_values, + "trees": _glpython_trees, }) diff --git a/src/style.css b/src/style.css index acae09d..21b5662 100644 --- a/src/style.css +++ b/src/style.css @@ -104,12 +104,34 @@ .input-dialog-field:focus-visible { outline: 2px solid var(--glp-accent); outline-offset: 2px; } .input-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } /* An abbreviated value in the visualizer in full (FullValueDialog). */ -.full-value-dialog { max-width: min(720px, 100%); max-height: calc(100vh - 40px); } +.full-value-dialog { box-sizing: border-box; min-width: 0; max-width: min(720px, 100%); max-height: calc(100vh - 40px); } .full-value-title { font-family: var(--glp-font-code); } .full-value-length { color: var(--glp-text-muted); font-size: 13px; font-weight: 400; } .full-value-close { margin-left: auto; } .full-value-text { min-height: 0; margin: 0; padding: 10px 12px; overflow: auto; color: var(--glp-text); background: var(--glp-surface); border: 1px solid var(--glp-border); border-radius: var(--glp-radius); font: 14px/1.6 var(--glp-font-code); white-space: pre-wrap; overflow-wrap: anywhere; } .full-value-cut { margin: 0; color: var(--glp-text-muted); font-size: 13px; } +.full-value-tree { flex-shrink: 0; min-width: 0; max-height: 50vh; overflow-y: auto; padding: 10px 12px; border: 1px solid var(--glp-border); border-radius: var(--glp-radius); font-size: 14px; } + +/* A list drawn as a tree (TreeView, listtree): positions in characters of + the code font and in em, so the tree scales with its font size. */ +.value-tree-frame { --value-tree-fade: var(--glp-bg); position: relative; display: block; min-width: 0; max-width: 100%; } +.value-tree-scroll { display: block; overflow-x: auto; overflow-y: hidden; border-radius: var(--glp-radius); } +.value-tree-scroll:focus-visible { outline: 2px solid var(--glp-accent); outline-offset: -2px; } +/* At an edge where more of a wide tree is hidden: the tree fades into the + background behind it (TreeView sets --value-tree-fade), with a plain arrow + in the middle of its whole height - no border, no node colour, so it + doesn't read as a node. */ +.value-tree-hint { position: absolute; top: 0; bottom: 0; display: flex; align-items: center; width: 36px; color: var(--glp-text-muted); font: 400 20px/1 var(--glp-font-ui); pointer-events: none; } +.value-tree-hint--left { left: 0; justify-content: flex-start; padding-left: 2px; background: linear-gradient(to right, var(--value-tree-fade) 30%, transparent); } +.value-tree-hint--right { right: 0; justify-content: flex-end; padding-right: 2px; background: linear-gradient(to left, var(--value-tree-fade) 30%, transparent); } +.value-tree { position: relative; display: block; flex-shrink: 0; font-family: var(--glp-font-code); line-height: 1.6em; } +.value-tree-edges { position: absolute; top: 0; left: 0; overflow: visible; } +.value-tree-edges line { stroke: var(--glp-border-control); stroke-width: 1px; vector-effect: non-scaling-stroke; } +.value-tree-node, .value-tree-leaf { position: absolute; box-sizing: border-box; height: 1.6em; overflow: hidden; text-align: center; white-space: pre; border-radius: var(--glp-radius); } +.value-tree-node { color: var(--glp-syntax-keyword); background: var(--glp-surface); border: 1px solid var(--glp-border-control); font-weight: 600; } +.value-tree-leaf { color: var(--glp-text); background: var(--glp-bg); border: 1px dashed var(--glp-border); } +.value-tree-leaf--string { color: var(--glp-syntax-string); } +.value-tree-leaf--number { color: var(--glp-syntax-number); } @media (max-width: 760px) { .editor-header { align-items: flex-start; flex-direction: column; gap: 12px; } @@ -211,6 +233,8 @@ .visual-value-more-text { text-decoration: underline dotted; text-underline-offset: 3px; } .visual-value-more:focus-visible { outline: 2px solid var(--glp-accent); outline-offset: 1px; } .locals-value-length { display: block; color: var(--glp-text-muted); font-size: 12px; } +.locals-tree { flex-basis: 100%; min-width: 0; max-width: 100%; padding: 6px 0; } +.locals-tree-more { flex-basis: auto; padding: 0; color: var(--glp-accent); font: 600 12px/1.6 var(--glp-font-ui); } .visualizer-animated .visual-substituted-value { animation: visual-return-fly-in .4s ease-out backwards; } @keyframes visual-return-fly-in { from { transform: translate(var(--return-dx, 0px), var(--return-dy, 0px)); } @@ -260,11 +284,13 @@ .repl-echo > span { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; } /* A history entry's «Visualisieren»: a quiet text label at the right edge, underlined on hover like a link. */ -.repl-entry-visualize { flex-shrink: 0; margin-left: auto; padding: 0 4px; border: 0; border-radius: var(--glp-radius); color: var(--glp-accent); background: transparent; cursor: pointer; font: 600 12px/1.6 var(--glp-font-ui); } -.repl-entry-visualize:hover:not(:disabled) { text-decoration: underline; } +.repl-entry-visualize, .repl-entry-tree { flex-shrink: 0; margin-left: auto; padding: 0 4px; border: 0; border-radius: var(--glp-radius); color: var(--glp-accent); background: transparent; cursor: pointer; font: 600 12px/1.6 var(--glp-font-ui); } +.repl-entry-visualize:hover:not(:disabled), .repl-entry-tree:hover { text-decoration: underline; } +.repl-entry-tree + .repl-entry-visualize { margin-left: 0; } .repl-entry-visualize:disabled { color: var(--glp-text-muted); cursor: not-allowed; } .repl-note { color: var(--glp-text-muted); font: italic 13px/1.5 var(--glp-font-ui); white-space: normal; } .repl-value { color: var(--glp-text); } +.repl-tree { display: block; min-width: 0; max-width: 100%; padding: 4px 0 6px; } .repl-hint { color: var(--glp-warning); font-style: italic; } .repl-line { display: flex; align-items: center; gap: 8px; padding: 12px; background: var(--glp-surface); border-top: 1px solid var(--glp-border); } diff --git a/src/treeLayout.ts b/src/treeLayout.ts new file mode 100644 index 0000000..41d9879 --- /dev/null +++ b/src/treeLayout.ts @@ -0,0 +1,254 @@ +// A tree as TreeView draws it: inner nodes with a label (an operator, a +// function's name, a list's first string), leaves with a value's text. +export type TreeNode = + | { kind: 'node'; label: string; children: TreeNode[] } + | { kind: 'leaf'; text: string } + +// A list that reads as a tree, as the runtime sends it (session.py's +// _glpython_tree_of). +export type ValueTree = { label: string; children: ValueTree[] } | { leaf: string } + +export function treeFromValue(tree: ValueTree): TreeNode { + return 'leaf' in tree ? { kind: 'leaf', text: tree.leaf } : { kind: 'node', label: tree.label, children: tree.children.map(treeFromValue) } +} + +export type PlacedNode = { + // The path of child indices from the root ("" for the root, "0.2" for its + // first child's third child), which stays the same while the tree does. + key: string + node: TreeNode + // The node's centre and width in characters of the code font, its row + // from 0 at the root. + x: number + width: number + depth: number + parentKey: string | null +} + +export type TreeLayout = { nodes: PlacedNode[]; width: number; depth: number } + +// Horizontal room around a node's text, and between two neighbouring +// nodes, in characters. +const NODE_PADDING = 2 +const SIBLING_GAP = 2 + +function textWidth(text: string): number { + return [...text].length +} + +export function nodeWidth(node: TreeNode): number { + return textWidth(node.kind === 'node' ? node.label : node.text) + NODE_PADDING +} + +// A node of the layout while it runs, named as in van der Ploeg's paper: +// `prelim` its preliminary centre relative to its parent's subtree, `mod` +// what it and its subtree move by, `shift`/`change` the moves still to be +// spread over its siblings, `tl`/`tr` the threads that continue a contour +// below a shorter subtree, `el`/`er` the subtree's extreme (lowest leftmost +// and rightmost) nodes with the sums of `mod` down to them (`msel`/`mser`). +type LayoutNode = { + tree: TreeNode + // As wide as the node plus SIBLING_GAP (half the gap on each side), so + // that boxes touching means nodes SIBLING_GAP apart. + w: number + y: number + children: LayoutNode[] + x: number + prelim: number + mod: number + shift: number + change: number + tl: LayoutNode | null + tr: LayoutNode | null + el: LayoutNode | null + er: LayoutNode | null + msel: number + mser: number +} + +// Every node is one row high. +const bottom = (node: LayoutNode) => node.y + 1 + +const nextLeftContour = (node: LayoutNode) => (node.children.length === 0 ? node.tl : node.children[0]) +const nextRightContour = (node: LayoutNode) => (node.children.length === 0 ? node.tr : node.children[node.children.length - 1]) + +// The lowest row of the siblings to the left, with the index of the +// rightmost sibling that reaches down to it ("IYL" in the paper). +type LowestSibling = { lowY: number; index: number; next: LowestSibling | null } + +function updateLowest(minY: number, index: number, lowest: LowestSibling | null): LowestSibling { + while (lowest !== null && minY >= lowest.lowY) lowest = lowest.next + return { lowY: minY, index, next: lowest } +} + +function setExtremes(node: LayoutNode) { + if (node.children.length === 0) { + node.el = node + node.er = node + node.msel = 0 + node.mser = 0 + } else { + const first = node.children[0] + const last = node.children[node.children.length - 1] + node.el = first.el + node.msel = first.msel + node.er = last.er + node.mser = last.mser + } +} + +// The moves of the siblings between `from` and `index`, spread evenly. +function distributeExtra(node: LayoutNode, index: number, from: number, distance: number) { + if (from === index - 1) return + const count = index - from + node.children[from + 1].shift += distance / count + node.children[index].shift -= distance / count + node.children[index].change -= distance - distance / count +} + +function moveSubtree(node: LayoutNode, index: number, from: number, distance: number) { + const child = node.children[index] + child.mod += distance + child.msel += distance + child.mser += distance + distributeExtra(node, index, from, distance) +} + +function setLeftThread(node: LayoutNode, index: number, contour: LayoutNode, modSum: number) { + const first = node.children[0] + const extreme = first.el as LayoutNode + extreme.tl = contour + const difference = modSum - contour.mod - first.msel + extreme.mod += difference + extreme.prelim -= difference + first.el = node.children[index].el + first.msel = node.children[index].msel +} + +function setRightThread(node: LayoutNode, index: number, contour: LayoutNode, modSum: number) { + const child = node.children[index] + const extreme = child.er as LayoutNode + extreme.tr = contour + const difference = modSum - contour.mod - child.mser + extreme.mod += difference + extreme.prelim -= difference + child.er = node.children[index - 1].er + child.mser = node.children[index - 1].mser +} + +// Moves child `index` right of its left siblings, following the right +// contour of these and its own left contour down, row by row. On the first +// row (the two siblings themselves) it also moves left, so siblings touch +// unless a lower row keeps them apart. +function separate(node: LayoutNode, index: number, lowest: LowestSibling) { + let right: LayoutNode | null = node.children[index - 1] + let rightModSum = right.mod + let left: LayoutNode | null = node.children[index] + let leftModSum = left.mod + let current: LowestSibling | null = lowest + let first = true + while (right !== null && left !== null) { + if (current !== null && bottom(right) > current.lowY) current = current.next + const distance = rightModSum + right.prelim + right.w / 2 - (leftModSum + left.prelim - left.w / 2) + if (distance > 0 || (first && distance < 0)) { + leftModSum += distance + moveSubtree(node, index, current?.index ?? 0, distance) + } + first = false + const rightBottom = bottom(right) + const leftBottom = bottom(left) + if (rightBottom <= leftBottom) { + right = nextRightContour(right) + if (right !== null) rightModSum += right.mod + } + if (rightBottom >= leftBottom) { + left = nextLeftContour(left) + if (left !== null) leftModSum += left.mod + } + } + if (right === null && left !== null) setLeftThread(node, index, left, leftModSum) + else if (right !== null && left === null) setRightThread(node, index, right, rightModSum) +} + +// Centred over the span from the first child's left edge to the last +// child's right edge. +function positionRoot(node: LayoutNode) { + const first = node.children[0] + const last = node.children[node.children.length - 1] + node.prelim = (first.prelim + first.mod - first.w / 2 + last.mod + last.prelim + last.w / 2) / 2 +} + +function firstWalk(node: LayoutNode) { + if (node.children.length === 0) { + setExtremes(node) + return + } + firstWalk(node.children[0]) + let lowest = updateLowest(bottom(node.children[0].el as LayoutNode), 0, null) + for (let index = 1; index < node.children.length; index++) { + firstWalk(node.children[index]) + const minY = bottom(node.children[index].er as LayoutNode) + separate(node, index, lowest) + lowest = updateLowest(minY, index, lowest) + } + positionRoot(node) + setExtremes(node) +} + +function addChildSpacing(node: LayoutNode) { + let shift = 0 + let modSumDelta = 0 + for (const child of node.children) { + shift += child.shift + modSumDelta += shift + child.change + child.mod += modSumDelta + } +} + +function secondWalk(node: LayoutNode, modSum: number) { + modSum += node.mod + node.x = node.prelim + modSum + addChildSpacing(node) + for (const child of node.children) secondWalk(child, modSum) +} + +// A tidy layout, in characters (columns) and rows: children in order below +// their parent, the parent centred over them, and subtrees moved as close +// together as their rows allow, so they never overlap and the same tree +// always lands in the same place. The algorithm is A. van der Ploeg, +// "Drawing non-layered tidy trees in linear time", Software: Practice and +// Experience 44(12), 2014, pp. 1467-1484 - Reingold and Tilford's algorithm +// for nodes of different sizes - following the Java code in its appendix, +// with the changes d3-flextree makes to it: positions are a node's centre, +// not its left edge, and a subtree first moves next to its left sibling +// (see separate), so a narrow node comes as close to a sibling whose +// children are wide as their rows allow. Our nodes are all one row high. scripts/check-tree-layout.mjs compares +// it with d3-flextree, a published implementation of the same paper. +export function layoutTree(root: TreeNode): TreeLayout { + let depth = 0 + const build = (tree: TreeNode, row: number): LayoutNode => { + depth = Math.max(depth, row + 1) + const children = tree.kind === 'node' ? tree.children.map((child) => build(child, row + 1)) : [] + return { tree, w: nodeWidth(tree) + SIBLING_GAP, y: row, children, x: 0, prelim: 0, mod: 0, shift: 0, change: 0, tl: null, tr: null, el: null, er: null, msel: 0, mser: 0 } + } + const layoutRoot = build(root, 0) + firstWalk(layoutRoot) + secondWalk(layoutRoot, 0) + let left = Infinity + let right = -Infinity + const visit = (node: LayoutNode, visitor: (node: LayoutNode) => void) => { + visitor(node) + for (const child of node.children) visit(child, visitor) + } + visit(layoutRoot, (node) => { + left = Math.min(left, node.x - node.w / 2 + SIBLING_GAP / 2) + right = Math.max(right, node.x + node.w / 2 - SIBLING_GAP / 2) + }) + const nodes: PlacedNode[] = [] + const place = (node: LayoutNode, key: string, parentKey: string | null) => { + nodes.push({ key, node: node.tree, x: node.x - left, width: node.w - SIBLING_GAP, depth: node.y, parentKey }) + node.children.forEach((child, index) => place(child, key === '' ? String(index) : `${key}.${index}`, key)) + } + place(layoutRoot, '', null) + return { nodes, width: right - left, depth } +} diff --git a/src/visualizer.ts b/src/visualizer.ts index b7d9009..64ff96c 100644 --- a/src/visualizer.ts +++ b/src/visualizer.ts @@ -9,6 +9,7 @@ // node's original source with the step's computed value inline. import type { ErrorHint } from './errorHints' +import type { ValueTree } from './treeLayout' // 'error' is only ever the very last step: the node the uncaught exception // was raised at, in the frame it was raised in, with Python's own last @@ -36,6 +37,9 @@ export type TraceStep = { // The abbreviated texts among this step's values ("[1, 2, 3, 4, 5, …, 100]"), // each with its full value's index in TraceResult.values. fullValues?: Record + // The values in this step's panel that read as a tree (listtree), each + // with its tree's index in TraceResult.trees. + trees?: Record stdoutLen: number graphicsLen: number // Only present on "loop-iter" steps: every node id inside the loop body, @@ -151,11 +155,28 @@ export type TraceResult = { truncated: boolean // The full values behind abbreviated texts (see TraceStep.fullValues). values?: FullValue[] + // The trees behind panel values (see TraceStep.trees). + trees?: ValueTree[] // Per file, what call boxes leave out: comments and docstrings, as // [line, col, end line, end col] (see _glpython_hidden_ranges). hidden?: Record> } +// The tree of each panel value that reads as one, shown up to `uptoStep` +// (as fullValuesUpTo). +export function valueTreesUpTo(trace: TraceStep[], uptoStep: number, trees: ValueTree[] | undefined): Map { + const result = new Map() + if (!trees || trees.length === 0) return result + for (const step of trace) { + if (step.step > uptoStep) break + for (const [text, index] of Object.entries(step.trees ?? {})) { + const tree = trees[index] + if (tree) result.set(text, tree) + } + } + return result +} + // A long value in full: Python's repr (`cut` when it's longer than the // runtime keeps), with the number of its elements or characters. export type FullValue = { text: string; cut: boolean; length: number | null; kind: string | null }