From 687ffa758d7b68b6a5efd661cbb9661f3b1005b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:45:11 +0100 Subject: [PATCH 1/3] Lists as trees (listtree, P34 B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A list whose first element is a string is drawn as a tree below its text: in the visualizer's variables (switch in the options menu), in the full-value dialog and, on request («Baum»), for a REPL value. The runtime sends the trees (_glpython_tree_of); TreeView draws them with the package's own layout. Behind capability listTree / embed feature listtree, off by default. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 3 +- README.md | 37 ++++++++++++- package.json | 2 +- scripts/check-console-browser.mjs | 82 +++++++++++++++++++++++++++-- scripts/check-runtime-trees.py | 87 +++++++++++++++++++++++++++++++ src/GlPythonWorkspace.tsx | 76 ++++++++++++++++++++------- src/TreeView.tsx | 39 ++++++++++++++ src/embed/App.tsx | 5 +- src/i18n.ts | 6 +++ src/runtime/session.py | 58 ++++++++++++++++++++- src/runtime/visualizer.py | 31 ++++++++++- src/style.css | 23 ++++++-- src/treeLayout.ts | 81 ++++++++++++++++++++++++++++ src/visualizer.ts | 21 ++++++++ 14 files changed, 518 insertions(+), 33 deletions(-) create mode 100644 scripts/check-runtime-trees.py create mode 100644 src/TreeView.tsx create mode 100644 src/treeLayout.ts diff --git a/CLAUDE.md b/CLAUDE.md index df17007..e9fe7ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ 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 build:embed # builds the iframe app + demo page into dist-embed/ (plus embed/public/: coi-serviceworker.js) @@ -54,6 +54,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`, layout in `src/treeLayout.ts`: positions in characters of the code font and in em), behind capability `listTree`. - 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..a676227 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,39 @@ 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. The layout is +the package's own (`src/treeLayout.ts`, no dependency), 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 +429,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.json b/package.json index 979abfb..3becad1 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "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: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": { diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index 745f117..fa3e8c6 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -15,6 +15,9 @@ // 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. // // 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 +163,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 = '') => { 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.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 +324,79 @@ 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 treeOf('baum').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() + } // 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/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..fd49c46 --- /dev/null +++ b/src/TreeView.tsx @@ -0,0 +1,39 @@ +import { useMemo } 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 + +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). +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])) + return ( + + + {layout.nodes.map((placed) => ( + + ))} + + ) +} 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..221c756 100644 --- a/src/style.css +++ b/src/style.css @@ -104,12 +104,24 @@ .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: 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 { 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 +223,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%; max-width: 100%; overflow-x: auto; 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 +274,14 @@ .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-tree[aria-pressed="true"] { text-decoration: underline; } .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; max-width: 100%; overflow-x: auto; 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..6636ee7 --- /dev/null +++ b/src/treeLayout.ts @@ -0,0 +1,81 @@ +// 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 +// subtrees, 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 tidy layout for small trees, in characters (columns) and rows: leaves +// side by side in order, each parent centred over its children, a subtree +// as wide as its widest row needs - so subtrees never overlap and the same +// tree always lands in the same place. +export function layoutTree(root: TreeNode): TreeLayout { + const spans = new Map() + const span = (node: TreeNode): number => { + const own = nodeWidth(node) + const children = node.kind === 'node' ? node.children : [] + const total = children.length === 0 ? own : Math.max(own, children.reduce((sum, child) => sum + span(child), 0) + SIBLING_GAP * (children.length - 1)) + spans.set(node, total) + return total + } + const width = span(root) + const nodes: PlacedNode[] = [] + let depth = 0 + const place = (node: TreeNode, left: number, row: number, key: string, parentKey: string | null): number => { + depth = Math.max(depth, row + 1) + const total = spans.get(node) ?? 0 + const own = nodeWidth(node) + const placed: PlacedNode = { key, node, x: left + total / 2, width: own, depth: row, parentKey } + nodes.push(placed) + const children = node.kind === 'node' ? node.children : [] + if (children.length > 0) { + const childrenSpan = children.reduce((sum, child) => sum + (spans.get(child) ?? 0), 0) + SIBLING_GAP * (children.length - 1) + let start = left + (total - childrenSpan) / 2 + const centres = children.map((child, index) => { + const centre = place(child, start, row + 1, key === '' ? String(index) : `${key}.${index}`, key) + start += (spans.get(child) ?? 0) + SIBLING_GAP + return centre + }) + const middle = (centres[0] + centres[centres.length - 1]) / 2 + placed.x = Math.min(Math.max(middle, left + own / 2), left + total - own / 2) + } + return placed.x + } + place(root, 0, 0, '', null) + return { nodes, width, 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 } From f3078c9c721fa12822279b8fd1f00ee15fdc8e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:09:48 +0100 Subject: [PATCH 2/3] Trees: d3-flextree for the layout, a centred root and edge hints when narrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node positions now come from d3-flextree (van der Ploeg 2014, tidy trees for nodes of different widths), which moves subtrees together where their rows allow; rendering stays ours. A tree wider than its place scrolls inside TreeView: it starts with the root in the middle, shows ‹ › where more is hidden, and takes the focus for the arrow keys, Home and End. The console's «Baum» is no longer underlined while open. Browser checks for the layout of parser-like and uneven trees and for the narrow view. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 11 ++- THIRD_PARTY_NOTICES.md | 58 ++++++++++++++++ package-lock.json | 19 ++++++ package.json | 1 + scripts/check-console-browser.mjs | 109 ++++++++++++++++++++++++++++-- src/TreeView.tsx | 77 +++++++++++++++++---- src/d3-flextree.d.ts | 23 +++++++ src/style.css | 14 ++-- src/treeLayout.ts | 65 ++++++++---------- 10 files changed, 318 insertions(+), 61 deletions(-) create mode 100644 src/d3-flextree.d.ts diff --git a/CLAUDE.md b/CLAUDE.md index e9fe7ee..262b4da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,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`, layout in `src/treeLayout.ts`: positions in characters of the code font and in em), behind capability `listTree`. + - 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 `d3-flextree` (`src/treeLayout.ts`, in characters of the code font and in rows; every node one row high), bundled into both 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 a676227..44833aa 100644 --- a/README.md +++ b/README.md @@ -194,9 +194,14 @@ is drawn too; the text stays as it is. Where: 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. The layout is -the package's own (`src/treeLayout.ts`, no dependency), in characters of the -code font, so a tree scales with the text around it. +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 [d3-flextree](https://github.com/Klortho/d3-flextree) +(van der Ploeg's tidy-tree algorithm for nodes of different widths, +`src/treeLayout.ts`), in characters of the code font, so a tree scales with +the text around it; the drawing is the package's own. ### Copying the evaluation as a → chain diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3b20efa..df4820c 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -66,6 +66,64 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## Bundled into both builds (`dist/` and `dist-embed/`) + +The trees' layout (`src/treeLayout.ts`) uses: + +### d3-flextree 2.1.2 + +License: WTFPL · https://github.com/Klortho/d3-flextree + +```text + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. +``` + +### d3-hierarchy 1.1.9 (a dependency of d3-flextree) + +License: BSD-3-Clause · https://github.com/d3/d3-hierarchy + +```text +Copyright 2010-2016 Mike Bostock +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the author nor the names of contributors may be used to + endorse or promote products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + ## Bundled into the embed (`dist-embed/`) The library build (`dist/`) keeps React, Monaco and lucide-react external; the embed app bundles them: 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 3becad1..8075cb2 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,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 fa3e8c6..41b8659 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -17,7 +17,10 @@ // 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. +// 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 @@ -163,9 +166,9 @@ 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, query = '') => { + 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.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 @@ -345,7 +348,7 @@ try { } 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 treeOf('baum').getAttribute('aria-label'), "['+', ['var', 'x'], ['f', 1, 2]]", 'its text alternative') + 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. @@ -397,6 +400,104 @@ try { } 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`) + 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/src/TreeView.tsx b/src/TreeView.tsx index fd49c46..6a3b559 100644 --- a/src/TreeView.tsx +++ b/src/TreeView.tsx @@ -1,10 +1,12 @@ -import { useMemo } from 'react' +import { useCallback, useLayoutEffect, useMemo, useRef, useState, 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 function leafClass(text: string): string { if (/^['"]/.test(text)) return 'value-tree-leaf value-tree-leaf--string' @@ -16,24 +18,73 @@ function leafClass(text: string): string { // 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, 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 scrollRef = useRef(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() + 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 ( - - - {layout.nodes.map((placed) => ( -