diff --git a/CLAUDE.md b/CLAUDE.md index f5cc0a6..ec6d6eb 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,…}.py +npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests,coverage,calls,hints,fidelity,replay,lists,…}.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) @@ -53,6 +53,8 @@ There is no unit test framework. The automated checks are `test:protocol` (plain - `_GlpythonFunction` calls a stub with the same parameters first, so a call with wrong arguments raises Python's own `TypeError` (same message) at the call site. - 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. + - 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. Worker messages: - `run` → `result`/`error`; while it runs, `output` messages (`{id, text}`, `id` undefined for a program, the input's id for a REPL input) stream what was printed, at most 50 per 50 ms. The final message brings the whole output again. diff --git a/README.md b/README.md index 210f984..b260ede 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,35 @@ The mode last chosen is remembered per browser. A host can preset it with the texts are `visualizerModeLabel`, `visualizerModeBox` and `visualizerModeSubstitution`, all optional like `visualizerBoxHint`. +### Lists and long values + +Indexing a variable is one step to the element, in both modes: +`dauer[0] + dauer[1] → 1 + dauer[1] → 1 + 2 → 3`, while the list itself stays in +the variables panel. The same holds for a tuple, a string or a dict, and for a +slice (`w[1:3]`). A variable passed to a call is still replaced by its value +(`notenschnitt(noten) → notenschnitt([5, 4.5, 6])`), and so is a parameter in a +substituted function's expression. A list written out and indexed directly +(`[1, 2, 30][1]`) keeps its own step. + +A long value is abbreviated, in the step and in the variables panel alike, +keeping its first elements and its last one: `[1, 2, 3, 4, 5, …, 100]`. +- **Shown completely:** a list, tuple, set or dict of up to 10 elements, in a + value of up to 160 characters (so are the course's expression trees, e.g. + `['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]`), and a string of up to + 80 characters. +- **Abbreviated:** a longer container keeps its first 5 elements and its last. + If that is still longer than 160 characters, it keeps fewer and shortens long + elements too. A long string is shortened in the middle. +- **In full:** an abbreviated value is underlined with dots. A click (or Enter) + shows it in full, with its number of elements or characters (`100 Werte`, + also below it in the variables panel). The runtime keeps up to 50 000 + characters of it. + +A long value in a line wraps within the code instead of running on below the +variables panel. Texts: `visualizerShowFullValue`, `visualizerValueLength`, +`visualizerFullValueTitle`, `visualizerFullValueCut`, `visualizerCloseFullValue`, +all optional. + ### Copying the evaluation as a → chain The **Copy → chain** item in the toolbar's **⋯ Options** menu copies the evaluation of the statement diff --git a/package.json b/package.json index 60d6889..a436287 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", + "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", "prepare": "npm run build" }, "peerDependencies": { diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index e2a22df..9b0fd2b 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -10,6 +10,8 @@ // input() (P36) asks in the console's input line where the parent page is // cross-origin isolated (a recursive REPL, Stop and Ctrl+D → EOFError, the // visualizer), and without isolation stops with an error and its hint. +// The visualizer (P39): a named list indexed in one step, a long list +// abbreviated in the step and the variables and shown in full on request. // // 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 @@ -227,6 +229,58 @@ try { } await page.close() } + // The visualizer (P39), in Ersetzung: indexing a named list is one step, + // a named list passed to a call is still replaced by its value. A long + // list is abbreviated in the step and in the variables, within the code's + // width, and opens in full; an expression tree stays complete. + { + const page = await openEmbed({}) + const frame = page.frameLocator('#embed') + const lines = () => frame.locator('.frame-box--root > .frame-code > .frame-line-row .frame-line').allInnerTexts() + const stepButton = (index) => frame.locator('.visualizer-controls button').nth(index) + try { + await page.evaluate((source) => window.send('visualize', source), 'def notenschnitt(noten):\n return sum(noten) / len(noten)\n\ndauer = [1, 2, 30]\nx = dauer[0] + dauer[1]\ny = [1, 2, 30][1]\nschnitt = notenschnitt(dauer)\n') + await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + const seen = { 5: [], 6: [], 7: [] } + while (!(await stepButton(2).isDisabled())) { + await stepButton(2).click() + const texts = await lines() + for (const line of [5, 6, 7]) if (seen[line].at(-1) !== texts[line - 1]) seen[line].push(texts[line - 1]) + } + assert.deepEqual(seen[5], ['x = dauer[0] + dauer[1]', 'x = 1 + dauer[1]', 'x = 1 + 2', 'x = 3'], 'dauer[0] + dauer[1]: one step per element') + assert.deepEqual(seen[6], ['y = [1, 2, 30][1]', 'y = 2'], 'a list literal indexed directly') + assert.ok(seen[7].includes('schnitt = notenschnitt([1, 2, 30])'), `a named list passed to a call: ${JSON.stringify(seen[7])}`) + console.log('visualizer: a named list indexed in one step: ok') + + await page.evaluate((source) => window.send('visualize', source), 'l = list(range(1, 101))\nbaum = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]]\n') + await stepButton(3).click() + await frame.locator('.locals-row[data-name="baum"]').waitFor({ timeout: 30000 }) + const short = '[1, 2, 3, 4, 5, …, 100]' + assert.equal((await lines())[0], `l = ${short}`, 'the step: abbreviated') + const panelValue = frame.locator('.locals-row[data-name="l"] .locals-value') + assert.equal(await panelValue.locator('.visual-value-more-text').innerText(), short, 'the variables: the same abbreviation') + assert.equal(await panelValue.locator('.locals-value-length').innerText(), '100 Werte', 'its length') + assert.equal(await frame.locator('.locals-row[data-name="baum"] .locals-value').innerText(), "['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]", 'an expression tree: complete') + const inline = frame.locator('.frame-box--root .visual-substituted-value.visual-value-more').first() + const codeBox = await frame.locator('.frame-box--root > .frame-code').boundingBox() + const valueBox = await inline.boundingBox() + assert.ok(valueBox.x + valueBox.width <= codeBox.x + codeBox.width, 'the step value stays within the code') + const full = `[${Array.from({ length: 100 }, (_, index) => index + 1).join(', ')}]` + for (const [label, target] of [['the variables', panelValue], ['the step', inline]]) { + await target.click() + const dialog = frame.getByRole('dialog') + assert.equal(await dialog.locator('.full-value-text').innerText(), full, `${label}: the full value`) + assert.match(await dialog.locator('.input-dialog-heading').innerText(), /100 Werte/, `${label}: its length`) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'detached', timeout: 5000 }) + } + console.log('visualizer: a long list abbreviated, in full on request: ok') + } catch (error) { + failed += 1 + console.error(error.message) + } + await page.close() + } // Without isolation (as in LiaScript): Python's error and the hint. { const page = await openEmbed({}) diff --git a/scripts/check-runtime-lists.py b/scripts/check-runtime-lists.py new file mode 100644 index 0000000..5061668 --- /dev/null +++ b/scripts/check-runtime-lists.py @@ -0,0 +1,126 @@ +"""Regression test: lists in the visualizer (P39). Indexing a named list is +one step to the element ("dauer[1]" -> "2", not first "[1, 2, 30][1]"), as +the course writes chains on paper; a named list passed to a call is still +replaced by its value, and a list literal indexed directly keeps its step. +A long value is abbreviated ("[1, 2, 3, 4, 5, …, 100]"), the same in the +step and in the variables panel, with its full value in the trace's +"values"; short values and the course's expression trees stay complete. + +Runs the visualizer runtime with CPython (no Pyodide needed): + python3 -B scripts/check-runtime-lists.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" + +namespace = {"__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(), name, "exec"), namespace) + +failures = [] + + +def expect(label, got, expected): + if got != expected: + failures.append(f"{label}: got {got!r}, expected {expected!r}") + + +def trace(source): + return json.loads(namespace["_glpython_trace_program"]("main.py", {"main.py": source}, None)) + + +def evals_on_line(result, line): + return [(step["label"], step["valueText"]) for step in result["trace"] if step["kind"] == "eval" and step["line"] == line] + + +# One step to the element; the list stays in the variables panel. +result = trace("dauer = [1, 2, 30]\nx = dauer[1]\ny = dauer[0] + dauer[1]\nz = [1, 2, 30][1]\n") +expect("dauer[1]", evals_on_line(result, 2), [("dauer[1]", "2")]) +expect("dauer[0] + dauer[1]", evals_on_line(result, 3), [("dauer[0]", "1"), ("dauer[1]", "2"), ("1 + 2", "3")]) +expect("the list in the panel", result["trace"][-1]["locals"]["dauer"], "[1, 2, 30]") +# A literal list indexed directly: its own step, then the element. +expect("[1, 2, 30][1]", evals_on_line(result, 4), [("[1, 2, 30]", "[1, 2, 30]"), ("[1, 2, 30][1]", "2")]) + +# The same for a tuple, a string, a dict, a slice, an index that is a +# variable (its value is shown) and a list in a list. +result = trace('t = (4, 5)\nw = "Hallo"\nd = {"a": 1}\nm = [[1, 2], [3, 4]]\ni = 1\nx = t[0] + len(w[1:3]) + d["a"] + m[i][0]\n') +labels = [label for label, _ in evals_on_line(result, 6)] +for unwanted in ("t", "w", "d", "m"): + expect(f"no step of its own for {unwanted}", unwanted in labels, False) +expect("m[i][0]", [step for step in evals_on_line(result, 6) if step[0] in ("i", "m[1]", "[3, 4][0]")], [("i", "1"), ("m[1]", "[3, 4]"), ("[3, 4][0]", "3")]) + +# A named list passed to a call is replaced by its value, as before. +NOTEN = '''def notenschnitt(noten): + return sum(noten) / len(noten) + +noten = [5, 4.5, 6] +schnitt = notenschnitt(noten) +''' +result = trace(NOTEN) +expect("notenschnitt(noten)", evals_on_line(result, 5), [("noten", "[5, 4.5, 6]"), ("notenschnitt(...)", "5.166666666666667")]) +enter = next(step for step in result["trace"] if step["kind"] == "call-enter") +expect("its short form fills in the list", [text for _, text in enter["shortForm"]["paramValues"]], ["[5, 4.5, 6]", "[5, 4.5, 6]"]) + +# An index that doesn't exist: the error step, with Python's message. +result = trace("dauer = [1, 2, 30]\nx = dauer[3]\n") +last = result["trace"][-1] +expect("dauer[3]", (last["kind"], last["line"], last["label"], result["errorMessage"]), ("error", 2, "IndexError: list index out of range", "IndexError: list index out of range")) +expect("dauer[3]: no step for the name", evals_on_line(result, 2), []) + +# The chain's reason comment keeps an indexed name, as the step does. +result = trace("dauer = [1, 2, 30]\nif dauer[0] > 0:\n pass\n") +expect("condition text", [step.get("conditionText") for step in result["trace"] if step["kind"] == "branch"], ["dauer[0] > 0"]) + +# A long list: abbreviated in the step and in the panel, its full value on +# request. +result = trace("l = list(range(1, 101))\n") +short = "[1, 2, 3, 4, 5, …, 100]" +step = next(step for step in result["trace"] if step["label"] == "list(...)") +expect("long list: the step", step["valueText"], short) +expect("long list: the panel", result["trace"][-1]["locals"]["l"], short) +full = result["values"][result["trace"][-1]["fullValues"][short]] +expect("long list: the full value", full, {"text": repr(list(range(1, 101))), "cut": False, "length": 100, "kind": "list"}) +expect("long list: the step has it too", step["fullValues"][short], result["trace"][-1]["fullValues"][short]) + +# Short values and the course's expression trees stay complete; nothing is +# noted for them. +repr_of = namespace["_glpython_safe_repr"] +for value in (list(range(10)), ["+", 1, ["*", 2, 3]], ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]], ("a", "b"), {"x": 1, "y": [1, 2]}, "Hallo Welt", [None, True, 2.5]): + expect(f"complete: {value!r}", repr_of(value), repr(value)) +result = trace('baum = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], 3]]\n') +expect("expression tree: no full value", (result["values"], result["trace"][-1]["locals"]["baum"]), ([], "['+', ['*', 2, ['-', 7, 3]], ['/', ['+', 1, 2], 3]]")) + +# How other long values are abbreviated: dicts, sets and tuples as lists, +# a list in a list, long strings in their middle, recursion as Python shows it. +for value, expected in [ + (list(range(11)), "[0, 1, 2, 3, 4, …, 10]"), + (tuple(range(20)), "(0, 1, 2, 3, 4, …, 19)"), + ({i: i * i for i in range(12)}, "{0: 0, 1: 1, 2: 4, 3: 9, 4: 16, …, 11: 121}"), + (set(range(12)), "{0, 1, 2, 3, 4, …, 11}"), + ([list(range(12)), [1]], "[[0, 1, 2, 3, 4, …, 11], [1]]"), + ("x" * 100, "'" + "x" * 58 + "…" + "x" * 19 + "'"), + # Still too long with 5 elements: 3, and long numbers shortened. + ([10 ** 30] * 12, "[10000000000…0000, 10000000000…0000, 10000000000…0000, …, 10000000000…0000]"), +]: + expect(f"abbreviated: {value!r:.40}", repr_of(value), expected) +recursive = [1] +recursive.append(recursive) +expect("recursive list", repr_of(recursive), "[1, [...]]") +# A string's length is in characters, a dict's in entries. +result = trace('s = "a" * 100\nd = {i: i for i in range(20)}\n') +expect("lengths", sorted((value["kind"], value["length"]) for value in result["values"]), [("dict", 20), ("str", 100)]) + +# A long value shows with the same abbreviation wherever it appears; each full +# text is stored once. +result = trace("l = list(range(1, 101))\nm = l\nn = len(l)\n") +expect("stored once", len(result["values"]), 1) + +if failures: + print("\n".join(failures)) + sys.exit(f"{len(failures)} list check(s) failed.") +print("Runtime list checks passed.") diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index 6ffe57c..0616797 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -1,11 +1,11 @@ import { createPortal } from 'react-dom' -import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react' +import { createContext, Fragment, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react' import Editor, { useMonaco } from '@monaco-editor/react' 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, hiddenRangesWithin, removedLines, computeLineOffsets, computeOpenFramesUpTo, indexAstNodes, lineNumberAtOffset, locateNodeRenderedRange, nodeRange, renderFrameSource, substitutionModeSteps, type AstNode, type CollapsedCalls, type OpenFrame, type TraceResult, type TraceStep, type VisualizerMode } from './visualizer' +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 { tokenizePythonFragment } from './pyHighlight' import { GLPYTHON_PYTHON, registerGlpythonPython } from './pythonLanguage' import { defineGlpythonTheme, GLPYTHON_THEME } from './monacoTheme' @@ -135,6 +135,14 @@ type CoreStrings = { inputDialogTitle: string // The console's input line while the program waits in input() (its label for screen readers). programInputLabel?: string + // An abbreviated value in the visualizer ("[1, 2, 3, 4, 5, …, 100]"): the + // title of the button showing it in full, how many elements or characters + // it has (`kind` is Python's type name), and its dialog. + visualizerShowFullValue?: string + visualizerValueLength?: (length: number, kind: string | null) => string + visualizerFullValueTitle?: (name: string | null) => string + visualizerFullValueCut?: string + visualizerCloseFullValue?: string inputDialogFallbackPrompt: string inputPlaceholder: string inputCancel: string @@ -479,11 +487,78 @@ function buildLineSegments( // their own dedicated color instead (see the `className` branch below) and // are never re-tokenized. function TextSegment({ segment, monaco }: { segment: Extract; monaco: Monaco | null }) { - if (segment.className || !monaco || segment.text === '') return {segment.text} + if (segment.className || !monaco || segment.text === '') return const runs = tokenizePythonFragment(monaco, segment.text) return <>{runs.map((run, index) => {run.text})} } +// The full values behind the abbreviated texts shown at the current step +// (see fullValuesUpTo), and how to open one in FullValueDialog. +type FullValueAccess = { values: Map; open: (value: FullValue, name: string | null) => void; t: CoreStrings } +const FullValueContext = createContext(null) + +// A loop variable's annotation reads "=[1, 2, …, 100]". +function lookupFullValue(access: FullValueAccess | null, text: string): FullValue | null { + if (!access || !text.includes('\u2026')) return null + return access.values.get(text) ?? (text.startsWith('=') ? access.values.get(text.slice(1)) ?? null : null) +} + +function openOnKey(event: KeyboardEvent, open: () => void) { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + open() +} + +// A substituted value or annotation in a line; an abbreviated one opens its +// full value. A span, not a button, so the line's text stays as it was. A +// long value is plain inline text, which wraps where the line ends. +const LONG_VALUE_LENGTH = 30 +function ValueText({ text, className: baseClassName, annotationNodeId }: { text: string; className?: string; annotationNodeId?: number }) { + const access = useContext(FullValueContext) + const full = lookupFullValue(access, text) + const className = text.length > LONG_VALUE_LENGTH && baseClassName?.includes('visual-substituted-value') ? `${baseClassName} visual-substituted-value--long` : baseClassName + if (!access || !full) return {text} + const open = () => access.open(full, null) + return openOnKey(event, open)}>{text} +} + +function fullValueLength(value: FullValue, t: CoreStrings): string | null { + if (value.length === null) return null + return t.visualizerValueLength?.(value.length, value.kind) ?? `${value.length} ${value.kind === 'str' ? 'characters' : value.kind === 'dict' ? 'entries' : 'items'}` +} + +// A variable in a frame's panel; an abbreviated value opens in full, with +// its length below it. +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}} +} + +// A long value in full, readable and scrollable. +function FullValueDialog({ value, name, t, onClose }: { value: FullValue; name: string | null; t: CoreStrings; onClose: () => void }) { + const closeRef = useRef(null) + useEffect(() => { + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null + closeRef.current?.focus() + return () => previous?.focus() + }, []) + const title = t.visualizerFullValueTitle?.(name) ?? (name ?? 'Value') + const length = fullValueLength(value, t) + return ( +
+
event.stopPropagation()} onKeyDown={(event) => { if (event.key === 'Escape') { event.stopPropagation(); onClose() } }}> +
{title}{length && {length}}
+
{value.text}{value.cut && '\u2026'}
+ {value.cut &&

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

} +
+
+ ) +} + // Which source span a Frame renders: the outermost module/global scope // (the whole file, no single AST node to clip to) or one open user-defined // function call (clipped to its own "def foo(...):" node). @@ -726,7 +801,7 @@ function Frame(props: FrameProps) { ) : segment.kind === 'group' ? ( - {segment.parts.map((part, partIndex) => {part.text})} + {segment.parts.map((part, partIndex) => )} ) : ( @@ -806,7 +881,7 @@ function Frame(props: FrameProps) { {localEntries.length > 0 && <>
- {localEntries.map(([name, value]) =>
{name}{value === null ? '\u2026' : valueKindLabels[value] ?? value}
)} + {localEntries.map(([name, value]) =>
{name}{value === null ? {'\u2026'} : Object.hasOwn(valueKindLabels, value) ? {valueKindLabels[value]} : }
)}
}
@@ -1001,6 +1076,8 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { class: t.valueKindClass ?? 'class', 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) // 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) @@ -1288,6 +1365,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { } const exitVisualizer = () => { setExampleExpectation(null) + setFullValueView(null) setVisualTrace(null) setVisualStepIndex(-1) setVisualRootPath(null) @@ -1448,6 +1526,11 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // last recorded step (or -1 itself if no steps were recorded at all). const clampedStepIndex = Math.min(Math.max(visualStepIndex, -1), Math.max(0, visualTraceSteps.length - 1)) const currentTraceStep = visualTraceSteps[clampedStepIndex] ?? null + const fullValueAccess = useMemo(() => ({ + values: fullValuesUpTo(visualTrace?.trace ?? [], currentTraceStep?.step ?? -1, visualTrace?.values), + open: (value, name) => setFullValueView({ value, name }), + t, + }), [visualTrace, currentTraceStep, t]) // 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. @@ -2138,7 +2221,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { 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 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 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" />
}
} // 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 @@ -2164,6 +2247,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)} />} {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/i18n.ts b/src/i18n.ts index 0584ad3..f6631e9 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -30,6 +30,11 @@ export const coreStrings: Record = { visualizerAnimate: 'Animate steps', visualizerError: (message) => `Visualizer error: ${message}`, visualizerTruncated: 'Trace stopped after too many steps (likely an infinite loop). Showing what ran so far.', + visualizerShowFullValue: 'Show the full value', + visualizerValueLength: (length, kind) => kind === 'str' ? `${length} ${length === 1 ? 'character' : 'characters'}` : kind === 'dict' ? `${length} ${length === 1 ? 'entry' : 'entries'}` : `${length} ${length === 1 ? 'item' : 'items'}`, + visualizerFullValueTitle: (name) => name ?? 'Value', + visualizerFullValueCut: 'Only the beginning of this value is shown: it is too long.', + visualizerCloseFullValue: 'Close', visualizerCollapsedCalls: (count, call) => `… ${count} more calls${call ? ` of ${call}` : ''} …`, replInputLabel: 'Python input', replDefinitionHint: 'Definitions belong in the file.', @@ -109,6 +114,11 @@ export const coreStrings: Record = { visualizerAnimate: 'Schritte animieren', visualizerError: (message) => `Fehler in der Visualisierung: ${message}`, visualizerTruncated: 'Die Aufzeichnung wurde nach zu vielen Schritten gestoppt (wahrscheinlich eine Endlosschleife). Es wird gezeigt, was bisher ausgeführt wurde.', + visualizerShowFullValue: 'Ganzen Wert zeigen', + visualizerValueLength: (length, kind) => kind === 'str' ? `${length} Zeichen` : kind === 'dict' ? `${length} ${length === 1 ? 'Eintrag' : 'Einträge'}` : `${length} ${length === 1 ? 'Wert' : 'Werte'}`, + visualizerFullValueTitle: (name) => name ?? 'Wert', + visualizerFullValueCut: 'Nur der Anfang dieses Werts wird gezeigt: Er ist zu lang.', + visualizerCloseFullValue: 'Schliessen', visualizerCollapsedCalls: (count, call) => `… ${count} weitere Aufrufe${call ? ` von ${call}` : ''} …`, replInputLabel: 'Python-Eingabe', replDefinitionHint: 'Definitionen gehören in die Datei.', diff --git a/src/runtime/visualizer.py b/src/runtime/visualizer.py index 7c01d48..5aacd7b 100644 --- a/src/runtime/visualizer.py +++ b/src/runtime/visualizer.py @@ -57,14 +57,117 @@ def _glpython_is_uninteresting_for_substitution(value): # the way learners write it ("int", "list", "NoneType" instead of # ""), so "type(a) != list" steps to "int != list". The program's # own output (print, the REPL transcript) keeps Python's repr. +# +# A long value is abbreviated as reprlib does, but keeping the last element: +# "[1, 2, 3, 4, 5, …, 100]". A list, tuple, set or dict of up to 10 elements +# is shown completely (so are the course's expression trees); a longer one +# keeps its first 5 and its last. If that is still longer than `limit`, fewer +# elements and shortened strings, until it fits. A string is shortened in +# its middle: "'Es war einmal…Ende.'". The full value goes to the +# trace's "values" (see _glpython_note_abbreviation), for the UI to show on +# request. +_GLPYTHON_ABBREVIATION_LEVELS = ( + # (most elements shown completely, elements kept before "…", longest + # nested string or other value before it's shortened too) + (10, 5, None), + (10, 5, 40), + (4, 3, 16), + (2, 1, 8), +) +_GLPYTHON_ABBREVIATION_DEPTH = 6 +# A string is shortened from this length on. +_GLPYTHON_TEXT_LIMIT = 80 +_GLPYTHON_FULL_VALUE_LIMIT = 50000 +_GLPYTHON_ELISION = object() + +def _glpython_shorten_text(text, limit): + if limit is None or len(text) <= limit: + return text + tail = max(1, limit // 4) + return text[: limit - tail - 1] + "…" + text[-tail:] + +def _glpython_abbreviated_repr(value, level, depth, active): + most, head, leaf_limit = level + kind = type(value) + if kind not in (list, tuple, set, frozenset, dict): + try: + text = repr(value) + except Exception: + text = "" + return _glpython_shorten_text(text, leaf_limit) + if id(value) in active: + return "{...}" if kind is dict else "[...]" + if depth >= _GLPYTHON_ABBREVIATION_DEPTH: + return {list: "[…]", tuple: "(…)", dict: "{…}"}.get(kind, "{…}") + active.add(id(value)) + try: + items = list(value.items()) if kind is dict else list(value) + shown = items if len(items) <= most else items[:head] + [_GLPYTHON_ELISION] + items[-1:] + parts = [] + for item in shown: + if item is _GLPYTHON_ELISION: + parts.append("…") + elif kind is dict: + parts.append(f"{_glpython_abbreviated_repr(item[0], level, depth + 1, active)}: {_glpython_abbreviated_repr(item[1], level, depth + 1, active)}") + else: + parts.append(_glpython_abbreviated_repr(item, level, depth + 1, active)) + finally: + active.discard(id(value)) + inner = ", ".join(parts) + if kind is list: + return f"[{inner}]" + if kind is tuple: + return f"({inner},)" if len(items) == 1 else f"({inner})" + if kind is dict: + return f"{{{inner}}}" + if not items: + return f"{kind.__name__}()" + return f"{{{inner}}}" if kind is set else f"frozenset({{{inner}}})" + def _glpython_safe_repr(value, limit=160): try: text = value.__name__ if isinstance(value, type) else repr(value) except Exception: text = "" - if len(text) > limit: - text = text[: limit - 1] + "…" - return text + if type(value) not in (list, tuple, set, frozenset, dict): + shown = _glpython_shorten_text(text, min(limit, _GLPYTHON_TEXT_LIMIT) if isinstance(value, str) else limit) + else: + shown = None + for level in _GLPYTHON_ABBREVIATION_LEVELS: + candidate = _glpython_abbreviated_repr(value, level, 0, set()) + if len(candidate) <= limit: + shown = candidate + break + if shown is None: + shown = text[: limit - 1] + "…" + if shown != text: + _glpython_note_abbreviation(shown, text, value) + return shown + +# The full values of the texts abbreviated since the last recorded step; +# _glpython_record hands them to that step as "fullValues" ({shown text: +# index into the trace's "values"}), each full text stored once per trace. +_glpython_pending_abbreviations = {} +_glpython_full_values = [] +_glpython_full_value_index = {} + +def _glpython_note_abbreviation(shown, text, value): + index = _glpython_full_value_index.get(text) + if index is None: + try: + length = len(value) if isinstance(value, (str, list, tuple, set, frozenset, dict)) else None + except Exception: + length = None + kind = type(value).__name__ if isinstance(value, (str, list, tuple, set, frozenset, dict)) else None + index = len(_glpython_full_values) + _glpython_full_values.append({ + "text": text[:_GLPYTHON_FULL_VALUE_LIMIT], + "cut": len(text) > _GLPYTHON_FULL_VALUE_LIMIT, + "length": length, + "kind": kind, + }) + _glpython_full_value_index[text] = index + _glpython_pending_abbreviations[shown] = index def _glpython_describe_for_locals_panel(value): # Callables/modules/classes have a noisy, memory-address-bearing repr @@ -204,6 +307,12 @@ def _glpython_pos(node): getattr(node, "_glpython_end_col", getattr(node, "end_col_offset", None)), ) +def _glpython_append_step(entry): + if _glpython_pending_abbreviations: + entry["fullValues"] = dict(_glpython_pending_abbreviations) + _glpython_pending_abbreviations.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): global _glpython_step_count # `force` is only used for the final "error" step (see @@ -259,7 +368,7 @@ def _glpython_record(node, kind, label, value_text=None, func_name="", e # Fields only some kinds of step carry (see their call sites). if extra: entry.update(extra) - _glpython_trace_log.append(entry) + _glpython_append_step(entry) _glpython_step_count += 1 def _glpython_record_call_pending(call_node, name, scope): @@ -312,7 +421,7 @@ def _glpython_record_call_pending(call_node, name, scope): "lookupName": name, "lookupFrameId": lookup_frame_id, } - _glpython_trace_log.append(entry) + _glpython_append_step(entry) _glpython_step_count += 1 def _glpython_record_call_enter(call_node, glpython_function, frame_id, parent_frame_id, extra_locals, param_annotations, arg_sources=None, short_form=None): @@ -370,7 +479,7 @@ def _glpython_record_call_enter(call_node, glpython_function, frame_id, parent_f "inOperator": getattr(call_node, "_glpython_in_operator", False), "hasLocals": getattr(glpython_function.node, "_glpython_has_locals", False), } - _glpython_trace_log.append(entry) + _glpython_append_step(entry) _glpython_step_count += 1 def _glpython_snapshot_locals(scope, limit=80): @@ -470,6 +579,10 @@ def _eval_constant(node, scope): def _eval_name(node, scope): value = scope.get(node.id) + # "dauer[1]" is one step to the element, as the course writes it: the + # name isn't first replaced by the whole list (see _glpython_indexed). + if getattr(node, "_glpython_indexed", False): + return value value_text = None if _glpython_is_uninteresting_for_substitution(value) else _glpython_safe_repr(value) _glpython_record(node, "eval", node.id, value_text, _glpython_current_func()) return value @@ -566,7 +679,8 @@ def _eval_subscript(node, scope): target = _glpython_eval(node.value, scope) index = _glpython_eval(node.slice, scope) value = target[index] - _glpython_record(node, "eval", f"{_glpython_safe_repr(target, 30)}[{_glpython_safe_repr(index, 20)}]", _glpython_safe_repr(value), _glpython_current_func()) + target_text = node.value.id if isinstance(node.value, ast.Name) else _glpython_safe_repr(target, 30) + _glpython_record(node, "eval", f"{target_text}[{_glpython_safe_repr(index, 20)}]", _glpython_safe_repr(value), _glpython_current_func()) return value def _eval_slice(node, scope): @@ -906,7 +1020,8 @@ def _glpython_names_read(expr): yield from _glpython_names_read(child) # An expression's source with every name in it that currently has a plain -# value (not a function, class or module) replaced by that value's repr. +# value (not a function, class or module) replaced by that value's repr - +# except an indexed one, which "dauer[1]" keeps (see _glpython_indexed). def _glpython_source_with_values(expr, scope): source = getattr(expr, "_glpython_source", None) if source is None or getattr(expr, "lineno", None) is None: @@ -918,7 +1033,7 @@ def _glpython_source_with_values(expr, scope): value = scope.get(name.id) except NameError: continue - if _glpython_is_uninteresting_for_substitution(value) or name.end_lineno != name.lineno: + if _glpython_is_uninteresting_for_substitution(value) or name.end_lineno != name.lineno or getattr(name, "_glpython_indexed", False): continue # Columns are UTF-8 byte offsets; the first line of the source starts at # the expression's own column, later lines at column 0. @@ -1243,6 +1358,10 @@ def _glpython_preprocess(tree, source, counter): # A call whose value an operator works on - including the object of an # attribute or subscript - needs parentheses around an expression that # replaces it (see "parens" on the call-enter step). + # The name of an indexed variable ("dauer" in "dauer[1]") gets no step + # of its own; the list stays visible in the variables panel. + if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name): + node.value._glpython_indexed = True for child in ast.iter_child_nodes(node): if isinstance(child, ast.Call): child._glpython_in_operator = isinstance(node, (ast.BinOp, ast.UnaryOp, ast.BoolOp, ast.Compare)) or (isinstance(node, (ast.Attribute, ast.Subscript)) and child is node.value) @@ -1487,6 +1606,9 @@ def _glpython_trace_program(main_path, sources, fill_scope, report_value=False): global _glpython_frame_id_counter, _glpython_frame_stack, _glpython_pending_call_node, _glpython_last_call_frame_id _glpython_trace_log = [] _glpython_step_count = 0 + _glpython_pending_abbreviations.clear() + _glpython_full_values.clear() + _glpython_full_value_index.clear() _glpython_stdout_len = 0 _glpython_call_depth = 0 _glpython_current_func_stack = [""] @@ -1580,4 +1702,5 @@ def _glpython_trace_program(main_path, sources, fill_scope, report_value=False): "errorHint": error_hint, "truncated": truncated, "value": value, + "values": _glpython_full_values, }) diff --git a/src/style.css b/src/style.css index ec150ad..acae09d 100644 --- a/src/style.css +++ b/src/style.css @@ -103,6 +103,13 @@ .input-dialog-field { padding: 9px 11px; border: 1px solid var(--glp-border-control); border-radius: var(--glp-radius); font: 15px var(--glp-font-code); background: var(--glp-bg); color: var(--glp-text); } .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-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; } @media (max-width: 760px) { .editor-header { align-items: flex-start; flex-direction: column; gap: 12px; } @@ -196,6 +203,14 @@ for the transform to take effect, since these spans otherwise lay out as plain inline text. */ .visual-substituted-value { display: inline-block; } +/* A long value stays inline and wraps where the line ends instead of running + on below the variables panel (and isn't flown in). */ +.visual-substituted-value--long { display: inline; white-space: pre-wrap; overflow-wrap: anywhere; box-decoration-break: clone; -webkit-box-decoration-break: clone; } +/* An abbreviated value ("[1, 2, 3, 4, 5, …, 100]") opens in full. */ +.visual-value-more { cursor: pointer; } +.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; } .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)); } diff --git a/src/visualizer.ts b/src/visualizer.ts index dc1a45a..b7d9009 100644 --- a/src/visualizer.ts +++ b/src/visualizer.ts @@ -33,6 +33,9 @@ export type TraceStep = { // e.g. a bare `pass` or an assignment target already covered by locals). valueText: string | null locals: Record + // 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 stdoutLen: number graphicsLen: number // Only present on "loop-iter" steps: every node id inside the loop body, @@ -146,11 +149,33 @@ export type TraceResult = { // The hint for that error, if it is one of the unit's common mistakes. errorHint?: ErrorHint | null truncated: boolean + // The full values behind abbreviated texts (see TraceStep.fullValues). + values?: FullValue[] // Per file, what call boxes leave out: comments and docstrings, as // [line, col, end line, end col] (see _glpython_hidden_ranges). hidden?: Record> } +// 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 } + +// The full value behind each abbreviated text shown up to `uptoStep`; a later +// step's wins, since the same abbreviation can stand for a value that changed +// in the middle. +export function fullValuesUpTo(trace: TraceStep[], uptoStep: number, values: FullValue[] | undefined): Map { + const result = new Map() + if (!values || values.length === 0) return result + for (const step of trace) { + if (step.step > uptoStep) break + for (const [text, index] of Object.entries(step.fullValues ?? {})) { + const value = values[index] + if (value) result.set(text, value) + } + } + return result +} + // A file's hidden ranges (see TraceResult.hidden) as character ranges that // lie entirely inside `within`, ready to be removed from a call box's text. export function hiddenRangesWithin(ranges: Array<[number, number, number, number]> | undefined, lineOffsets: number[], sourceLength: number, within: { start: number; end: number }): Array<{ start: number; end: number }> {