From 9984bd70ba989733fa3103130b9710bfdadaddfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:48:51 +0100 Subject: [PATCH] Visualizer: global variables in a footer that doesn't scroll (P53) The module's variables leave the column beside the code for a footer below the visualizer's view, at every width; the code gets the full width. Hover, tap or focus on an entry opens a box over the code, upwards from the pointer: a function's definition, highlighted, or the full value (and its tree). Arrows to and from globals end at their footer entry, drawn over view and footer and measured again on scroll. The current call's box keeps its variable row sticky at the view's bottom. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + README.md | 34 +++- scripts/check-console-browser.mjs | 117 +++++++++--- src/GlPythonWorkspace.tsx | 290 ++++++++++++++++++++++++++---- src/embed/style.css | 4 +- src/i18n.ts | 2 + src/pyHighlight.ts | 21 +++ src/style.css | 41 +++-- src/visualizer.ts | 46 +++++ 9 files changed, 481 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3c1045d..aa0b7b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,7 @@ Worker messages: - **Visualizer modes:** in `box` mode every call is a `Frame` box at its call site. In `substitution` mode (the default; prop `visualizerMode`, embed `?visualizer=`), a call with `shortForm` renders as `Frame` scope `'inline'`: its return expression with parameters substituted, in parentheses in operator context. The outermost box says why it isn't replaced (the call-enter step's `boxReason`, `visualizerBoxReason`). - **Tree mode («Baum», capability `tree`, embed `features=tree`):** `src/treeMode.ts`. `treeModeSteps` takes the substitution mode's steps and adds a step of its own (`treePhase` `'open'`/`'close'`, a copy of a nearby step with `step` set to the state it shows) before and after each statement expression's reductions (`TreeEpisode`: open in one frame between `openIndex` and `closeIndex`). It is also a substitution mode (`substitutionMode` is true), so inline calls, pending boxes and hosts work as there. `Frame` splices the open tree into its line like a child (`childRanges` entry with `frame: null`, `ExpressionTreeBox`) and hangs the boxes of the calls in it below. `buildExpressionTree` builds the tree from the AST and `buildSubstitutionsUpTo` (so it can't diverge from the chain; node keys `frameId:nodeId`, an inline call's body with the callee's frame). `episodeTreeLayout` lays a tree out where it opened or last grew (an inline call replaced, a box opened or closed), with room for every later text, so reductions move nothing; `TreeView` then takes that `layout`. A call of the program's own labels its argument edges with the parameters (`edgeLabel`, from the episode's `calls`; `edgeLabelPlacement` in `treeLayout.ts`, room reserved by the layout). A call that stays a box is a `kind: 'box'` node of measured size (`ExpressionTreeBox` measures the drawn box in characters and rows, growth-only; the layout gives a box several rows), drawn by `TreeView`'s `renderBox`. Check changes with `test:tree` and the «Baum» cases in `test:console`. - **Steady height (compact hosts):** while visualizing, the canvas keeps the height of the tallest step from the start (`steadyHeightCandidates`: up to 120 likely tallest steps, drawn hidden in `.visualizer-measure` through the same `rootFrame(stepViewAt(…))` as the visible step, and measured), and only grows; the console reserves the last step's output (`.visual-output-steady`). The embed caps the visualizer at 80% of `windowHeight` (the macro's). A step's view is `StepView`; compute it with `stepViewAt`, not inline, so the measurement and the visible canvas stay the same. +- **Global variables:** the module frame has no variables column; `GlobalsFooter` shows them in `.visualizer-globals`, below the scrolling `.visualizer-view`, both inside `.visualizer-frame` (from the start if `traceHasGlobals`; an invisible layer of the last step's entries reserves its height). Hover, tap or focus opens `.visualizer-globals-peek` (the definition via `globalDefinitionSource`, else the full value). An arrow with an end in the footer has `layer: 'frame'` and is drawn in `.visualizer-connectors--frame` over view and footer (`connectorBetween`, `localsValueElement`); all arrows are measured again when the view scrolls. The current call's box (`.frame-box--current`) keeps its variable row sticky at the view's bottom. - **Layout and scrolling:** nested boxes show their variables below their code (CSS). The scroll effect follows the active box horizontally. It sets `scrollLeft`/`scrollTop` on the visualizer view only; don't use `scrollIntoView`, which also scrolls the LiaScript page around the embed and moves the step buttons. - **Console and Stop:** output, REPL inputs, Tests reports and notes form one transcript (`ConsoleEntry` list after `output`). While Python is busy for more than `STOP_BUTTON_DELAY`, the run button is **Stop**: `stopPython` terminates the worker and calls `startWorker` again, so the REPL namespace is gone after a stop - except while `input()` waits: then Stop only cancels the wait (`EOFError`). The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`). - **One Monaco model for all files:** a tab switch replaces the editor's text. `updateCode` ignores flush events (`setValue`), since `@monaco-editor/react` shows a read-only file (`ProjectFile.readOnly`) with `setValue` before its `onChange` knows the new tab - the text would land in the previous file. diff --git a/README.md b/README.md index 5ed0a24..6342289 100644 --- a/README.md +++ b/README.md @@ -115,9 +115,24 @@ The visualizer's toolbar is one line: first step, previous step, next step, last step, the scrubber, the step counter, the box/substitution switch, the **⋯ Options** menu (animated steps, and with `export` the → chain's comments and copy button) and ✕ to exit (`visualizerFirstStep`, `visualizerLastStep`, -`visualizerOptions`; ✕ uses `exitVisualizer`). The variables panel only appears -when there are variables; in a visualizer narrower than 900px it sits below the -code. +`visualizerOptions`; ✕ uses `exitVisualizer`). + +A call box lists its variables in a row below its code; the box of the current +call, when it's taller than the view, keeps that row at the view's bottom edge +while the box is on screen. The file's (global) variables are in a footer below +the code that doesn't scroll with it, at every width, so the code has the full +width: name and value (`auswerten Funktion`, `x 3`) in a row that wraps, at +most about three lines high (then it scrolls). A program without global +variables has no footer; one with them has it from the start, as tall as at +the last step. Hovering over an entry, tapping it (touch) or focusing it opens +a box over the code, upwards from the pointer and inside the visualizer (at +most its width and ⅔ of its height, scrolling inside): a function's (or +class's) whole definition, highlighted, also while it's open as a call box; any +other value in full. The box stays while the pointer moves into it; it closes +when the pointer leaves both, on a tap elsewhere or with Escape. Arrows from a +call to a global function, and between a global variable and the code, end at +its entry in the footer. Text: `visualizerGlobals` (the footer's label for +screen readers), optional. The toolbar switches between two ways of showing a function call. In **substitution** mode (the default), a call to a function whose body is a @@ -219,7 +234,8 @@ keeping its first elements and its last one: `[1, 2, 3, 4, 5, …, 100]`. 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 + also below it in a call box's variables). A global variable's box (in the + footer) shows it in full, with its length. 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 @@ -245,10 +261,11 @@ leaf. `['+', ['var', 'x'], ['f', 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 +- **Variables of a call box:** 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. + dialog instead. A global variable's tree is in its box (hover or tap its + entry in the footer), below the full value. 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 @@ -257,7 +274,8 @@ 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. A tree wider -than its place (the console, the dialog, a narrow panel) scrolls sideways: +than its place (the console, the dialog, a global variable's box, a narrow +panel) scrolls sideways: it starts with its root in the middle, a ‹ or › at an edge shows that more is hidden there, and it takes the focus, so the arrow keys, Home and End scroll it. The positions come from the package's own implementation of van der Ploeg's diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index 025abdd..ab469f9 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -192,6 +192,17 @@ try { // embed draws some steps a second time, hidden, to know their height. const measured = (frame) => frame.locator('.visualizer-view[data-steady-height="done"]').waitFor({ timeout: 30000 }) const programLine = (frame) => frame.locator('.repl-line--program .repl-input') + // A global variable's box (P53): hovering over its entry in the footer. + const peekOf = async (frame, name) => { + // An open box may cover the entry: leave it first, it closes. + const page = frame.owner().page() + await page.mouse.move(0, 0) + await frame.locator('.visualizer-globals-peek').waitFor({ state: 'detached', timeout: 5000 }) + await frame.locator(`.visualizer-globals .locals-row[data-name="${name}"]`).hover() + const peek = frame.locator('.visualizer-globals-peek') + await peek.waitFor({ timeout: 5000 }) + return peek + } // A REPL input, once the REPL is free again (until then it keeps the text). const enterRepl = async (frame, text) => { const repl = frame.locator('.repl-line .repl-input') @@ -288,15 +299,20 @@ try { 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 panelValue.innerText(), short, 'the variables: the same abbreviation') 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]]) { + // A global variable: its box, on hover. A step's value: the dialog. + const peek = await peekOf(frame, 'l') + assert.equal(await peek.locator('.visualizer-globals-value-text').innerText(), full, 'the variables: the full value') + assert.equal(await peek.locator('.locals-value-length').innerText(), '100 Werte', 'the variables: its length') + await page.mouse.move(0, 0) + await peek.waitFor({ state: 'detached', timeout: 5000 }) + for (const [label, target] of [['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`) @@ -354,7 +370,8 @@ try { await measured(frame) 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`) + // The trees of global variables are in their boxes (P53). + const treeOf = (_name) => frame.locator('.visualizer-globals-peek .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' }) @@ -363,32 +380,38 @@ try { assert.equal(await toggle.count(), 0, 'no switch') } else { await page.keyboard.press('Escape') + const peek = await peekOf(frame, 'baum') assert.deepEqual(await parts(treeOf('baum')), ['+', 'var', "'x'", 'f', '1', '2'], 'the tree, labels without quotes') - assert.equal(await frame.locator('.locals-row[data-name="baum"] .value-tree-scroll').getAttribute('aria-label'), "['+', ['var', 'x'], ['f', 1, 2]]", 'its text alternative') + assert.equal(await peek.locator('.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. + // stays within the box. 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') + const peekBox = await peek.boundingBox() + assert.ok(Math.max(...boxes.map((box) => box.right)) <= peekBox.x + peekBox.width, 'within the box') + await peekOf(frame, 'namen') 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 }) + await peekOf(frame, 'gross') + assert.ok((await parts(treeOf('gross'))).includes('liste'), 'a wide tree: in the box too') + await page.mouse.move(0, 0) + await peek.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 }) + await page.keyboard.press('Escape') + await peekOf(frame, 'baum') + assert.equal(await treeOf('baum').count(), 0, 'switched off: no tree') + await page.mouse.move(0, 0) + await peek.waitFor({ state: 'detached', timeout: 5000 }) + await frame.getByRole('button', { name: 'Optionen' }).click() 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') + await peekOf(frame, 'baum') + await treeOf('baum').waitFor({ timeout: 5000 }) + await page.mouse.move(0, 0) } // The REPL: a «Baum» button for a value that reads as a tree. await frame.locator('.visualizer-exit').click() @@ -518,8 +541,7 @@ try { await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) await measured(frame) 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')) + await checkNarrow('the box', (await peekOf(frame, 'breit')).locator('.value-tree-scroll')) console.log('listtree: narrow: ok') } catch (error) { failed += 1 @@ -527,6 +549,58 @@ try { } await page.close() } + // The global variables (P53): in a footer below the code that doesn't + // scroll with it; the arrow from a call to a global function ends at its + // entry there, also with the current line at the bottom of a long + // program; hovering opens the definition, highlighted, inside the + // visualizer. The current call's box, taller than the view, keeps its + // variable row in sight above the footer. No footer without globals. + { + const label = 'global variables in the footer' + const page = await openEmbed({}, '?visualizer=box&windowHeight=600') + const frame = page.frameLocator('#embed') + try { + const body = Array.from({ length: 30 }, (_, index) => ` s = s + ${index}`).join('\n') + await page.evaluate((source) => window.send('visualize', source), `def lang(n):\n s = n\n${body}\n return s\n\n${'\n'.repeat(20)}x = lang(1)\n`) + await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + await measured(frame) + const next = frame.locator('.visualizer-controls button').nth(2) + const geometry = () => frame.locator('.visualizer-frame').evaluate(async (box) => { + await Promise.all(box.ownerDocument.getAnimations().filter((animation) => animation.effect?.getTiming().iterations !== Infinity).map((animation) => animation.finished.catch(() => {}))) + const rect = (element) => element?.getBoundingClientRect().toJSON() ?? null + const frameRect = box.getBoundingClientRect() + const ends = [...box.querySelectorAll('.visualizer-connectors--frame > path')].flatMap((arrow) => [0, arrow.getTotalLength()].map((at) => { const point = arrow.getPointAtLength(at); return { x: point.x + frameRect.left, y: point.y + frameRect.top } })) + return { frame: rect(box), view: rect(box.querySelector('.visualizer-view')), footer: rect(box.querySelector('.visualizer-globals')), entry: rect(box.querySelector('.visualizer-globals-list > .locals-row[data-name="lang"]')), arrowEnds: ends, sticky: rect(box.querySelector('.frame-box--current > .frame-vars')) } + }) + // Until the call of lang is about to be looked up: its arrow. + let state = await geometry() + const atEntry = ({ arrowEnds, entry }) => arrowEnds.some((point) => point.y >= entry.top - 1 && point.y <= entry.bottom + 1 && point.x >= entry.left - 1 && point.x <= entry.right + 1) + for (let steps = 0; !(state.entry && atEntry(state)) && steps < 5; steps++) { await next.click(); state = await geometry() } + assert.ok(state.footer && state.footer.top >= state.view.bottom - 1 && state.footer.bottom <= state.frame.bottom + 1, 'the footer below the code, in the visualizer') + assert.ok(atEntry(state), `an arrow from the entry of lang: ${JSON.stringify(state)}`) + // The box of lang, taller than the view: its variables at the bottom. + for (let steps = 0; !state.sticky && steps < 5; steps++) { await next.click(); state = await geometry() } + assert.ok(state.sticky && state.sticky.bottom <= state.footer.top + 1 && state.sticky.bottom >= state.view.bottom - 2, `the current box's variables above the footer: ${JSON.stringify(state)}`) + const peek = await peekOf(frame, 'lang') + assert.match(await peek.innerText(), /^def lang\(n\):\n {4}s = n\n/, 'the definition') + assert.ok(await peek.locator('.py-tok-keyword').count() >= 2, 'highlighted') + const peekBox = await peek.boundingBox() + const frameBox = await frame.locator('.visualizer-frame').boundingBox() + assert.ok(peekBox.y >= frameBox.y - 1 && peekBox.x >= frameBox.x - 1 && peekBox.x + peekBox.width <= frameBox.x + frameBox.width + 1 && peekBox.height <= frameBox.height * 2 / 3 + 1, 'inside the visualizer, at most two thirds of its height') + assert.ok(await peek.evaluate((element) => element.scrollHeight > element.clientHeight), 'a long definition scrolls') + await page.mouse.move(0, 0) + await peek.waitFor({ state: 'detached', timeout: 5000 }) + await page.evaluate((source) => window.send('visualize', source), 'print(1 + 2)\n') + await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 60000 }) + await measured(frame) + assert.equal(await frame.locator('.visualizer-globals').count(), 0, 'no footer without globals') + console.log(`${label}: ok`) + } catch (error) { + failed += 1 + console.error(`${label}: ${error.message}`) + } + await page.close() + } // A steady height while stepping, in both modes: a recursion taller than // the cap (windowHeight=900: the visualizer at most 720px) and a loop that // prints. The current line is always in the visualizer's view. @@ -541,7 +615,10 @@ try { await page.evaluate((program) => window.send('visualize', program), source) await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) await measured(frame) - const state = () => frame.locator('.embed-app').evaluate((app) => { + // Once the step's animations are over (a call box flies in from its + // function's entry in the footer, below the code). + const state = () => frame.locator('.embed-app').evaluate(async (app) => { + await Promise.all(app.ownerDocument.getAnimations().filter((animation) => animation.effect?.getTiming().iterations !== Infinity).map((animation) => animation.finished.catch(() => {}))) const view = app.querySelector('.visualizer-view') const line = view.querySelector('.visual-current-line') const box = view.getBoundingClientRect() diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index 612653d..00fdc03 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -5,8 +5,8 @@ import type { Monaco, OnMount } from '@monaco-editor/react' import { AlertTriangle, BookOpen, Copy, Square, FileCode2, FlaskConical, Lock, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronRight, Ellipsis, Play, RotateCcw, SkipBack, SkipForward, Sparkles, SquareTerminal, Terminal, Workflow, X } from 'lucide-react' import { projectImportsGturtle, type Project } from './project' import { GraphicsWindow, type TurtleCommand } from './GraphicsWindow' -import { buildAnnotationsUpTo, buildEvaluationChain, buildExecutedLinesUpTo, buildLocalsUpTo, buildSubstitutionsUpTo, collapseDeepCallChain, hasEvaluationChain, fullValuesUpTo, valueTreesUpTo, hiddenRangesWithin, removedLines, computeLineOffsets, computeOpenFramesUpTo, indexAstNodes, lineNumberAtOffset, locateNodeRenderedRange, nodeRange, renderFrameSource, substitutionModeSteps, type AstNode, type CollapsedCalls, type FullValue, type OpenFrame, type BoxReason, type TraceResult, type TraceStep, type VisualizerMode } from './visualizer' -import { tokenizePythonFragment } from './pyHighlight' +import { buildAnnotationsUpTo, buildEvaluationChain, buildExecutedLinesUpTo, buildLocalsUpTo, buildSubstitutionsUpTo, collapseDeepCallChain, globalDefinitionSource, traceHasGlobals, hasEvaluationChain, fullValuesUpTo, valueTreesUpTo, hiddenRangesWithin, removedLines, computeLineOffsets, computeOpenFramesUpTo, indexAstNodes, lineNumberAtOffset, locateNodeRenderedRange, nodeRange, renderFrameSource, substitutionModeSteps, type AstNode, type CollapsedCalls, type FullValue, type OpenFrame, type BoxReason, type TraceResult, type TraceStep, type VisualizerMode } from './visualizer' +import { tokenizePythonFragment, tokenizePythonLines } from './pyHighlight' import { GLPYTHON_PYTHON, registerGlpythonPython } from './pythonLanguage' import { defineGlpythonTheme, GLPYTHON_THEME } from './monacoTheme' import type { ErrorHint, ErrorHintContext } from './errorHints' @@ -155,6 +155,8 @@ type CoreStrings = { visualizerValueLength?: (length: number, kind: string | null) => string visualizerFullValueTitle?: (name: string | null) => string visualizerFullValueCut?: string + // The label of the visualizer's footer of global variables (for screen readers). + visualizerGlobals?: 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. @@ -613,6 +615,142 @@ function FullValueDialog({ value, name, tree, t, onClose }: { value: FullValue; ) } +// The module's variables, in a footer below the visualizer's code that +// doesn't scroll with it: name and value as a box's row shows them, in a row +// that wraps (at most about three lines, then it scrolls). Hovering over an +// entry, tapping it or focusing it opens a box over the code, upwards from +// the pointer: a function's (or class's) whole definition, highlighted, or +// the full value. The box stays while the pointer is on the entry or on the +// box; a tap elsewhere or Escape closes it. Pointer events, so mouse, +// trackpad, pen and touch share one path. +type GlobalsPeek = { name: string; x: number; y: number } +const GLOBALS_PEEK_OPEN_DELAY = 120 +const GLOBALS_PEEK_CLOSE_DELAY = 180 +function GlobalsFooter({ entries, reserveEntries, valueKindLabels, definitionOf, monaco, frameRef, resetKey }: { + entries: Array<[string, string | null]> + // The variables at the last step, drawn invisibly below the current ones, + // so the footer is as tall from the start as it will be at the end. + reserveEntries: Array<[string, string]> + valueKindLabels: Record + definitionOf: (name: string) => { path: string; text: string } | null + monaco: Monaco | null + frameRef: { current: HTMLDivElement | null } + resetKey: unknown +}) { + const access = useContext(FullValueContext) + const footerRef = useRef(null) + const peekRef = useRef(null) + const [peek, setPeek] = useState(null) + const timerRef = useRef(null) + const clearTimer = () => { if (timerRef.current !== null) { window.clearTimeout(timerRef.current); timerRef.current = null } } + const later = (delay: number, action: () => void) => { clearTimer(); timerRef.current = window.setTimeout(() => { timerRef.current = null; action() }, delay) } + useEffect(() => clearTimer, []) + // Its height only grows during one visualization, so the code area above + // doesn't jump as variables come and go. + const heightRef = useRef(0) + useLayoutEffect(() => { + heightRef.current = 0 + if (footerRef.current) footerRef.current.style.minHeight = '' + }, [resetKey]) + useLayoutEffect(() => { + const footer = footerRef.current + if (!footer || footer.offsetHeight <= heightRef.current) return + heightRef.current = footer.offsetHeight + footer.style.minHeight = `${heightRef.current}px` + }) + const shown = peek !== null && entries.some(([name]) => name === peek.name) ? peek : null + const shownEntry = shown ? entries.find(([name]) => name === shown.name) ?? null : null + + // Upwards from the pointer, inside the visualizer: at most its width and + // two thirds of its height (CSS), moved left where it would stick out. + useLayoutEffect(() => { + const box = peekRef.current + const frame = frameRef.current + if (!box || !frame || !shown) return + const bounds = frame.getBoundingClientRect() + const y = Math.min(Math.max(shown.y, bounds.top), bounds.bottom) - bounds.top + box.style.bottom = `${bounds.height - y}px` + box.style.maxHeight = `${Math.max(48, Math.min(bounds.height * 2 / 3, y))}px` + box.style.left = '0px' + const left = Math.min(Math.max(0, shown.x - bounds.left - 16), Math.max(0, bounds.width - box.offsetWidth)) + box.style.left = `${left}px` + }) + + useEffect(() => { + if (!shown) return + const closeOutside = (event: PointerEvent) => { + const target = event.target as Node | null + if (target && (peekRef.current?.contains(target) || footerRef.current?.contains(target))) return + setPeek(null) + } + const closeOnEscape = (event: globalThis.KeyboardEvent) => { if (event.key === 'Escape') setPeek(null) } + document.addEventListener('pointerdown', closeOutside) + document.addEventListener('keydown', closeOnEscape) + return () => { + document.removeEventListener('pointerdown', closeOutside) + document.removeEventListener('keydown', closeOnEscape) + } + }, [shown]) + + const peekId = 'glp-globals-peek' + const entryHandlers = (name: string) => ({ + onPointerEnter: (event: ReactPointerEvent) => { + if (event.pointerType === 'touch') return + const { clientX: x, clientY: y } = event + if (shown) { clearTimer(); setPeek({ name, x, y }) } else later(GLOBALS_PEEK_OPEN_DELAY, () => setPeek({ name, x, y })) + }, + onPointerLeave: (event: ReactPointerEvent) => { if (event.pointerType !== 'touch') later(GLOBALS_PEEK_CLOSE_DELAY, () => setPeek(null)) }, + onPointerDown: (event: ReactPointerEvent) => { + clearTimer() + if (event.pointerType === 'touch' && shown?.name === name) { setPeek(null); return } + setPeek({ name, x: event.clientX, y: event.clientY }) + }, + onFocus: (event: { currentTarget: HTMLElement }) => { + if (shown?.name === name) return + const rect = event.currentTarget.getBoundingClientRect() + setPeek({ name, x: rect.left, y: rect.top }) + }, + onBlur: (event: { relatedTarget: EventTarget | null }) => { if (!(event.relatedTarget instanceof Node && peekRef.current?.contains(event.relatedTarget))) setPeek(null) }, + }) + + let peekContent: ReactNode = null + if (shown && shownEntry) { + const [name, value] = shownEntry + const definition = value !== null && (value === 'function' || value === 'class') ? definitionOf(name) : null + if (definition) { + peekContent =
{tokenizePythonLines(monaco, definition.text).map((runs, index) => {index > 0 && '\n'}{runs.map((run, runIndex) => {run.text})})}
+ } else { + const full = value === null ? null : lookupFullValue(access, value) + const text = value === null ? '…' : Object.hasOwn(valueKindLabels, value) ? valueKindLabels[value] : full ? `${full.text}${full.cut ? '…' : ''}` : value + const length = full && access ? fullValueLength(full, access.t) : null + const tree = value !== null && access?.showTrees ? access.trees.get(value) : undefined + peekContent = <> +
{name}{length && {length}}
+
{text}
+ {full?.cut && access &&

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

} + {tree && } + + } + } + return ( + <> +
+ +
{entries.map(([name, value]) =>
+ {name} + {value === null ? {'…'} : Object.hasOwn(valueKindLabels, value) ? {valueKindLabels[value]} : {value}} +
)}
+
+ {peekContent && } + + ) +} + +function GlobalsPeekTree({ tree, label }: { tree: ValueTree; label: string }) { + const node = useMemo(() => treeFromValue(tree), [tree]) + return
+} + // 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). @@ -1062,8 +1200,12 @@ function Frame(props: FrameProps) { // shows by itself that the call stays a box. const showBoxHint = substitutionMode && trees === null && scope.kind === 'call' && !insideBox const boxReason = showBoxHint ? trace.find((step) => step.kind === 'call-enter' && step.frameId === frameId)?.boxReason ?? null : null + // The box of the current call (or of the inline call the current step + // runs in): its variable row stays in sight at the bottom of the view + // while the box is taller than it (CSS, position: sticky). + const isCurrentBox = scope.kind === 'call' && (isFrameActive || activeHost?.frameId === frameId) return ( -
+
{showBoxHint && openFrame &&
{boxHint(boxReason, openFrame.funcName)}
} {lines.map((line, index) => { @@ -1110,8 +1252,9 @@ function Frame(props: FrameProps) { ) })}
- {/* The variables panel only when there are variables to show. */} - {localEntries.length > 0 && <> + {/* The variables row only when there are variables to show; the + module's are in the visualizer's footer (GlobalsFooter). */} + {!isModule && localEntries.length > 0 && <>
{localEntries.map(([name, value]) =>
{name}{value === null ? {'\u2026'} : Object.hasOwn(valueKindLabels, value) ? {valueKindLabels[value]} : }
)} @@ -1210,6 +1353,55 @@ function canvasOffset(element: HTMLElement, canvas: HTMLElement): { left: number return { left, top } } +// `layer`: an arrow with an end in the footer of global variables is +// drawn over the whole visualizer (view and footer, `.visualizer-frame`), +// in its coordinates, since the view clips what it scrolls; the others in +// the canvas. Both are measured again when the view scrolls (the footer +// and a sticky variable row stay put while the code moves), from +// `source`/`target`. +type Connector = { d: string; value: string; kind: 'var' | 'funcLookup' | 'arg'; x1: number; y1: number; layer: 'canvas' | 'frame'; source: Element; target: Element } + +// One bezier path between two rendered elements, in the coordinates of its +// layer (`canvasRect` or `frameRect`). In the canvas it leaves the source on +// the side facing the target and enters the target from the side facing the +// source, so the curve never doubles back through either end's text (locals +// -> code, code -> locals, code -> code). An arrow to or from the footer of +// global variables runs vertically: from the entry's top up to the code, or +// down from the code into the entry. +function connectorBetween(sourceEl: Element, targetEl: Element, kind: Connector['kind'], canvasRect: DOMRect, frameRect: DOMRect): Connector { + const layer = sourceEl.closest('.visualizer-globals') || targetEl.closest('.visualizer-globals') ? 'frame' : 'canvas' + const rect = layer === 'frame' ? frameRect : canvasRect + const sourceRect = sourceEl.getBoundingClientRect() + const targetRect = targetEl.getBoundingClientRect() + // Strip a leading "=" from parameter-annotation sources (e.g. the "=3" + // shown next to "n" in "def fact(n=3):") so the flying copy reads as a + // plain value, matching what lands in the locals row. + const value = (sourceEl.textContent?.trim() ?? '').replace(/^=/, '') + if (layer === 'frame') { + const down = targetRect.top + targetRect.bottom >= sourceRect.top + sourceRect.bottom ? 1 : -1 + const x1 = sourceRect.left - rect.left + Math.min(sourceRect.width / 2, 24) + const x2 = targetRect.left - rect.left + Math.min(targetRect.width / 2, 24) + const y1 = (down === 1 ? sourceRect.bottom : sourceRect.top) - rect.top + const y2 = (down === 1 ? targetRect.top : targetRect.bottom) - rect.top + const dy = Math.max(Math.abs(y2 - y1) * 0.5, 30) + return { d: `M ${x1} ${y1} C ${x1} ${y1 + dy * down}, ${x2} ${y2 - dy * down}, ${x2} ${y2}`, value, kind, x1, y1, layer, source: sourceEl, target: targetEl } + } + const y1 = sourceRect.top - rect.top + sourceRect.height / 2 + const y2 = targetRect.top - rect.top + targetRect.height / 2 + const dir = targetRect.left + targetRect.right >= sourceRect.left + sourceRect.right ? 1 : -1 + const x1 = (dir === 1 ? sourceRect.right : sourceRect.left) - rect.left + const x2 = (dir === 1 ? targetRect.left : targetRect.right) - rect.left + const dx = Math.max(Math.abs(x2 - x1) * 0.5, 40) + return { d: `M ${x1} ${y1} C ${x1 + dx * dir} ${y1}, ${x2 - dx * dir} ${y2}, ${x2} ${y2}`, value, kind, x1, y1, layer, source: sourceEl, target: targetEl } +} + +// The value of a variable in its frame's row: the module's in the footer +// (outside the canvas), a call's in its box. +function localsValueElement(canvas: HTMLElement, frameId: number, name: string): Element | null { + if (frameId === 0) return canvas.closest('.visualizer-frame')?.querySelector(`.visualizer-globals-list > .locals-row[data-name="${CSS.escape(name)}"] .locals-value`) ?? null + return canvas.querySelector(`.frame-box[data-frame-id="${frameId}"] > .frame-vars > .locals-row[data-name="${CSS.escape(name)}"] .locals-value`) +} + // The doctest report in the output panel: a summary, glpython's hints, and // every failing example in doctest's own words with a button to visualize it. // The visualizer bar's «⋯ Options» menu. It opens like any button (click, @@ -1460,6 +1652,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // Monaco's view-zone/content-widget machinery. const visualizerScrollRef = useRef(null) const visualizerCanvasRef = useRef(null) + const visualizerFrameRef = useRef(null) const inputChannelRef = useRef<{ control: Int32Array; payload: Uint8Array } | null>(null) const selectedFile = project.files[activeFile] ?? project.files[project.mainFile] const selectedPath = project.files[activeFile] ? activeFile : project.mainFile @@ -1984,7 +2177,6 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // `pendingReturnOriginRef`) - it never draws an arrow at all, instead // flying the real destination span in directly, so it has no 'return' // entry in this union. - type Connector = { d: string; value: string; kind: 'var' | 'funcLookup' | 'arg'; x1: number; y1: number } const [connectorPaths, setConnectorPaths] = useState([]) // Short-lived "value flying along the arrow it just consumed" ghosts, @@ -1992,7 +2184,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // previously-shown connector disappear. Purely decorative (and disabled // along with every other animation via `animationsEnabled`), so each one // just removes itself again once its CSS motion-path animation ends. - const [flights, setFlights] = useState<{ id: number; d: string; value: string }[]>([]) + const [flights, setFlights] = useState<{ id: number; d: string; value: string; layer: 'canvas' | 'frame' }[]>([]) const flightIdRef = useRef(0) const prevConnectorsRef = useRef([]) // The about-to-close callee frame's own return-line position, measured @@ -2132,6 +2324,14 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const errorHost = isAtErrorStep ? inlineHostOf(errorTraceStep.frameId) : null const currentActiveTrees = useMemo(() => treeSteps ? activeTreesAt(treeSteps.episodes, clampedStepIndex) : new Map(), [treeSteps, clampedStepIndex]) const currentStepView: StepView = { currentStep: currentTraceStep, nextStep: nextTraceStep, openFrames, pendingCall: nextStepPendingCall, framesForRender, pendingCallLocals, collapsedCalls, declareTarget: nextStepDeclareTarget, isAtErrorStep, isAtLastStep, activeHost, errorHost, activeTrees: currentActiveTrees } + // The module's variables, in the footer below the code (GlobalsFooter): + // there from the start if the program has any, else no footer at all. A + // name the next step declares is there already, without a value yet. + const showGlobalsFooter = useMemo(() => traceHasGlobals(visualTrace?.trace ?? []), [visualTrace]) + const finalGlobalEntries = useMemo(() => Object.entries(buildLocalsUpTo(visualTrace?.trace ?? [], Infinity, 0)), [visualTrace]) + const globalEntries: Array<[string, string | null]> = Object.entries(buildLocalsUpTo(visualTrace?.trace ?? [], currentTraceStep?.step ?? -1, 0)) + if (nextStepDeclareTarget?.frameId === 0 && !globalEntries.some(([name]) => name === nextStepDeclareTarget.varName)) globalEntries.push([nextStepDeclareTarget.varName, null]) + const globalDefinitionOf = (name: string) => globalDefinitionSource(visualTrace?.trace ?? [], currentTraceStep?.step ?? -1, name, astIndexByFile, visualSources, visualRootPath ?? project.mainFile) // Why a call stays a box in the substitution and tree modes, at its box. const boxHintFor = (reason: BoxReason | null, funcName: string): ReactNode => { const text = reason ? t.visualizerBoxReason?.(reason) : undefined @@ -2179,21 +2379,8 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // deferred per-argument measurement below can re-measure the canvas // fresh once the callee frame-box's grow animation has actually // finished, instead of reusing a rect captured while it was still mid-flight. - const pathBetween = (sourceEl: Element, targetEl: Element, kind: Connector['kind'], rect: DOMRect): Connector => { - const sourceRect = sourceEl.getBoundingClientRect() - const targetRect = targetEl.getBoundingClientRect() - const y1 = sourceRect.top - rect.top + sourceRect.height / 2 - const y2 = targetRect.top - rect.top + targetRect.height / 2 - const dir = targetRect.left + targetRect.right >= sourceRect.left + sourceRect.right ? 1 : -1 - const x1 = (dir === 1 ? sourceRect.right : sourceRect.left) - rect.left - const x2 = (dir === 1 ? targetRect.left : targetRect.right) - rect.left - const dx = Math.max(Math.abs(x2 - x1) * 0.5, 40) - // Strip a leading "=" from parameter-annotation sources (e.g. the - // "=3" shown next to "n" in "def fact(n=3):") so the flying copy - // reads as a plain value, matching what lands in the locals row. - const value = (sourceEl.textContent?.trim() ?? '').replace(/^=/, '') - return { d: `M ${x1} ${y1} C ${x1 + dx * dir} ${y1}, ${x2 - dx * dir} ${y2}, ${x2} ${y2}`, value, kind, x1, y1 } - } + const pathBetween = (sourceEl: Element, targetEl: Element, kind: Connector['kind'], rect: DOMRect): Connector => + connectorBetween(sourceEl, targetEl, kind, rect, visualizerFrameRef.current?.getBoundingClientRect() ?? rect) const paths: Connector[] = [] @@ -2205,9 +2392,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // set at once, since they key off disjoint AST node types. const varAnchor = nextStepVarSource ?? nextStepDeclareTarget if (varAnchor) { - const varEl = canvas.querySelector( - `.frame-box[data-frame-id="${varAnchor.frameId}"] > .frame-vars > .locals-row[data-name="${CSS.escape(varAnchor.varName)}"] .locals-value`, - ) + const varEl = localsValueElement(canvas, varAnchor.frameId, varAnchor.varName) const codeEl = canvas.querySelector('.visual-next-step') if (varEl && codeEl) paths.push(pathBetween(nextStepVarSource ? varEl : codeEl, nextStepVarSource ? codeEl : varEl, 'var', canvasRect)) } @@ -2219,9 +2404,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // a different step kind, so it's always disjoint from both `varAnchor` // cases and can safely add its own path alongside them. if (nextStepFuncLookup) { - const funcEl = canvas.querySelector( - `.frame-box[data-frame-id="${nextStepFuncLookup.frameId}"] > .frame-vars > .locals-row[data-name="${CSS.escape(nextStepFuncLookup.varName)}"] .locals-value`, - ) + const funcEl = localsValueElement(canvas, nextStepFuncLookup.frameId, nextStepFuncLookup.varName) const codeEl = canvas.querySelector('.visual-next-step') if (funcEl && codeEl) paths.push(pathBetween(funcEl, codeEl, 'funcLookup', canvasRect)) } @@ -2303,9 +2486,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (!annotation) continue const [nodeId] = annotation const argEl = canvas.querySelector(`.frame-box[data-frame-id="${calleeFrameId}"] [data-annotation-node-id="${nodeId}"]`) - const paramEl = canvas.querySelector( - `.frame-box[data-frame-id="${calleeFrameId}"] > .frame-vars > .locals-row[data-name="${CSS.escape(paramName)}"] .locals-value`, - ) + const paramEl = localsValueElement(canvas, calleeFrameId, paramName) if (argEl && paramEl) result.push(pathBetween(argEl, paramEl, 'arg', rect)) } return result @@ -2339,8 +2520,13 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const boxRect = boxEl.getBoundingClientRect() const cx = boxRect.left - canvasRect.left + boxRect.width / 2 const cy = boxRect.top - canvasRect.top + boxRect.height / 2 - boxEl.style.setProperty('--grow-dx', `${origin.x1 - cx}px`) - boxEl.style.setProperty('--grow-dy', `${origin.y1 - cy}px`) + // Where the function's row is now, in the canvas (it may be in the + // footer, which doesn't scroll with the canvas). + const originRect = origin.source.isConnected ? origin.source.getBoundingClientRect() : null + const ox = originRect ? originRect.left - canvasRect.left + originRect.width / 2 : origin.x1 + const oy = originRect ? originRect.top - canvasRect.top + originRect.height / 2 : origin.y1 + boxEl.style.setProperty('--grow-dx', `${ox - cx}px`) + boxEl.style.setProperty('--grow-dy', `${oy - cy}px`) } else { boxEl.style.removeProperty('--grow-dx') boxEl.style.removeProperty('--grow-dy') @@ -2446,6 +2632,36 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { setConnectorPaths(paths) }, [nextStepVarSource, nextStepDeclareTarget, nextStepPendingCall, nextStepFuncLookup, nextStepReturnFlight, currentTraceStep, openFrames, clampedStepIndex]) + // The footer of global variables and a sticky variable row stay where they + // are while the code scrolls: their arrows are measured again (once per + // frame), as are the ones a resize moves. + useEffect(() => { + const view = visualizerScrollRef.current + const frame = visualizerFrameRef.current + if (!isVisualizing || !view || !frame) return + let pending = 0 + const remeasure = () => { + pending = 0 + const canvas = visualizerCanvasRef.current + if (!canvas) return + const canvasRect = canvas.getBoundingClientRect() + const frameRect = frame.getBoundingClientRect() + const again = (connector: Connector) => connector.source.isConnected && connector.target.isConnected ? connectorBetween(connector.source, connector.target, connector.kind, canvasRect, frameRect) : connector + if (prevConnectorsRef.current.length === 0) return + prevConnectorsRef.current = prevConnectorsRef.current.map(again) + setConnectorPaths((current) => current.map(again)) + } + const schedule = () => { if (!pending) pending = requestAnimationFrame(remeasure) } + view.addEventListener('scroll', schedule, { passive: true }) + const observer = new ResizeObserver(schedule) + observer.observe(frame) + return () => { + view.removeEventListener('scroll', schedule) + observer.disconnect() + if (pending) cancelAnimationFrame(pending) + } + }, [isVisualizing]) + // Keeps the currently executing line in view as steps advance, with // ordinary DOM scrolling - directly replacing the old Monaco // view-zone/content-widget machinery, whose geometry bookkeeping across @@ -2474,12 +2690,16 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { let nextTop = container.scrollTop const line = root.querySelector('.visual-current-line') + // The current box's variable row may stick to the bottom of the view + // (.frame-box--current), covering what's behind it. + const stickyRow = root.querySelector('.frame-box--current > .frame-vars') + const visibleHeight = container.clientHeight - (stickyRow && !stickyRow.contains(line) ? stickyRow.offsetHeight : 0) if (line) { const canvasTop = canvas.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop const top = canvasTop + canvasOffset(line, canvas).top const bottom = top + line.offsetHeight if (top - margin < nextTop) nextTop = top - margin - else if (bottom + margin > nextTop + container.clientHeight) nextTop = bottom + margin - container.clientHeight + else if (bottom + margin > nextTop + visibleHeight) nextTop = Math.min(top - margin, bottom + margin - visibleHeight) } const frameId = currentTraceStep?.frameId ?? 0 @@ -2575,7 +2795,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.tree && }
{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 })}{isMarkdown && !isVisualizing &&
}
- const editorWrapNode =
{isVisualizing ?
0 ? 'measuring' : 'done') : undefined}>
{rootFrame(currentStepView)}{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})}
}
{steadyMeasureNode}
: <>{isMarkdown && markdownView === 'page' &&
}
{ 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 ?
0 ? 'measuring' : 'done') : undefined}>
{rootFrame(currentStepView)}{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => connector.layer === 'canvas' && )}{animationsEnabled && flights.map((flight) => flight.layer === 'canvas' && )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => flight.layer === 'canvas' && {flight.value})}
}
{steadyMeasureNode}
{showGlobalsFooter && }{(connectorPaths.some((connector) => connector.layer === 'frame') || flights.some((flight) => flight.layer === 'frame')) && {connectorPaths.map((connector, index) => connector.layer === 'frame' && )}{animationsEnabled && flights.map((flight) => flight.layer === 'frame' && )}}{animationsEnabled && flights.some((flight) => flight.layer === 'frame') &&
{flights.map((flight) => flight.layer === 'frame' && {flight.value})}
}
: <>{isMarkdown && markdownView === 'page' &&
}
{ 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}{compact && }
: <>
{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 diff --git a/src/embed/style.css b/src/embed/style.css index 1b6bc04..289bdda 100644 --- a/src/embed/style.css +++ b/src/embed/style.css @@ -82,7 +82,7 @@ body, min-height: 140px; } -.embed-app .visualizer-view { +.embed-app .visualizer-frame { max-height: var(--glp-visualizer-max-height, none); } @@ -97,7 +97,7 @@ body, /* "?size=large": bigger code (20px, line height 1.6), the bar's text 16px, and the visualizer at least as tall as the macro asks for (60% of the course window). */ -.embed-app--large .visualizer-view { +.embed-app--large .visualizer-frame { font-size: 20px; line-height: 1.6; } diff --git a/src/i18n.ts b/src/i18n.ts index 26522ad..1401984 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -34,6 +34,7 @@ export const coreStrings: Record = { 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.', + visualizerGlobals: 'Global variables', visualizerCloseFullValue: 'Close', visualizerListTrees: 'Show lists as trees', showTree: 'Show tree', @@ -132,6 +133,7 @@ export const coreStrings: Record = { 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.', + visualizerGlobals: 'Globale Variablen', visualizerCloseFullValue: 'Schliessen', visualizerListTrees: 'Listen als Baum zeigen', showTree: 'Baum zeigen', diff --git a/src/pyHighlight.ts b/src/pyHighlight.ts index 6c915b7..006196c 100644 --- a/src/pyHighlight.ts +++ b/src/pyHighlight.ts @@ -42,3 +42,24 @@ export function tokenizePythonFragment(monaco: Monaco, text: string): PyTextRun[ } return runs } + +// A whole piece of source (a function's definition) as coloured runs per +// line, tokenized in one go so a string or comment spanning lines keeps its +// colour. +export function tokenizePythonLines(monaco: Monaco | null, text: string): PyTextRun[][] { + const lines = text.split('\n') + if (!monaco) return lines.map((line) => line === '' ? [] : [{ text: line }]) + registerGlpythonPython(monaco) + const tokenized = monaco.editor.tokenize(text, GLPYTHON_PYTHON) + return lines.map((line, index) => { + const tokens = tokenized[index] ?? [] + const runs: PyTextRun[] = [] + if (tokens.length === 0 && line !== '') runs.push({ text: line }) + for (let t = 0; t < tokens.length; t++) { + const start = tokens[t].offset + const end = t + 1 < tokens.length ? tokens[t + 1].offset : line.length + if (end > start) runs.push({ text: line.slice(start, end), className: pyTokenClassName(tokens[t].type) }) + } + return runs + }) +} diff --git a/src/style.css b/src/style.css index 3f6e285..1f6f37c 100644 --- a/src/style.css +++ b/src/style.css @@ -197,7 +197,30 @@ .visual-loop-annotation { color: var(--glp-text-muted) !important; font-style: italic; } .visual-next-step { outline: 1.5px dashed var(--glp-current-outline); outline-offset: -1px; border-radius: var(--glp-radius); } -.visualizer-view { min-height: 0; flex: 1; overflow: auto; container-type: inline-size; padding: 12px 0; background: var(--glp-surface); color: var(--glp-text); font-family: var(--glp-font-code); font-size: 15px; line-height: 1.6; } +/* The visualizer: the code in a view that scrolls, and below it the footer + of the module's variables (GlobalsFooter), which doesn't; their hover box + and the arrows to and from the footer are drawn over both. */ +.visualizer-frame { position: relative; min-height: 0; flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--glp-surface); color: var(--glp-text); font-family: var(--glp-font-code); font-size: 15px; line-height: 1.6; } +/* Its bottom padding is the canvas's, so a sticky variable row (below) sits + on the view's bottom edge, with no code showing below it. */ +.visualizer-view { min-height: 0; flex: 1 1 auto; overflow: auto; container-type: inline-size; padding: 12px 0 0; } +.visualizer-canvas { padding-bottom: 12px; } +.visualizer-globals { flex: none; display: grid; min-height: calc(1.6em + 9px); max-height: calc(4.8em + 9px); overflow-y: auto; padding: 4px 12px; border-top: 1px solid var(--glp-border); background: var(--glp-surface); } +.visualizer-globals-list, .visualizer-globals-reserve { grid-area: 1 / 1; min-width: 0; display: flex; flex-wrap: wrap; align-content: flex-start; gap: 0 20px; } +.visualizer-globals-reserve { visibility: hidden; } +.visualizer-globals .locals-row { min-width: 0; padding: 0 4px; margin: 0 -4px; border-bottom: 0; border-radius: var(--glp-radius); cursor: default; touch-action: manipulation; -webkit-tap-highlight-color: transparent; } +.visualizer-globals .locals-row:hover, .visualizer-globals .locals-row[aria-describedby] { background: var(--glp-bg); } +.visualizer-globals .locals-row:focus-visible { outline: 2px solid var(--glp-accent); outline-offset: -2px; } +.visualizer-globals .locals-value { overflow-wrap: anywhere; } +/* Its box: upwards from the pointer, inside the visualizer (left, bottom and + max-height set by GlobalsFooter), at most its width; longer content scrolls. */ +.visualizer-globals-peek { position: absolute; z-index: 30; left: 0; box-sizing: border-box; width: max-content; max-width: 100%; overflow: auto; padding: 8px 12px; background: var(--glp-bg); border: 1px solid var(--glp-border); border-radius: var(--glp-radius); box-shadow: 0 8px 20px rgba(0, 0, 0, .12); overscroll-behavior: contain; } +.visualizer-globals-definition { margin: 0; font: inherit; white-space: pre; } +.visualizer-globals-value { display: flex; align-items: baseline; gap: 10px; } +.visualizer-globals-value .locals-value-length { display: inline; } +.visualizer-globals-value-text { margin: 0; font: inherit; white-space: pre-wrap; overflow-wrap: anywhere; } +.visualizer-globals-peek .full-value-cut { margin: 6px 0 0; } +.visualizer-globals-peek .full-value-tree { margin-top: 8px; } .visualizer-canvas { position: relative; } /* Steps drawn only to measure their height (compact hosts, see steadyHeightCandidates): laid out at the canvas's width, never shown. */ @@ -288,6 +311,9 @@ .visual-closing-box-ghost { position: absolute; animation: visual-closing-box-fade .4s ease-in forwards; } @keyframes visual-closing-box-fade { from { opacity: 1; } to { opacity: 0; } } .visualizer-flights { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 3; } +/* The arrows (and flights) with an end in the footer, over view and footer. */ +.visualizer-connectors--frame { z-index: 25; overflow: hidden; } +.visualizer-flights--frame { z-index: 26; overflow: hidden; } .visual-flight-value { position: absolute; top: 0; left: 0; offset-rotate: 0deg; transform: translate(-50%, -50%); background: var(--glp-current); color: var(--glp-text); padding: 0 5px; border-radius: var(--glp-radius); font: 600 13px/18px var(--glp-font-code); white-space: nowrap; animation: visual-flight-move .6s ease-out forwards; } .visualizer-connectors path.visual-flight-arrow { animation: visual-flight-arrow-fade .6s ease-out forwards; } /* Travel at full opacity for the whole trip; only fade once the value has actually arrived at its destination (last 20%), so the arrow it traced stays visible for the full flight and disappears together with the value. */ @@ -345,18 +371,13 @@ .frame-box:not(.frame-box--root) > .frame-vars { width: auto; display: flex; flex-wrap: wrap; gap: 0 16px; padding: 3px 12px 5px; border-top: 1px solid var(--glp-border); overflow: visible; contain: inline-size; } .frame-box:not(.frame-box--root) > .frame-vars .locals-row { min-width: 0; padding: 0; border-bottom: 0; } .frame-box:not(.frame-box--root) > .frame-vars .locals-value { overflow-wrap: anywhere; } +/* The current call's box, taller than the view: its variable row stays at + the bottom of the view while the box is on screen. Only this box's, so + rows never stack. */ +.frame-box--current > .frame-vars { position: sticky; bottom: 0; z-index: 1; background: var(--glp-bg); } .visual-param-annotation { color: var(--glp-text) !important; } -/* In a narrow visualizer (e.g. LiaScript with its table of contents open) - the file's variables go below the code, in a row like a box's, instead of - a panel beside it. */ -@container (max-width: 899px) { - .frame-box--root { flex-direction: column; } - .frame-box--root > .frame-divider { display: none; } - .frame-box--root > .frame-vars { width: auto; display: flex; flex-wrap: wrap; gap: 0 20px; margin-top: 8px; padding: 8px 12px 0; border-top: 1px solid var(--glp-border); overflow: visible; } - .frame-box--root > .frame-vars .locals-row { min-width: 0; padding: 0; border-bottom: 0; } -} .visualizer-bar { flex-wrap: wrap; row-gap: 6px; padding-top: 6px; padding-bottom: 6px; } diff --git a/src/visualizer.ts b/src/visualizer.ts index 3738575..27b4e41 100644 --- a/src/visualizer.ts +++ b/src/visualizer.ts @@ -832,6 +832,52 @@ export function buildLocalsUpTo(trace: TraceStep[], uptoStep: number, frameId = return locals } +// Whether the module frame has a variable at any step: only then does the +// visualizer show its footer of global variables (from the start, so the +// code area doesn't shrink when the first one appears). +export function traceHasGlobals(trace: TraceStep[]): boolean { + return trace.some((step) => step.frameId === 0 && Object.keys(step.locals).length > 0) +} + +const DEFINITION_TYPES = new Set(['FunctionDef', 'AsyncFunctionDef', 'ClassDef']) + +// The source of the function (or class) a global name is bound to, for the +// footer's hover box: the last `def` of that name the module ran up to +// `uptoStep`, else (an import: `from lib import f`) a module-level `def` of +// that name in one of the project's files, the root file first. Decorators +// belong to it. Null for anything else (a lambda, a built-in). +export function globalDefinitionSource( + trace: TraceStep[], + uptoStep: number, + name: string, + nodeIndexByFile: Map>, + sources: Record, + rootPath: string, +): { path: string; text: string } | null { + let found: { path: string; node: AstNode } | null = null + for (const step of trace) { + if (step.step > uptoStep) break + if (step.frameId !== 0) continue + const node = nodeIndexByFile.get(step.path)?.get(step.nodeId) + if (node && DEFINITION_TYPES.has(node.type) && node.summary === name) found = { path: step.path, node } + } + if (!found) { + const paths = [rootPath, ...[...nodeIndexByFile.keys()].filter((path) => path !== rootPath)] + for (const path of paths) { + const module = [...(nodeIndexByFile.get(path)?.values() ?? [])].find((node) => node.type === 'Module') + const body = module?.fields.body + const node = Array.isArray(body) ? body.find((item) => DEFINITION_TYPES.has(item.type) && item.summary === name) : undefined + if (node) { found = { path, node }; break } + } + } + const source = found ? sources[found.path] : undefined + if (!found || source === undefined || found.node.line === undefined) return null + const decorators = found.node.fields.decorator_list + const startLine = Math.min(found.node.line, ...(Array.isArray(decorators) ? decorators.map((item) => item.line ?? Infinity) : [])) + const lines = source.split('\n').slice(startLine - 1, found.node.endLine ?? found.node.line) + return { path: found.path, text: lines.join('\n').replace(/\s+$/, '') } +} + // The texts the evaluation chain export needs, see buildEvaluationChain. export type EvaluationChainStrings = { conditionComment: (condition: string, value: string) => string