From db792baa03703d4412a7b25ce4c5d06a77e16be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:11:16 +0100 Subject: [PATCH 1/2] Visualizer: one height while stepping, in the embed In a compact host (the embed, whose height follows its content) the canvas is as tall as its tallest step from the start: up to 120 likely tallest steps are drawn once, hidden, and measured, through the same StepView and root Frame as the visible step. The console reserves the last step's output. The embed caps the visualizer at 80% of the course window (windowHeight, from the macro; else the screen's), so a taller step scrolls inside it. The step-dependent parts of the root Frame's props are now plain functions (stepViewAt and its helpers). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + README.md | 14 ++ scripts/check-console-browser.mjs | 42 +++++ src/GlPythonWorkspace.tsx | 280 ++++++++++++++++++++++-------- src/embed/App.tsx | 9 +- src/embed/style.css | 8 +- src/style.css | 8 + 7 files changed, 288 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 20bae3f..7512817 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,7 @@ Worker messages: - `substitutionModeSteps` filters the trace down to visible substitutions. - **Rendering always replays the full trace up to the current step's `step` number.** In substitution mode the scrubber moves through a filtered step list, so use list indices only for the scrubber, never as step numbers. - **Visualizer modes:** in `box` mode every call is a `Frame` box at its call site. In `substitution` mode (the default; prop `visualizerMode`, embed `?visualizer=`), a call with `shortForm` renders as `Frame` scope `'inline'`: its return expression with parameters substituted, in parentheses in operator context. +- **Steady height (compact hosts):** while visualizing, the canvas keeps the height of the tallest step from the start (`steadyHeightCandidates`: up to 120 likely tallest steps, drawn hidden in `.visualizer-measure` through the same `rootFrame(stepViewAt(…))` as the visible step, and measured), and only grows; the console reserves the last step's output (`.visual-output-steady`). The embed caps the visualizer at 80% of `windowHeight` (the macro's). A step's view is `StepView`; compute it with `stepViewAt`, not inline, so the measurement and the visible canvas stay the same. - **Layout and scrolling:** nested boxes show their variables below their code (CSS). The scroll effect follows the active box horizontally. It sets `scrollLeft`/`scrollTop` on the visualizer view only; don't use `scrollIntoView`, which also scrolls the LiaScript page around the embed and moves the step buttons. - **Console and Stop:** output, REPL inputs, Tests reports and notes form one transcript (`ConsoleEntry` list after `output`). While Python is busy for more than `STOP_BUTTON_DELAY`, the run button is **Stop**: `stopPython` terminates the worker and calls `startWorker` again, so the REPL namespace is gone after a stop - except while `input()` waits: then Stop only cancels the wait (`EOFError`). The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`). - **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 3ebf251..69c7168 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,20 @@ macro: `features=run,repl,visualize,…` hides the REPL (`repl`), the visualizer coverage after Tests (capability `coverage`), and `listtree` lists drawn as trees (capability `listTree`), which a missing `features` doesn't; and `size=large` with `visualizerHeight=` shows bigger code and a visualizer at least that tall. +`windowHeight=` is the host window's height, which the iframe can't see: +the visualizer is at most 80% of it (without it: of the screen's height). + +**A steady height.** The embed's height follows its content, so the page +around it would move with every step. While visualizing, it doesn't: the +visualizer is as tall as its tallest step from the start, and the console as +tall as the output at the last step. The workspace draws the likely tallest +steps once, hidden, and measures them: every step of a short trace, else the +120 with the most open calls and some spread over the trace. A step taller +still makes it grow when it comes, and it never shrinks while stepping. A +step taller than the cap scrolls inside the visualizer, which follows the +current step. A new visualization, a switch of the mode, and a new width +start again. This applies to `compact` hosts (the embed); in the IDE the +visualizer has a fixed height and scrolls already. The UI language is the optional `lang` query parameter (e.g. the LiaScript course's language), else the browser's language, else English; only the diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index dd62196..e7fd6a5 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -21,6 +21,9 @@ // their parent, the parent centred over them. In a narrow embed (420px) a // wide tree starts with its root in the middle, shows a hint at each edge // where more is hidden, and scrolls with the keyboard. +// A steady height: while stepping through a visualization the embed keeps +// one height (the visualizer the tallest step's, at most 80% of the window, +// the console the last step's output), and the current line stays in view. // // 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 @@ -510,6 +513,45 @@ try { } 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. + for (const [label, mode, source] of [ + ['steady height: a recursion, Kasten', 'box', 'def fakultaet(n):\n if n == 0:\n return 1\n return n * fakultaet(n - 1)\n\nprint(fakultaet(4))\n'], + ['steady height: a recursion, Ersetzung', 'substitution', 'def fakultaet(n):\n if n == 0:\n return 1\n return n * fakultaet(n - 1)\n\nprint(fakultaet(4))\n'], + ['steady height: a loop that prints', 'substitution', 'for i in range(6):\n print("Zeile", i)\n'], + ]) { + const page = await openEmbed({}, `?visualizer=${mode}&windowHeight=900`) + const frame = page.frameLocator('#embed') + try { + await page.evaluate((program) => window.send('visualize', program), source) + await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + await frame.locator('.visualizer-measure').waitFor({ state: 'detached', timeout: 30000 }) + const state = () => frame.locator('.embed-app').evaluate((app) => { + const view = app.querySelector('.visualizer-view') + const line = view.querySelector('.visual-current-line') + const box = view.getBoundingClientRect() + const lineBox = line?.getBoundingClientRect() + return { height: Math.round(app.getBoundingClientRect().height), view: Math.round(box.height), lineInView: !lineBox || (lineBox.top >= box.top - 1 && lineBox.bottom <= box.bottom + 1) } + }) + const states = [await state()] + const next = frame.locator('.visualizer-controls button').nth(2) + while (await next.isEnabled()) { + await next.click() + await new Promise((resolve) => setTimeout(resolve, 50)) + states.push(await state()) + } + assert.ok(states.length > 10, 'some steps') + assert.deepEqual([...new Set(states.map((step) => step.height))], [states[0].height], 'one height for every step') + assert.ok(Math.max(...states.map((step) => step.view)) <= 720, `the visualizer at most 80% of the window (${Math.max(...states.map((step) => step.view))}px)`) + assert.deepEqual(states.map((step, index) => step.lineInView ? null : index).filter((index) => index !== null), [], 'the current line in view') + console.log(`${label}: ok`) + } catch (error) { + failed += 1 + console.error(`${label}: ${error.message}`) + } + await page.close() + } // P37: the run file is __main__, in Pyodide too. { const page = await openEmbed({}) diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index abc170c..9efa58b 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -603,6 +603,147 @@ type FrameScope = // message if the call fails, since inline text has no lines of its own. type InlineHost = { frameId: number; path: string; line: number; endLine: number } +// What the visualizer shows at one step, apart from the trace itself: the +// open boxes (and the one about to open), what the next step declares, and +// where an inline call is drawn. The workspace computes it for the current +// step; SteadyHeightMeasure for other steps, to know their height. +type PendingCall = { openFrame: OpenFrame; argSources: string[] } +type StepView = { + currentStep: TraceStep | null + nextStep: TraceStep | null + openFrames: Map + pendingCall: PendingCall | null + framesForRender: Map + pendingCallLocals: { frameId: number; paramNames: string[] } | null + collapsedCalls: CollapsedCalls | null + declareTarget: { frameId: number; varName: string } | null + isAtErrorStep: boolean + isAtLastStep: boolean + activeHost: InlineHost | null + errorHost: InlineHost | null +} + +// When the pending next step is a plain "x = ..." assignment or a +// "def f(...):" that's about to bind a name this frame doesn't have yet, +// which frame and name it's about to create (see Frame's `declareTarget`). +function declareTargetFor(steps: TraceStep[], currentStep: TraceStep | null, nextStep: TraceStep | null, astIndexByFile: Map>) { + if (!nextStep || nextStep.kind !== 'exec') return null + const node = astIndexByFile.get(nextStep.path)?.get(nextStep.nodeId) + if (!node) return null + let varName: string | null = null + if (node.type === 'FunctionDef') { + varName = node.summary || null + } else if (node.type === 'Assign') { + const targets = node.fields.targets + const onlyTarget = Array.isArray(targets) && targets.length === 1 ? targets[0] : null + if (onlyTarget && typeof onlyTarget === 'object' && (onlyTarget as AstNode).type === 'Name') varName = (onlyTarget as AstNode).summary + } + if (!varName) return null + const frameId = nextStep.frameId + const currentLocals = buildLocalsUpTo(steps, currentStep?.step ?? -1, frameId) + if (varName in currentLocals) return null + return { frameId, varName } +} + +// When the pending next step is a "call-enter", the callee frame's +// OpenFrame one step early (see `framesForRender` in the workspace). +function pendingCallFor(nextStep: TraceStep | null, substitutionMode: boolean): PendingCall | null { + if (!nextStep || nextStep.kind !== 'call-enter') return null + // A call shown as its return expression must not be replaced one step early. + if (substitutionMode && nextStep.shortForm) return null + if (nextStep.funcDefNodeId === null || nextStep.funcDefPath === null) return null + const openFrame: OpenFrame = { + frameId: nextStep.frameId, + parentFrameId: nextStep.parentFrameId ?? 0, + funcName: nextStep.func, + anchorPath: nextStep.path, + anchorNodeId: nextStep.nodeId, + funcDefPath: nextStep.funcDefPath, + funcDefNodeId: nextStep.funcDefNodeId, + paramAnnotations: nextStep.paramAnnotations, + shortForm: nextStep.shortForm ?? null, + } + return { openFrame, argSources: nextStep.argSources } +} + +function framesWithPendingCall(openFrames: Map, pendingCall: PendingCall | null) { + if (!pendingCall) return openFrames + const merged = new Map(openFrames) + merged.set(pendingCall.openFrame.frameId, pendingCall.openFrame) + return merged +} + +// Inline calls take no extra width, so only boxes count towards collapsing. +function collapsedCallsOf(framesForRender: Map, pendingCall: PendingCall | null, substitutionMode: boolean) { + return collapseDeepCallChain(substitutionMode ? new Map([...framesForRender].filter(([, frame]) => !frame.shortForm)) : framesForRender, pendingCall ? 2 : 1) +} + +// In the substitution mode, the line where an inline call (and the inline +// calls around it) is drawn. +function inlineHostIn(framesForRender: Map, astIndexByFile: Map>, substitutionMode: boolean, frameId: number | undefined): InlineHost | null { + if (!substitutionMode || frameId === undefined) return null + let frame = framesForRender.get(frameId) + if (!frame?.shortForm) return null + for (let parent = framesForRender.get(frame.parentFrameId); parent?.shortForm; parent = framesForRender.get(frame.parentFrameId)) frame = parent + const anchor = astIndexByFile.get(frame.anchorPath)?.get(frame.anchorNodeId) + if (anchor?.line === undefined) return null + return { frameId: frame.parentFrameId, path: frame.anchorPath, line: anchor.line, endLine: anchor.endLine ?? anchor.line } +} + +// How many steps the embed measures ahead, and how many at a time (one +// batch per frame, so a long trace doesn't block the page). +const STEADY_HEIGHT_MEASURES = 120 +const STEADY_HEIGHT_BATCH = 12 + +// The steps (indices into `steps`, -1 before the first) most likely to be +// the tallest, tallest first: every step of a short trace, else those with +// the most open calls (also the one about to open), later ones first, then +// some spread over the trace and its last step. Only their height counts. +function steadyHeightCandidates(trace: TraceStep[], steps: TraceStep[]): number[] { + const depthAt = new Map() + let depth = 0 + for (const step of trace) { + if (step.kind === 'call-enter') depth += 1 + if (step.closesFrameId !== null && step.closesFrameId !== undefined) depth -= 1 + depthAt.set(step.step, depth) + } + const depthOf = (index: number) => Math.max(index < 0 ? 0 : depthAt.get(steps[index].step) ?? 0, index + 1 < steps.length ? depthAt.get(steps[index + 1].step) ?? 0 : 0) + const all = Array.from({ length: steps.length + 1 }, (_, index) => index - 1) + const byDepth = [...all].sort((a, b) => depthOf(b) - depthOf(a) || b - a) + if (all.length <= STEADY_HEIGHT_MEASURES) return byDepth + const chosen = new Set(byDepth.slice(0, Math.round(STEADY_HEIGHT_MEASURES * 2 / 3))) + chosen.add(steps.length - 1) + const spread = STEADY_HEIGHT_MEASURES - chosen.size + for (let index = 0; index < spread; index++) chosen.add(Math.floor((index * steps.length) / spread)) + return [...chosen].sort((a, b) => depthOf(b) - depthOf(a) || b - a) +} + +// The view at the visualizer's step `index` (of `steps`, the mode's list). +function stepViewAt(trace: TraceStep[], steps: TraceStep[], index: number, astIndexByFile: Map>, substitutionMode: boolean): StepView { + const currentStep = steps[index] ?? null + const nextStep = steps[index + 1] ?? null + const lastStep = steps[steps.length - 1] + const errorStep = lastStep?.kind === 'error' ? lastStep : null + const isAtErrorStep = errorStep !== null && currentStep === errorStep + const openFrames = computeOpenFramesUpTo(trace, currentStep?.step ?? -1) + const pendingCall = pendingCallFor(nextStep, substitutionMode) + const framesForRender = framesWithPendingCall(openFrames, pendingCall) + return { + currentStep, + nextStep, + openFrames, + pendingCall, + framesForRender, + pendingCallLocals: pendingCall ? { frameId: pendingCall.openFrame.frameId, paramNames: pendingCall.argSources } : null, + collapsedCalls: collapsedCallsOf(framesForRender, pendingCall, substitutionMode), + declareTarget: declareTargetFor(steps, currentStep, nextStep, astIndexByFile), + isAtErrorStep, + isAtLastStep: steps.length > 0 && index === steps.length - 1, + activeHost: inlineHostIn(framesForRender, astIndexByFile, substitutionMode, currentStep?.frameId), + errorHost: isAtErrorStep && errorStep ? inlineHostIn(framesForRender, astIndexByFile, substitutionMode, errorStep.frameId) : null, + } +} + type FrameProps = { frameId: number path: string @@ -1580,6 +1721,10 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const errorTraceStep = lastTraceStep?.kind === 'error' ? lastTraceStep : null const isAtErrorStep = errorTraceStep !== null && currentTraceStep === errorTraceStep const visualStdout = visualTrace?.output.slice(0, currentTraceStep?.stdoutLen ?? 0) ?? '' + // What the console shows at the last step: in a host whose height follows + // the content, the console keeps that height from the start (invisibly, + // see .visual-output-steady), so it doesn't grow with every print. + const visualFinalOutput = visualTrace ? `${visualTrace.output}${visualTrace.error && errorTraceStep ? `${visualTrace.output && !visualTrace.output.endsWith('\n') ? '\n' : ''}${visualTrace.error}` : ''}` || t.noOutput : '' const astIndexByFile = useMemo(() => { const map = new Map>() @@ -1607,24 +1752,10 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // `declareTarget` prop), with an arrow drawn from the upcoming // value/def in the code into that placeholder instead of the other way // around. - const nextStepDeclareTarget = useMemo(() => { - if (!nextTraceStep || nextTraceStep.kind !== 'exec') return null - const node = astIndexByFile.get(nextTraceStep.path)?.get(nextTraceStep.nodeId) - if (!node) return null - let varName: string | null = null - if (node.type === 'FunctionDef') { - varName = node.summary || null - } else if (node.type === 'Assign') { - const targets = node.fields.targets - const onlyTarget = Array.isArray(targets) && targets.length === 1 ? targets[0] : null - if (onlyTarget && typeof onlyTarget === 'object' && (onlyTarget as AstNode).type === 'Name') varName = (onlyTarget as AstNode).summary - } - if (!varName) return null - const frameId = nextTraceStep.frameId - const currentLocals = buildLocalsUpTo(visualTraceSteps, currentTraceStep?.step ?? -1, frameId) - if (varName in currentLocals) return null - return { frameId, varName } - }, [nextTraceStep, astIndexByFile, visualTraceSteps, currentTraceStep]) + const nextStepDeclareTarget = useMemo( + () => declareTargetFor(visualTraceSteps, currentTraceStep, nextTraceStep, astIndexByFile), + [nextTraceStep, astIndexByFile, visualTraceSteps, currentTraceStep], + ) // When the pending next step is a "call-enter" (a user-defined function // about to start running), synthesize that callee frame's OpenFrame @@ -1641,24 +1772,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // each bound parameter's name, in parameter-def order), used to seed // this frame's own parameters as valueless placeholder rows // (`pendingCallLocals`). - const nextStepPendingCall = useMemo(() => { - if (!nextTraceStep || nextTraceStep.kind !== 'call-enter') return null - // A call shown as its return expression must not be replaced one step early. - if (substitutionMode && nextTraceStep.shortForm) return null - if (nextTraceStep.funcDefNodeId === null || nextTraceStep.funcDefPath === null) return null - const openFrame: OpenFrame = { - frameId: nextTraceStep.frameId, - parentFrameId: nextTraceStep.parentFrameId ?? 0, - funcName: nextTraceStep.func, - anchorPath: nextTraceStep.path, - anchorNodeId: nextTraceStep.nodeId, - funcDefPath: nextTraceStep.funcDefPath, - funcDefNodeId: nextTraceStep.funcDefNodeId, - paramAnnotations: nextTraceStep.paramAnnotations, - shortForm: nextTraceStep.shortForm ?? null, - } - return { openFrame, argSources: nextTraceStep.argSources } - }, [nextTraceStep, substitutionMode]) + const nextStepPendingCall = useMemo(() => pendingCallFor(nextTraceStep, substitutionMode), [nextTraceStep, substitutionMode]) // When the pending next step is a "call-pending" (a user-defined call // whose callee and arguments are already resolved - the call site reads @@ -1789,36 +1903,18 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // callee frame from `nextStepPendingCall` above - this is what actually // gets handed down to , so that pending frame renders inline at // its call site one step early, exactly like any other already-open call. - const framesForRender = useMemo(() => { - if (!nextStepPendingCall) return openFrames - const merged = new Map(openFrames) - merged.set(nextStepPendingCall.openFrame.frameId, nextStepPendingCall.openFrame) - return merged - }, [openFrames, nextStepPendingCall]) + const framesForRender = useMemo(() => framesWithPendingCall(openFrames, nextStepPendingCall), [openFrames, nextStepPendingCall]) const pendingCallLocals = useMemo(() => nextStepPendingCall ? { frameId: nextStepPendingCall.openFrame.frameId, paramNames: nextStepPendingCall.argSources } : null, [nextStepPendingCall]) - // Inline calls take no extra width, so only boxes count towards collapsing. const collapsedCalls = useMemo( - () => collapseDeepCallChain(substitutionMode ? new Map([...framesForRender].filter(([, frame]) => !frame.shortForm)) : framesForRender, nextStepPendingCall ? 2 : 1), + () => collapsedCallsOf(framesForRender, nextStepPendingCall, substitutionMode), [framesForRender, nextStepPendingCall, substitutionMode], ) - const collapsedCallsLabel = collapsedCalls - ? t.visualizerCollapsedCalls?.(collapsedCalls.hiddenCount, collapsedCalls.callText) - ?? `\u2026 ${collapsedCalls.hiddenCount} more calls${collapsedCalls.callText ? ` of ${collapsedCalls.callText}` : ''} \u2026` - : '' - - const inlineHostOf = (frameId: number | undefined): InlineHost | null => { - if (!substitutionMode || frameId === undefined) return null - let frame = framesForRender.get(frameId) - if (!frame?.shortForm) return null - for (let parent = framesForRender.get(frame.parentFrameId); parent?.shortForm; parent = framesForRender.get(frame.parentFrameId)) frame = parent - const anchor = astIndexByFile.get(frame.anchorPath)?.get(frame.anchorNodeId) - if (anchor?.line === undefined) return null - return { frameId: frame.parentFrameId, path: frame.anchorPath, line: anchor.line, endLine: anchor.endLine ?? anchor.line } - } + + const inlineHostOf = (frameId: number | undefined) => inlineHostIn(framesForRender, astIndexByFile, substitutionMode, frameId) // The visualizer's root file: a visualized REPL input's pseudo-file, or the // main file. const chainRootPath = visualRootPath ?? project.mainFile @@ -1855,9 +1951,9 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // At the last step of a visualized failing doctest example: what doctest // got and what the example expected, as the texts doctest compared. const isAtLastStep = visualTraceSteps.length > 0 && clampedStepIndex === visualTraceSteps.length - 1 - const finalNote = isAtLastStep && exampleExpectation && currentTraceStep + const finalNoteAt = (view: StepView) => view.isAtLastStep && exampleExpectation && view.currentStep ? { - line: currentTraceStep.frameId === 0 ? currentTraceStep.endLine : 1, + line: view.currentStep.frameId === 0 ? view.currentStep.endLine : 1, text: (t.exampleGotExpected ?? ((got: string, want: string) => `Got: ${got} \u00b7 Expected: ${want}`))( exampleExpectation.got.replace(/\n$/, '') || t.noOutput, exampleExpectation.want.replace(/\n$/, ''), @@ -1866,6 +1962,15 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { : null const activeHost = inlineHostOf(currentTraceStep?.frameId) const errorHost = isAtErrorStep ? inlineHostOf(errorTraceStep.frameId) : null + const currentStepView: StepView = { currentStep: currentTraceStep, nextStep: nextTraceStep, openFrames, pendingCall: nextStepPendingCall, framesForRender, pendingCallLocals, collapsedCalls, declareTarget: nextStepDeclareTarget, isAtErrorStep, isAtLastStep, activeHost, errorHost } + // The module's frame at a step, with the boxes and inline calls inside it. + const rootFrame = (view: StepView) => { + const collapsedLabel = view.collapsedCalls + ? t.visualizerCollapsedCalls?.(view.collapsedCalls.hiddenCount, view.collapsedCalls.callText) + ?? `\u2026 ${view.collapsedCalls.hiddenCount} more calls${view.collapsedCalls.callText ? ` of ${view.collapsedCalls.callText}` : ''} \u2026` + : '' + return : null} /> + } const changeVisualizerMode = (mode: VisualizerMode) => { if (mode === visualizerMode) return // Stay at the same point of the program: the last step of the new mode's @@ -2221,28 +2326,59 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // While stepping through one visualization the canvas only grows: a call // box that opens makes it taller, but closing it again doesn't shrink it, // so a host whose height follows the content (the embed) doesn't jump up - // and down with every box. A new trace or mode, and a new width (content - // that wrapped while the host was still laying out), starts from the - // content again. + // and down with every box. In such a host (`compact`) it is as tall as the + // tallest step from the start: the likely tallest steps are drawn once, + // hidden, and measured (see steadyHeightCandidates), and the canvas keeps + // the tallest of them; a step taller still makes it grow when it comes. + // The host caps the visualizer's height (the embed: at most 80% of the + // course window), so a taller canvas scrolls inside it, following the + // current step (see above). A new trace or mode, and a new width (content + // wraps differently), starts again. + const steadyHeightRef = useRef(0) + const [steadyMeasure, setSteadyMeasure] = useState<{ width: number; pending: number[] }>({ width: 0, pending: [] }) + const raiseSteadyHeight = (height: number) => { + const canvas = visualizerCanvasRef.current + if (!canvas || height <= steadyHeightRef.current) return + steadyHeightRef.current = height + canvas.style.minHeight = `${height}px` + } useEffect(() => { const canvas = visualizerCanvasRef.current if (!isVisualizing || !canvas) return - let tallest = 0 let width = -1 + steadyHeightRef.current = 0 canvas.style.minHeight = '' const observer = new ResizeObserver(() => { if (canvas.offsetWidth !== width) { width = canvas.offsetWidth - tallest = 0 + steadyHeightRef.current = 0 canvas.style.minHeight = '' + if (compact && visualTrace) setSteadyMeasure({ width, pending: steadyHeightCandidates(visualTrace.trace, visualTraceSteps) }) } - if (canvas.offsetHeight <= tallest) return - tallest = canvas.offsetHeight - canvas.style.minHeight = `${tallest}px` + raiseSteadyHeight(canvas.offsetHeight) }) observer.observe(canvas) - return () => observer.disconnect() - }, [isVisualizing, visualTrace, substitutionMode]) + return () => { + observer.disconnect() + setSteadyMeasure({ width: 0, pending: [] }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisualizing, visualTrace, substitutionMode, compact]) + const steadyMeasureRef = useRef(null) + useLayoutEffect(() => { + const container = steadyMeasureRef.current + if (!container || steadyMeasure.pending.length === 0) return + for (const step of container.querySelectorAll('[data-measure-step]')) raiseSteadyHeight(step.offsetHeight) + const frame = requestAnimationFrame(() => setSteadyMeasure((measure) => ({ ...measure, pending: measure.pending.slice(STEADY_HEIGHT_BATCH) }))) + return () => cancelAnimationFrame(frame) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [steadyMeasure]) + const steadyMeasureNode = steadyMeasure.pending.length > 0 && visualTrace ? : null const visualStepBack = () => setVisualStepIndex((index) => Math.max(-1, index - 1)) const visualStepForward = () => setVisualStepIndex((index) => Math.min(visualTraceSteps.length - 1, index + 1)) @@ -2259,8 +2395,8 @@ 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.listTree && }{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
const visualizerWarningNode = <>{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
} const editorTabsNode =
{openFiles.map((path) => { const file = project.files[path]; return })}
- const editorWrapNode =
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} onMount={setNormalEditor} options={{ minimap: { enabled: false }, bracketPairColorization: { enabled: false }, fontSize: 15, lineHeight: 24, padding: { top: 22 }, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', scrollBeyondLastLine: false, smoothScrolling: true, automaticLayout: true, tabSize: 4, insertSpaces: true, detectIndentation: false, placeholder: t.editorPlaceholder ?? 'Type code here', unicodeHighlight: EDITOR_UNICODE_HIGHLIGHT, readOnly: selectedFile.readOnly === true, domReadOnly: selectedFile.readOnly === true, readOnlyMessage: { value: t.readOnlyFile ?? 'Read-only: this file can be read but not changed.' } }} />}
- const consoleNode = <>{!hideOutputPanel &&
{t.output}{runtimeState !== 'ready' && {t.pyodideRuntime}}
{Object.keys(project.files).length > 1 && {t.runsFile(project.mainFile)}}
{ const element = event.currentTarget; consoleAtBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 24 }}>{isVisualizing ?
{isAtErrorStep ? <>{visualStdout && !visualStdout.endsWith('\n') ? `${visualStdout}\n` : visualStdout}{visualTrace?.error} : visualStdout || t.noOutput}
: <>
{output}
{consoleEntries.map((entry) => entry.kind === 'tests' ?
: entry.kind === 'note' ?

{entry.text}

:
 visualizeReplEntry(entry.id) : undefined} busy={isBusy} />
)}}{answeredInputLines.length > 0 &&
{answeredInputLines.join('\n')}
}
{(workspaceCapabilities.repl || programInput) &&
{ event.preventDefault(); if (programInput) respondToInput(false); else submitRepl() }}> { if (programInput) { setInputValue(event.target.value) } else { setReplInput(event.target.value); setReplHistoryIndex(null) } }} onKeyDown={handleReplKeyDown} aria-label={programInput ? `${t.programInputLabel ?? 'Input for the program'}${inputRequest ? `: ${inputRequest}` : ''}` : t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" autoCorrect="off" enterKeyHint="send" />
}
} + const editorWrapNode =
{isVisualizing ?
{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}
: { 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 // the middle of stepping. Its ✕ hides it until the next drawing. diff --git a/src/embed/App.tsx b/src/embed/App.tsx index 457e41e..64e0cdb 100644 --- a/src/embed/App.tsx +++ b/src/embed/App.tsx @@ -52,6 +52,13 @@ const features = (feature: string) => featureList === null || featureList.has(fe // macro passes 60% of the course window; the iframe follows its content). const isLarge = new URLSearchParams(window.location.search).get('size') === 'large' const largeVisualizerHeight = Number(new URLSearchParams(window.location.search).get('visualizerHeight')) || null +// "&windowHeight=900": the course window's height (the macro passes it; the +// iframe can't see it). The visualizer is at most 80% of it - a taller step +// scrolls inside - and at least as tall as "large" asks for. Without it +// (an older macro), the screen's height stands in for the window's. +const VISUALIZER_WINDOW_SHARE = 0.8 +const windowHeight = Number(new URLSearchParams(window.location.search).get('windowHeight')) || window.screen.availHeight +const visualizerMaxHeight = Math.max(Math.round(windowHeight * VISUALIZER_WINDOW_SHARE), isLarge ? largeVisualizerHeight ?? 0 : 0) function visualizerModeFromLocation(): VisualizerMode | undefined { const mode = new URLSearchParams(window.location.search).get('visualizer') @@ -173,7 +180,7 @@ export function App() { return (
diff --git a/src/embed/style.css b/src/embed/style.css index b472289..1b6bc04 100644 --- a/src/embed/style.css +++ b/src/embed/style.css @@ -75,11 +75,17 @@ body, } /* The visualizer is as tall as its content, with a small minimum, so a short - program leaves no empty area ("large" below sets its own minimum). */ + program leaves no empty area ("large" below sets its own minimum) - from + the start as tall as its tallest step, and at most 80% of the course + window (App.tsx sets the variable); a taller step scrolls inside it. */ .embed-app .editor-panel { min-height: 140px; } +.embed-app .visualizer-view { + max-height: var(--glp-visualizer-max-height, none); +} + /* The console is as tall as its content too (the host's iframe follows it), so a hint below a traceback is never scrolled away - up to 480px: a program printing in a loop scrolls there, following its newest line, rather than diff --git a/src/style.css b/src/style.css index 21b5662..2bff500 100644 --- a/src/style.css +++ b/src/style.css @@ -171,6 +171,14 @@ .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; } .visualizer-canvas { position: relative; } +/* Steps drawn only to measure their height (compact hosts, see + steadyHeightCandidates): laid out at the canvas's width, never shown. */ +.visualizer-measure { position: absolute; top: 0; left: 0; height: 0; overflow: hidden; visibility: hidden; pointer-events: none; } +/* The console while visualizing in a compact host: as tall as the output at + the last step from the start, the current output on top of it. */ +.visual-output-steady { display: grid; } +.visual-output-steady > * { grid-area: 1 / 1; } +.visual-output-final { visibility: hidden; } .visualizer-connectors { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; z-index: 2; opacity: .55; } .visualizer-connectors path { fill: none; stroke: var(--glp-text-muted); stroke-width: 1.5; } .visualizer-connectors marker path { fill: var(--glp-text-muted); stroke: none; } From a61390d641f588abfc6e943b57b8e114860a39d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:24:05 +0100 Subject: [PATCH 2/2] Steady height: tie the measurement to its trace and mode; checks wait for it The measuring state names the trace and mode it was made for, so a new trace never renders the old candidates or reads as measured; the view shows data-steady-height="measuring"/"done". The browser checks wait for "done" before reading the visualizer, since the hidden copies have the same markup. Co-Authored-By: Claude Opus 5.5 --- scripts/check-console-browser.mjs | 13 ++++++++++++- src/GlPythonWorkspace.tsx | 17 ++++++++--------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index e7fd6a5..7346418 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -185,6 +185,9 @@ try { for (const button of buttons) button.style.display = '' return text }) + // A visualization is shown once its steps are measured: until then the + // 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 REPL input, once the REPL is free again (until then it keeps the text). const enterRepl = async (frame, text) => { @@ -241,6 +244,7 @@ try { await page.evaluate((source) => window.send('visualize', source), 'text = input("> ")\nprint(eval(text))\n') await answer(frame, '2 * 3') await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + await measured(frame) console.log('input(): the visualizer asks, then shows the steps: ok') } catch (error) { failed += 1 @@ -261,6 +265,7 @@ try { 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 }) + await measured(frame) const seen = { 5: [], 6: [], 7: [] } while (!(await stepButton(2).isDisabled())) { await stepButton(2).click() @@ -273,6 +278,8 @@ try { 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 frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 60000 }) + await measured(frame) await stepButton(3).click() await frame.locator('.locals-row[data-name="baum"]').waitFor({ timeout: 30000 }) const short = '[1, 2, 3, 4, 5, …, 100]' @@ -301,6 +308,8 @@ try { for (const mode of ['Kasten', 'Ersetzung']) { await frame.locator('.visualizer-mode').getByRole('button', { name: mode }).click() await page.evaluate((source) => window.send('visualize', source), displays) + await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 60000 }) + await measured(frame) await frame.locator('.frame-box--root > .frame-code .frame-line').filter({ hasText: 'baum = ' }).waitFor({ timeout: 30000 }) if (!(await stepButton(0).isDisabled())) await stepButton(0).click() await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 5000 }) @@ -339,6 +348,7 @@ try { try { await page.evaluate((source) => window.send('visualize', source), 'baum = ["+", ["var", "x"], ["f", 1, 2]]\ngross = ["+", ["*", 2, ["-", 7, 3]], ["/", ["+", 1, 2], ["liste", "anna", "ben", "carla", 12345]]]\nnamen = ["Anna", "Ben"]\n') await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + await 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`) @@ -503,6 +513,7 @@ try { await checkNarrow('the console', frame.locator('.repl-tree .value-tree-scroll')) await page.evaluate((source) => window.send('visualize', source), `breit = ${breit}\n`) await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + await 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')) @@ -526,7 +537,7 @@ try { try { await page.evaluate((program) => window.send('visualize', program), source) await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) - await frame.locator('.visualizer-measure').waitFor({ state: 'detached', timeout: 30000 }) + await measured(frame) const state = () => frame.locator('.embed-app').evaluate((app) => { const view = app.querySelector('.visualizer-view') const line = view.querySelector('.visual-current-line') diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index 9efa58b..7f90c4c 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -2335,7 +2335,9 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // current step (see above). A new trace or mode, and a new width (content // wraps differently), starts again. const steadyHeightRef = useRef(0) - const [steadyMeasure, setSteadyMeasure] = useState<{ width: number; pending: number[] }>({ width: 0, pending: [] }) + // The steps still to measure, for which trace, mode and width. + const [steadyMeasure, setSteadyMeasure] = useState<{ trace: TraceResult | null; substitutionMode: boolean; width: number; pending: number[] }>({ trace: null, substitutionMode: false, width: 0, pending: [] }) + const steadyMeasureCurrent = steadyMeasure.trace === visualTrace && steadyMeasure.substitutionMode === substitutionMode const raiseSteadyHeight = (height: number) => { const canvas = visualizerCanvasRef.current if (!canvas || height <= steadyHeightRef.current) return @@ -2353,27 +2355,24 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { width = canvas.offsetWidth steadyHeightRef.current = 0 canvas.style.minHeight = '' - if (compact && visualTrace) setSteadyMeasure({ width, pending: steadyHeightCandidates(visualTrace.trace, visualTraceSteps) }) + if (compact && visualTrace) setSteadyMeasure({ trace: visualTrace, substitutionMode, width, pending: steadyHeightCandidates(visualTrace.trace, visualTraceSteps) }) } raiseSteadyHeight(canvas.offsetHeight) }) observer.observe(canvas) - return () => { - observer.disconnect() - setSteadyMeasure({ width: 0, pending: [] }) - } + return () => observer.disconnect() // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisualizing, visualTrace, substitutionMode, compact]) const steadyMeasureRef = useRef(null) useLayoutEffect(() => { const container = steadyMeasureRef.current - if (!container || steadyMeasure.pending.length === 0) return + if (!container || !steadyMeasureCurrent || steadyMeasure.pending.length === 0) return for (const step of container.querySelectorAll('[data-measure-step]')) raiseSteadyHeight(step.offsetHeight) const frame = requestAnimationFrame(() => setSteadyMeasure((measure) => ({ ...measure, pending: measure.pending.slice(STEADY_HEIGHT_BATCH) }))) return () => cancelAnimationFrame(frame) // eslint-disable-next-line react-hooks/exhaustive-deps }, [steadyMeasure]) - const steadyMeasureNode = steadyMeasure.pending.length > 0 && visualTrace ?