diff --git a/CLAUDE.md b/CLAUDE.md index aa0b7b6..7262cb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ Worker messages: - **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. 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. +- **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 fills its iframe and reports its natural height (`naturalHeight` in `src/embed/App.tsx`: the root with the code area and the console at their content's height, so a clipped embed doesn't stay small) plus `visualizing`; the LiaScript macro caps the iframe at 80% of the course's visible height, and the code area scrolls between step bar and footer (`src/embed/style.css`: only it and the console give way). 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`). diff --git a/README.md b/README.md index 6342289..d72260f 100644 --- a/README.md +++ b/README.md @@ -565,10 +565,21 @@ macro: `features=run,repl,visualize,…` hides the REPL (`repl`), the visualizer `repl`, `visualization`, `export`); `coverage` in that list switches on the coverage after Tests (capability `coverage`), `listtree` lists drawn as trees (capability `listTree`) and `tree` the visualizer's mode «Baum» -(capability `tree`), 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). +(capability `tree`), which a missing `features` doesn't; and `size=large` +shows bigger code. + +**Its height is the parent's.** The embed fills its iframe and reports the +height its content needs in `glpython:resize` (`height`; since v0.1.35 also +`visualizing`, whether the visualizer is open) - its natural height, also +while the iframe is smaller or taller. A parent sets the iframe to that +height, or less: the LiaScript macro gives it at most 80% of the course's +visible height (with `large` while visualizing exactly that). In a smaller +iframe the visualizer's code area scrolls, between the step bar and the +global variables' footer, which stay in view, and follows the current step; +without the visualizer the console scrolls. A taller iframe (`large`) is +filled by the code area. Scrolling past the code's end goes on with the +page around it. The old `windowHeight` and `visualizerHeight` parameters are +ignored. **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 @@ -577,7 +588,7 @@ 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 +step taller than the iframe 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. diff --git a/embed/demo.html b/embed/demo.html index f8a9049..e8dd155 100644 --- a/embed/demo.html +++ b/embed/demo.html @@ -71,8 +71,11 @@ runButton.dataset.command = event.data.busy ? 'stop' : 'run' runButton.textContent = event.data.busy ? '■ Stop' : 'Run' } + // The height the embed's content needs, here at most 80% of the + // window (as the LiaScript macro does with the course's visible + // height): a taller visualization scrolls its code inside. if (event.data?.type === 'glpython:resize' && Number.isFinite(event.data.height)) { - iframe.style.height = `${Math.max(400, event.data.height)}px` + iframe.style.height = `${Math.min(Math.max(400, event.data.height), Math.round(window.innerHeight * 0.8))}px` } }) diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs index ab469f9..07166b8 100644 --- a/scripts/check-console-browser.mjs +++ b/scripts/check-console-browser.mjs @@ -45,14 +45,22 @@ process.on('exit', stopServer) // The parent page, served on the embed's own origin: it loads the embed and // passes messages on, version 4. Its own query (e.g. "?features=…") goes on -// to the embed. +// to the embed. As the LiaScript macro does, it sets the iframe to the +// height the embed reports, at most "?cap=" (default 900px; the macro: 80% +// of the course's visible height) - then the embed scrolls inside. const harness = ` - + ` @@ -251,6 +259,8 @@ try { await programLine(frame).waitFor({ timeout: 30000 }) await programLine(frame).press('Control+d') await frame.locator('.repl-line:not(.repl-line--program)').waitFor({ timeout: 30000 }) + // The error may arrive just after the line is back. + await frame.locator('.console', { hasText: /Name: \n[\s\S]*EOFError/ }).waitFor({ timeout: 10000 }).catch(() => {}) assert.match(await consoleText(frame), /Name: \n[\s\S]*EOFError/, 'Ctrl+D → EOFError') console.log('input(): Stop and Ctrl+D end the wait with EOFError: ok') @@ -557,7 +567,7 @@ try { // 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 page = await openEmbed({}, '?visualizer=box&cap=600') const frame = page.frameLocator('#embed') try { const body = Array.from({ length: 30 }, (_, index) => ` s = s + ${index}`).join('\n') @@ -602,14 +612,14 @@ 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 + // the cap (the embed at most 720px: its code scrolls) 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 page = await openEmbed({}, `?visualizer=${mode}&cap=720`) const frame = page.frameLocator('#embed') try { await page.evaluate((program) => window.send('visualize', program), source) @@ -634,7 +644,7 @@ try { } 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.ok(Math.max(...states.map((step) => step.height)) <= 720, `the embed at most as tall as its iframe may be (${Math.max(...states.map((step) => step.height))}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) { @@ -643,6 +653,76 @@ try { } await page.close() } + // The embed's height (P53b): it reports the height its content needs, also + // while its iframe is smaller (the LiaScript macro caps it at 80% of the + // course's visible height). Then the visualizer's code scrolls inside, + // between the step bar and the footer, both in view, and the embed's own + // page doesn't scroll; a larger cap lets it grow again. A short program + // scrolls nothing; a long output scrolls the console. + { + const label = 'the embed\'s height' + const page = await openEmbed({}, '?visualizer=box&cap=500') + const frame = page.frameLocator('#embed') + try { + const recursion = 'def fakultaet(n):\n if n == 0:\n return 1\n return n * fakultaet(n - 1)\n\nprint(fakultaet(5))\n' + await page.evaluate((program) => window.send('visualize', program), recursion) + await frame.locator('.visualizer-step-count').waitFor({ timeout: 60000 }) + await measured(frame) + const last = frame.locator('.visualizer-controls button').nth(3) + await last.click() + const layout = async () => ({ + iframe: await page.locator('#embed').evaluate((iframe) => iframe.getBoundingClientRect().height), + reported: await page.evaluate(() => window.reported), + ...(await 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(() => {}))) + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))) + const rect = (selector) => app.querySelector(selector)?.getBoundingClientRect().toJSON() ?? null + const view = app.querySelector('.visualizer-view') + const console = app.querySelector('.console') + return { inner: innerHeight, pageScrolls: document.scrollingElement.scrollHeight > innerHeight + 1, bar: rect('.visualizer-bar'), footer: rect('.visualizer-globals'), view: view && { client: view.clientHeight, scroll: view.scrollHeight }, console: { client: console.clientHeight, scroll: console.scrollHeight } } + })), + }) + const settled = async (check) => { + let state + for (let tries = 0; tries < 20; tries++) { + state = await layout() + if (check(state)) return state + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return state + } + let state = await settled((s) => s.iframe === 500) + assert.equal(state.iframe, 500, `the iframe at the cap: ${JSON.stringify(state)}`) + assert.ok(state.reported.visualizing === true && state.reported.height > 700, `the content's own height reported: ${JSON.stringify(state.reported)}`) + assert.ok(state.view.scroll > state.view.client, 'the code scrolls') + assert.ok(state.bar.top >= 0 && state.footer.bottom <= state.inner + 1 && !state.pageScrolls, `step bar and footer in view, the page doesn't scroll: ${JSON.stringify(state)}`) + const current = await frame.locator('.visualizer-view').evaluate((view) => { const line = view.querySelector('.visual-current-line')?.getBoundingClientRect(); const box = view.getBoundingClientRect(); return !line || (line.top >= box.top - 1 && line.bottom <= box.bottom + 1) }) + assert.ok(current, 'the current line in view') + const natural = state.reported.height + await page.evaluate(() => window.setCap(3000)) + state = await settled((s) => s.iframe === natural) + assert.ok(state.iframe === natural && state.reported.height === natural && state.view.scroll <= state.view.client + 1, `a larger cap: the whole content (${JSON.stringify(state)})`) + await page.evaluate(() => window.setCap(400)) + state = await settled((s) => s.iframe === 400) + assert.ok(state.iframe === 400 && state.reported.height === natural, `a smaller cap again: ${JSON.stringify(state)}`) + await page.evaluate(() => window.setCap(900)) + await page.evaluate((program) => window.send('visualize', program), 'x = 1 + 2\nprint(x)\n') + await frame.locator('.visualizer-step-count').filter({ hasText: 'Vor der Ausführung' }).waitFor({ timeout: 60000 }) + await measured(frame) + state = await settled((s) => s.reported.height < 500 && s.iframe === s.reported.height) + assert.ok(state.iframe === state.reported.height && state.view.scroll <= state.view.client + 1 && !state.pageScrolls, `a short program: as tall as it needs (${JSON.stringify(state)})`) + await page.evaluate(() => window.setCap(400)) + await page.evaluate((program) => window.send('run', program), 'for i in range(60):\n print(i)\n') + await frame.locator('.console', { hasText: '59' }).waitFor({ timeout: 60000 }) + state = await settled((s) => s.reported.visualizing === false && s.reported.height > 400 && s.iframe === 400) + assert.ok(state.iframe === 400 && state.reported.visualizing === false && state.console.scroll > state.console.client && !state.pageScrolls, `a long output scrolls the console: ${JSON.stringify(state)}`) + console.log(`${label}: ok`) + } catch (error) { + failed += 1 + console.error(`${label}: ${error.message}`) + } + await page.close() + } // The visualizer's mode «Baum» (P34 A): only with "tree" in features= - // without it no mode button, and a stored or preset «Baum» shows // «Ersetzung». With it, each example takes «Ersetzung»'s steps plus one to @@ -667,7 +747,7 @@ try { } { const notenschnitt = 'def notenschnitt(noten):\n summe = sum(noten)\n anzahl = len(noten)\n return summe / anzahl\n\nnotenschnitt([4, 5, 4.5])\n' - const page = await openEmbed({}, '?features=visualize,tree&windowHeight=900') + const page = await openEmbed({}, '?features=visualize,tree') const frame = page.frameLocator('#embed') const stepCount = () => frame.locator('.visualizer-step-count').innerText() const mode = (name) => frame.locator('.visualizer-mode button', { hasText: name }).click() @@ -818,7 +898,7 @@ try { // call text. The console prints nothing for None. { const anwenden = "def anwenden(op, x, y):\n \"\"\"\n >>> anwenden('*', 3, 4)\n 12\n \"\"\"\n if op == '+':\n return x + y\n" - const page = await openEmbed({}, '?features=run,repl,visualize,tests,tree&windowHeight=900') + const page = await openEmbed({}, '?features=run,repl,visualize,tests,tree') const frame = page.frameLocator('#embed') const mode = (name) => frame.locator('.visualizer-mode button', { hasText: name }).click() // The root box's code at each step, a tree as its text. diff --git a/src/embed/App.tsx b/src/embed/App.tsx index 2339901..1c0006d 100644 --- a/src/embed/App.tsx +++ b/src/embed/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { GlPythonWorkspace, type WorkspaceCommand } from '../GlPythonWorkspace' import { isEmbedMode, @@ -48,18 +48,42 @@ const featureList = (() => { })() const features = (feature: string) => featureList === null || featureList.has(feature) -// "?size=large" (the macro's large option): bigger code, and with -// "&visualizerHeight=600" the visualizer at least that many pixels tall (the -// macro passes 60% of the course window; the iframe follows its content). +// "?size=large" (the macro's large option): bigger code. The embed's height +// is the parent's to set (the macro: at most 80% of the course's visible +// height, with "large" while visualizing that much); the embed fills its +// iframe and scrolls the visualizer's code inside it (style.css), and +// reports the height its content needs (naturalHeight). 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) + +// The parts that scroll when the iframe is smaller than the content: the +// visualizer's code area and the console. +const SCROLLING_PARTS = '.visualizer-view, .console' + +function naturalHeight(root: HTMLElement): number { + // With its overflow: in a small iframe the parts' minimum heights stick + // out below the root (measured unrounded, unlike scrollHeight). + const top = root.getBoundingClientRect().top + let height = root.getBoundingClientRect().height + for (const part of root.querySelectorAll('.main-split > *, .editor-column > *, .output-panel > *')) height = Math.max(height, part.getBoundingClientRect().bottom - top) + for (const element of root.querySelectorAll(SCROLLING_PARTS)) { + const style = getComputedStyle(element) + // The content's height. The console only ever shrinks, so its + // scrollHeight is that. The code area also grows to fill the iframe, + // and scrollHeight is never less than its current height, so there it + // is the canvas's height plus the padding. + const canvas = element.classList.contains('visualizer-view') ? element.querySelector(':scope > .visualizer-canvas') : null + const content = canvas ? canvas.getBoundingClientRect().height + parseFloat(style.paddingTop) + parseFloat(style.paddingBottom) : element.scrollHeight + const current = element.getBoundingClientRect().height + // Plus a horizontal scrollbar and borders, within the part's own limits. + let natural = Math.max(Math.min(content + element.offsetHeight - element.clientHeight, parseFloat(style.maxHeight) || Infinity), parseFloat(style.minHeight) || 0) + // The code area gets no smaller than its panel's minimum height allows. + const panel = canvas ? element.closest('.editor-panel') : null + if (panel) natural = Math.max(natural, current - (panel.getBoundingClientRect().height - (parseFloat(getComputedStyle(panel).minHeight) || 0))) + // Unchanged when it isn't clipped or stretched (no rounding). + if (Math.abs(natural - current) >= 1) height += natural - current + } + return Math.ceil(height) +} function visualizerModeFromLocation(): VisualizerMode | undefined { const mode = new URLSearchParams(window.location.search).get('visualizer') @@ -155,17 +179,51 @@ export function App() { return () => window.removeEventListener('message', onMessage) }, [configuredParentOrigin, mode, sendToParent]) + // The height the embed's content needs, also while its iframe is smaller + // (then the visualizer's code, or the console, scrolls inside) or taller + // (then the visualizer fills it): the root's height with each scrolling + // part at its natural height instead of its current one. Reporting the + // root's own height instead would keep a small iframe small for good. + // While visualizing it also says so, so that the parent can give "large" + // its height. useEffect(() => { const root = rootRef.current if (!root || !configuredParentOrigin) return - const observer = new ResizeObserver(() => { - sendToParent({ - type: 'glpython:resize', - height: Math.ceil(root.getBoundingClientRect().height), - }) - }) - observer.observe(root) - return () => observer.disconnect() + let last = '' + let frame = 0 + const report = () => { + frame = 0 + const visualizing = root.querySelector('.glpython-workspace--visualizing') !== null + const height = naturalHeight(root) + const key = `${height}:${visualizing}` + if (key === last) return + last = key + sendToParent({ type: 'glpython:resize', height, visualizing }) + } + const schedule = () => { + if (!frame) frame = requestAnimationFrame(report) + } + // The root keeps its size while its parts change inside it (it fills + // the iframe while visualizing), so the scrolling parts and their + // content are watched too. + const resizes = new ResizeObserver(schedule) + const watch = () => { + resizes.disconnect() + resizes.observe(root) + for (const element of root.querySelectorAll(SCROLLING_PARTS)) { + resizes.observe(element) + for (const child of element.children) resizes.observe(child) + } + schedule() + } + const mutations = new MutationObserver(watch) + mutations.observe(root, { childList: true, subtree: true, characterData: true }) + watch() + return () => { + resizes.disconnect() + mutations.disconnect() + if (frame) cancelAnimationFrame(frame) + } }, [configuredParentOrigin, sendToParent]) // Version 4: the parent's run button becomes a stop button while Python is @@ -181,7 +239,6 @@ export function App() { return (
diff --git a/src/embed/style.css b/src/embed/style.css index 289bdda..11417d3 100644 --- a/src/embed/style.css +++ b/src/embed/style.css @@ -74,16 +74,55 @@ 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) - 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 { +/* The embed fills its iframe, whose height the parent sets: the macro gives + it the height the content needs (App.tsx reports it), at most 80% of the + course's visible height. A taller step scrolls the visualizer's code area, + between the step bar and the footer, which stay in view; a taller output + (Run, Tests) scrolls the console. While visualizing the code area also + fills an iframe taller than the content ("large"). The visualizer has a + small minimum, so a short program leaves no empty area. */ +.embed-app { + display: flex; + flex-direction: column; + max-height: 100vh; +} + +.embed-app:has(.glpython-workspace--visualizing) { + height: 100vh; +} + +.embed-app .editor-column { + min-height: 0; +} + +/* Only the code area (while visualizing) and the console (otherwise) give + way; the bars keep their height. */ +.embed-app .editor-column > *, +.embed-app .output-panel > * { + flex-shrink: 0; +} + +.embed-app .editor-column > .editor-panel { + flex-shrink: 1; min-height: 140px; } -.embed-app .visualizer-frame { - max-height: var(--glp-visualizer-max-height, none); +.embed-app .editor-wrap { + display: flex; + flex-direction: column; +} + +/* Not clipped (the embed has no rounded corners), so that a console at its + minimum still counts in the content's height (App.tsx). */ +.embed-app .output-panel { + display: flex; + flex-direction: column; + overflow: visible; +} + +.embed-app .glpython-workspace:not(.glpython-workspace--visualizing) .editor-column > .output-panel, +.embed-app .glpython-workspace:not(.glpython-workspace--visualizing) .output-panel > .console { + flex-shrink: 1; } /* The console is as tall as its content too (the host's iframe follows it), @@ -94,9 +133,8 @@ body, max-height: 480px; } -/* "?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). */ +/* "?size=large": bigger code (20px, line height 1.6) and the bar's text + 16px (the macro makes the embed as tall as it may while visualizing). */ .embed-app--large .visualizer-frame { font-size: 20px; line-height: 1.6; @@ -115,6 +153,3 @@ body, height: 20px; } -.embed-app--large .editor-panel { - min-height: var(--glp-large-visualizer-height, 330px); -} diff --git a/src/embedProtocol.ts b/src/embedProtocol.ts index 4c1e991..16eb5c2 100644 --- a/src/embedProtocol.ts +++ b/src/embedProtocol.ts @@ -79,10 +79,15 @@ export type GlPythonBusyMessage = { busy: boolean } +// The height the embed's content needs, also when its iframe is smaller or +// taller (then it scrolls its code area, or fills the iframe). Since +// glpython-editor v0.1.35 also whether the visualizer is open (an optional +// field: older parents ignore it, older embeds leave it out). export type GlPythonResizeMessage = { type: 'glpython:resize' version: GlPythonProtocolVersion height: number + visualizing?: boolean } export type GlPythonErrorMessage = {