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..7346418 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 @@ -182,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) => { @@ -238,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 @@ -258,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() @@ -270,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]' @@ -298,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 }) @@ -336,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`) @@ -500,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')) @@ -510,6 +524,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 measured(frame) + 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..7f90c4c 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,58 @@ 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) + // 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 + 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({ trace: visualTrace, substitutionMode, 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]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isVisualizing, visualTrace, substitutionMode, compact]) + const steadyMeasureRef = useRef(null) + useLayoutEffect(() => { + const container = steadyMeasureRef.current + 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 = steadyMeasureCurrent && 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 +2394,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 ?
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}
: { 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; }