diff --git a/CLAUDE.md b/CLAUDE.md index a9d2007..3b7af17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ Worker messages: - **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. The embed reports this to its parent (protocol version 4, `glpython:busy`/`glpython:stop`). - **UI strings** come in via the `t: CoreStrings` prop. `src/i18n.ts` is their single source: `coreStrings` holds German and English, and `resolveUiLocale` picks the language. The embed uses them directly; the glpython IDE imports them and adds only its shell strings. New strings have been added as *optional* `CoreStrings` keys with an English fallback, so older hosts keep compiling; add both languages to `coreStrings`. Python values, reprs and error messages are never translated. +- **Two layouts:** with `compact` (the embed) the original tree (`main-split` > `editor-column` + graphics aside), unchanged and styled by `src/embed/style.css`; without it (the IDE) the stacked layout (`workspace-stack`: `stack-top` with the editor panel and, while drawing, the graphics, then `stack-divider` and `stack-console`). Both are assembled from the same JSX pieces (`actionsNode`, `visualizerBarNode`, `editorWrapNode`, `consoleNode`, …) just before the return. The buttons can be portaled into the host's bar (`actionsContainer`). - **Embedded/reduced layouts:** the knobs are `compact`, `capabilities` (`WorkspaceCapabilities`, including `repl`) and `command` (`WorkspaceCommand`, an immutable `{id, type}` used by editor-free hosts to trigger run/visualize). Defaults must preserve the full-workspace behavior. **Embed protocol.** `src/embedProtocol.ts` defines the versioned message types and validators (`parseParentMessage`, `parseParentOrigin`, `GLPYTHON_EMBED_PROTOCOL_VERSION`, `GLPYTHON_MAX_SOURCE_LENGTH`), all re-exported from `src/index.ts`. The iframe (`src/embed/App.tsx`) requires an exact `parentOrigin` query param, sends `glpython:ready`/`glpython:resize`, and accepts `glpython:load`/`run`/`visualize`/`reset`. `run`/`visualize` carry the source snapshot so the parent remains the authoritative editor. Version 2 adds `files` + `mainFile` for programs of several files, version 3 adds `glpython:test` (doctests); version 4 adds `glpython:stop` and `glpython:busy`; the embed still accepts every earlier version and answers each parent in the version it last used, so older (e.g. cached) macros keep working. Modes: `eval` (with REPL), `turtle` (graphics layout), `visual` (visualizer-only, cannot exit). Reset remounts the workspace via a `key` generation counter, which also recreates the worker. Any protocol change should bump/handle the version and be covered in `scripts/check-embed-protocol.mjs`; also update `embed/demo.html` and the README. diff --git a/README.md b/README.md index 4769af5..4d26f46 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,29 @@ it was. An input that doesn't compile has no button. Disable the REPL with `capabilities={{ repl: false }}`; the embed shows it in `eval` mode only, and resetting the embed discards the namespace. +### Stacked layout (the IDE) + +Without `compact`, the workspace is stacked: the editor (or, while +visualizing, the visualizer) in full width on top, a divider, and the console +in full width below. The divider can be dragged (mouse, pen or finger) or +moved with the arrow keys, Page Up/Down, Home and End (`consoleSize` names +it); the console's share of the height (a third at first) is remembered in +the browser. The file tabs and, while visualizing, the visualizer's bar take +the same strip above the editor, so nothing below moves when switching. The +graphics show beside the editor only while the program draws (for a +visualization: if it draws at all); their ✕ hides them until the next drawing. +An empty editor shows `editorPlaceholder` («Code hier eingeben»). + +The buttons come in the embed's order and labels - **▶ Run · Tests · +Visualize · Reset** (`runProject`, `runTests`, `visualize`, `resetOutput`) - +and stay while visualizing (they leave the visualizer first). **Reset** +restarts Python and clears the console, the visualization and the drawing, as +the embed's Reset does. The run button keeps its width when it becomes +**Stop**. A host can put the buttons into its own bar with +`actionsContainer={element}` (the IDE's top bar); without it they sit above +the editor. `capabilities={{ closeFileTabs: false }}` drops the ✕ on the file +tabs, for a host that shows every file of the project as a tab. + ### Compact embeds `GlPythonWorkspace` accepts optional `compact` and `capabilities` props. The diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx index 44defd4..fd58056 100644 --- a/src/GlPythonWorkspace.tsx +++ b/src/GlPythonWorkspace.tsx @@ -1,4 +1,5 @@ -import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react' import Editor, { useMonaco } from '@monaco-editor/react' import type { Monaco } from '@monaco-editor/react' import { AlertTriangle, BookOpen, Copy, Square, FileCode2, FlaskConical, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronRight, Ellipsis, Play, RotateCcw, SkipBack, SkipForward, Sparkles, SquareTerminal, Terminal, Workflow, X } from 'lucide-react' @@ -53,6 +54,11 @@ type CoreStrings = { // Optional, like visualizerCollapsedCalls. stop?: string stoppedNote?: string + // The stacked layout's divider between editor and console (its accessible + // name), and the empty editor's placeholder. Optional, like + // visualizerCollapsedCalls. + consoleSize?: string + editorPlaceholder?: string // Accessible name of the REPL's input line, and the note shown after a // "def"/"class" typed into the REPL. Optional like visualizerCollapsedCalls. replInputLabel?: string @@ -143,6 +149,9 @@ type WorkspaceCapabilities = { // raises no error, showing «(Keine Ausgabe)». Off for visualization-only // embeds, so a short visualization takes less space. emptyOutput: boolean + // A ✕ on each file tab (closing it). Off when the host shows every file of + // the project as a tab, as the IDE does. + closeFileTabs: boolean } const defaultWorkspaceCapabilities: WorkspaceCapabilities = { @@ -158,6 +167,7 @@ const defaultWorkspaceCapabilities: WorkspaceCapabilities = { tests: true, export: true, emptyOutput: true, + closeFileTabs: true, } type WorkspaceCommand = { @@ -200,12 +210,30 @@ type GlPythonWorkspaceProps = { // (a program, a REPL input, Tests, a visualization) - while it is, the run // button is a stop button. The embed passes it on to its parent. onBusyChange?: (busy: boolean) => void + // Where the buttons (▶ Run · Tests · Visualize · Reset) go, e.g. the + // host's top bar; without it they sit above the editor. Only in the + // stacked (not compact) layout. + actionsContainer?: HTMLElement | null } // Busy for less than this, the run button stays as it is, so a quick REPL // input doesn't make it flicker into a stop button and back. const STOP_BUTTON_DELAY = 300 +// The stacked layout's console: its share of the height, remembered in the +// browser, a third at first. +const consoleShareStorageKey = 'glpython-console-share' +const CONSOLE_SHARE_MIN = 0.15 +const CONSOLE_SHARE_MAX = 0.8 +function storedConsoleShare(): number { + try { + const share = Number(localStorage.getItem(consoleShareStorageKey)) + return share >= CONSOLE_SHARE_MIN && share <= CONSOLE_SHARE_MAX ? share : 1 / 3 + } catch { + return 1 / 3 + } +} + const animationsStorageKey = 'glpython-visualizer-animations' const visualizerModeStorageKey = 'glpython-visualizer-mode' const chainCommentsStorageKey = 'glpython-chain-comments' @@ -882,6 +910,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { command = null, visualizerMode: visualizerModePreset, onBusyChange, + actionsContainer = null, } = props const workspaceCapabilities = { ...defaultWorkspaceCapabilities, ...capabilities } const [output, setOutput] = useState(() => t.readyOutput) @@ -997,7 +1026,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (event.data.type === 'result' || event.data.type === 'error') streamedOutputRef.current = streamedOutputRef.current.filter((chunk) => chunk.id !== undefined) if (event.data.type === 'repl-result') streamedOutputRef.current = streamedOutputRef.current.filter((chunk) => chunk.id !== event.data.id) if (event.data.type === 'error') setRuntimeState((state) => state === 'loading' ? 'failed' : state) - if (event.data.type === 'result' || event.data.type === 'error') { setOutput(event.data.output || currentTranslation.noOutput); setOutputErrorHint(event.data.type === 'error' ? event.data.errorHint ?? null : null); setGraphics(event.data.graphics ?? []); setIsRunning(false) } + if (event.data.type === 'result' || event.data.type === 'error') { setOutput(event.data.output || currentTranslation.noOutput); setOutputErrorHint(event.data.type === 'error' ? event.data.errorHint ?? null : null); setGraphics(event.data.graphics ?? []); setGraphicsDismissed(false); setIsRunning(false) } if (event.data.type === 'input_request') { setInputValue(''); setInputRequest(event.data.prompt ?? '') } if (event.data.type === 'test-result') { setConsoleEntries((entries) => [...entries, { kind: 'tests', id: ++replIdRef.current, report: event.data as TestReport }]) @@ -1027,6 +1056,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { if (event.data.sources) setVisualSources(event.data.sources) setVisualTrace(event.data as TraceResult) setVisualStepIndex(-1) + setGraphicsDismissed(false) } } if (typeof SharedArrayBuffer !== 'undefined') { @@ -1118,7 +1148,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { // memory. The code stays, the console keeps what was printed and says that // Python restarted; earlier REPL inputs can't be visualized anymore, as // their session is gone. - const stopPython = () => { + const restartPython = () => { workerRef.current?.terminate() // What was printed until now stays in the console. if (streamFlushTimerRef.current !== null) window.clearTimeout(streamFlushTimerRef.current) @@ -1127,12 +1157,23 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { setIsRunning(false); setIsReplBusy(false); setIsTesting(false); setIsVisualizerLoading(false) setInputRequest(null); setInputValue('') setRuntimeState('loading') + startWorker() + } + const stopPython = () => { + restartPython() setOutput((current) => current === t.runningOutput(project.mainFile) ? '' : current) setConsoleEntries((entries) => [ ...entries.map((entry) => entry.kind === 'repl' ? { ...entry, stale: true, result: entry.result ?? { output: entry.live ?? '' }, live: undefined } : entry), { kind: 'note' as const, id: ++replIdRef.current, text: t.stoppedNote ?? 'Stopped. Python was restarted.' }, ]) - startWorker() + } + // Reset: Python restarts too, and the console, the visualization and the + // drawing start empty - as the embed's Reset, which remounts it. + const resetPython = () => { + restartPython() + exitVisualizer() + runStreamStartedRef.current = false + setOutput(t.readyOutput); setOutputErrorHint(null); setConsoleEntries([]); setGraphics([]) } const exitVisualizer = () => { setExampleExpectation(null) @@ -1214,6 +1255,43 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { }, [command?.id]) const isVisualizing = visualTrace !== null const isBusy = isRunning || isReplBusy || isTesting || isVisualizerLoading + const [graphicsDismissed, setGraphicsDismissed] = useState(false) + const [consoleShare, setConsoleShare] = useState(storedConsoleShare) + const stackRef = useRef(null) + const changeConsoleShare = (share: number) => { + const clamped = Math.min(CONSOLE_SHARE_MAX, Math.max(CONSOLE_SHARE_MIN, share)) + setConsoleShare(clamped) + try { localStorage.setItem(consoleShareStorageKey, String(clamped)) } catch { /* not remembered */ } + } + // The divider follows the pointer (mouse, pen or finger) ... + const startConsoleResize = (event: ReactPointerEvent) => { + const stack = stackRef.current + if (!stack) return + event.preventDefault() + const divider = event.currentTarget + divider.setPointerCapture(event.pointerId) + const move = (moveEvent: PointerEvent) => { + const rect = stack.getBoundingClientRect() + changeConsoleShare((rect.bottom - moveEvent.clientY) / rect.height) + } + const end = () => { + divider.removeEventListener('pointermove', move) + divider.removeEventListener('pointerup', end) + divider.removeEventListener('pointercancel', end) + } + divider.addEventListener('pointermove', move) + divider.addEventListener('pointerup', end) + divider.addEventListener('pointercancel', end) + } + // ... and the arrow keys: up makes the console taller. + const resizeConsoleWithKeys = (event: KeyboardEvent) => { + const steps: Record = { ArrowUp: 0.05, ArrowDown: -0.05, PageUp: 0.15, PageDown: -0.15 } + if (event.key in steps) changeConsoleShare(consoleShare + steps[event.key]) + else if (event.key === 'Home') changeConsoleShare(CONSOLE_SHARE_MIN) + else if (event.key === 'End') changeConsoleShare(CONSOLE_SHARE_MAX) + else return + event.preventDefault() + } const [showStopButton, setShowStopButton] = useState(false) useEffect(() => { if (!isBusy) { setShowStopButton(false); return } @@ -1920,6 +1998,26 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { const visualStepForward = () => setVisualStepIndex((index) => Math.min(visualTraceSteps.length - 1, index + 1)) const visualFirstStep = () => setVisualStepIndex(-1) const visualLastStep = () => setVisualStepIndex(visualTraceSteps.length - 1) + // The workspace's buttons, in the embed's order and with its labels. While + // visualizing they stay (so nothing moves) and leave the visualizer first. + // The run button holds both of its labels, the hidden one invisible, so it + // keeps its width as ▶ becomes ■. + const runFromActions = () => { if (isVisualizing) exitVisualizer(); runCode() } + const testsFromActions = () => { if (isVisualizing) exitVisualizer(); runTests() } + const actionsNode = workspaceCapabilities.actions ?
{workspaceCapabilities.tests && }{workspaceCapabilities.visualization && }{workspaceCapabilities.resetOutput && }
: null + const breadcrumbsNode =
{project.name}/{selectedFile.path}
+ const visualizerBarNode =
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
+ const visualizerWarningNode = <>{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
} + const editorTabsNode =
{openFiles.map((path) => { const file = project.files[path]; return })}
+ const editorWrapNode =
{isVisualizing ?
: null} />{animationsEnabled && closingBoxes.length > 0 &&
{closingBoxes.map((box) =>
)}
}{(connectorPaths.length > 0 || flights.length > 0) && {connectorPaths.map((connector, index) => )}{animationsEnabled && flights.map((flight) => )}}{animationsEnabled && flights.length > 0 &&
{flights.map((flight) => {flight.value})}
}
: { registerGlpythonPython(instance); defineGlpythonTheme(instance) }} value={selectedFile.code} onChange={updateCode} 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' }} />}
+ 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} />
)}}
{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
} + // 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. + const drawsSomething = (isVisualizing ? visualTrace?.graphics.length ?? 0 : graphics.length) > 0 + const shownGraphics = isVisualizing ? (visualTrace?.graphics.slice(0, currentTraceStep?.graphicsLen ?? 0) as TurtleCommand[] ?? []) : graphics + const stackedGraphicsNode = workspaceCapabilities.graphics && drawsSomething && !graphicsDismissed ? : null + const workspaceClassName = [ 'glpython-workspace', isVisualizing ? 'glpython-workspace--visualizing' : '', @@ -1936,7 +2034,7 @@ function GlPythonWorkspace(props: GlPythonWorkspaceProps) { return (
-
{project.name}/{selectedFile.path}
{isVisualizing ? null : workspaceCapabilities.actions ? <>{workspaceCapabilities.tests && } : null}
{isVisualizing &&
{visualTraceSteps.length > 0 ? <>
{ setVisualStepIndex(Number(event.target.value)) }} />{errorTraceStep && }
{clampedStepIndex === -1 ? t.visualizerBeforeStart : t.visualizerStepOf(clampedStepIndex + 1, visualTraceSteps.length)}{visualTrace?.truncated && {t.visualizerTruncated}} : {t.visualizerNoSteps}}
{workspaceCapabilities.export && <>}{workspaceCapabilities.exitVisualizer && }
}{isVisualizing && visualTrace?.error && !errorTraceStep &&
{t.visualizerError(visualTrace.error)}
}{(workspaceCapabilities.editor || isVisualizing) &&
{openFiles.map((path) => { const file = project.files[path]; return })}
{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} 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 }} />}
}{!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} />
)}}
{workspaceCapabilities.repl &&
{ event.preventDefault(); submitRepl() }}> { setReplInput(event.target.value); setReplHistoryIndex(null) }} onKeyDown={handleReplKeyDown} aria-label={t.replInputLabel ?? 'Python input'} spellCheck={false} autoComplete="off" autoCapitalize="off" />
}
}
{isGraphicsOpen ? : }
+ {compact ?
{breadcrumbsNode}{isVisualizing || !actionsNode ?
: actionsNode}
{isVisualizing && visualizerBarNode}{visualizerWarningNode}{(workspaceCapabilities.editor || isVisualizing) &&
{editorTabsNode}{editorWrapNode}
}{consoleNode}
{isGraphicsOpen ? : }
: <>
{!actionsContainer && (actionsNode || workspaceCapabilities.breadcrumbs) &&
{workspaceCapabilities.breadcrumbs && breadcrumbsNode}{actionsNode}
}
{isVisualizing ? visualizerBarNode : workspaceCapabilities.fileTabs ? editorTabsNode : null}
{visualizerWarningNode}{editorWrapNode}
{stackedGraphicsNode}
{consoleNode}
{actionsContainer && actionsNode && createPortal(actionsNode, actionsContainer)}} {inputRequest !== null &&
respondToInput(true)}>
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); respondToInput(false) }}>
{t.inputDialogTitle}

{inputRequest || t.inputDialogFallbackPrompt}

setInputValue(event.target.value)} placeholder={t.inputPlaceholder} />
}
) diff --git a/src/i18n.ts b/src/i18n.ts index d07d583..ba847d1 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -12,10 +12,10 @@ export const coreStrings: Record = { en: { exitVisualizer: 'Exit visualizer', readyOutput: 'No output yet – run the code.', - resetOutput: 'Reset output', + resetOutput: 'Reset', visualize: 'Visualize', visualizing: 'Visualizing...', - runProject: 'Run project', + runProject: 'Run', running: 'Running...', runningOutput: (mainFile) => `Running ${mainFile} and its project files...`, visualizerBeforeStart: 'Before execution', @@ -65,12 +65,14 @@ export const coreStrings: Record = { output: 'Console', stop: 'Stop', stoppedNote: 'Stopped. Python was restarted.', + consoleSize: 'Console size', + editorPlaceholder: 'Type code here', pyodideRuntime: 'Pyodide runtime', runsFile: (mainFile) => `Runs ${mainFile}`, noOutput: '(No output)', turtleGraphics: 'Turtle graphics', gturtleWindow: 'gturtle window', - collapseGraphics: 'Collapse graphics window', + collapseGraphics: 'Hide graphics', expandGraphics: 'Expand graphics window', graphicsEmptyBefore: 'The drawing appears here when a project that imports', graphicsEmptyAfter: 'runs.', @@ -84,10 +86,10 @@ export const coreStrings: Record = { de: { exitVisualizer: 'Visualisierung beenden', readyOutput: 'Noch keine Ausgabe – Code ausführen.', - resetOutput: 'Ausgabe zurücksetzen', + resetOutput: 'Zurücksetzen', visualize: 'Visualisieren', visualizing: 'Visualisierung läuft...', - runProject: 'Projekt ausführen', + runProject: 'Ausführen', running: 'Wird ausgeführt...', runningOutput: (mainFile) => `${mainFile} und die Projektdateien werden ausgeführt...`, visualizerBeforeStart: 'Vor der Ausführung', @@ -137,12 +139,14 @@ export const coreStrings: Record = { output: 'Konsole', stop: 'Stoppen', stoppedNote: 'Gestoppt. Python wurde neu gestartet.', + consoleSize: 'Grösse der Konsole', + editorPlaceholder: 'Code hier eingeben', pyodideRuntime: 'Pyodide-Laufzeit', runsFile: (mainFile) => `Führt ${mainFile} aus`, noOutput: '(Keine Ausgabe)', turtleGraphics: 'Turtle-Grafik', gturtleWindow: 'gturtle-Fenster', - collapseGraphics: 'Grafikfenster einklappen', + collapseGraphics: 'Grafik ausblenden', expandGraphics: 'Grafikfenster ausklappen', graphicsEmptyBefore: 'Die Zeichnung erscheint hier, wenn ein Projekt läuft, das', graphicsEmptyAfter: 'importiert.', diff --git a/src/style.css b/src/style.css index 24ab8f4..7eddea2 100644 --- a/src/style.css +++ b/src/style.css @@ -391,3 +391,33 @@ .test-failure .secondary-button { margin-top: 8px; padding: 4px 10px; font-size: 13px; } .visual-expectation-message { padding: 2px 8px 4px; color: var(--glp-error); font: 600 13px/1.5 var(--glp-font-code); white-space: pre-wrap; } + +/* The stacked layout (not compact - the IDE): the editor or the visualizer in + full width on top (with the graphics beside it while the program draws), + a divider that can be dragged or moved with the arrow keys, and the + console in full width below. Its height share is set inline. */ +.workspace-stack { min-width: 0; min-height: 0; flex: 1; display: flex; flex-direction: column; } +.workspace-stack > .editor-header { margin-bottom: 12px; } +.stack-top { min-height: 120px; flex: 1 1 0; display: flex; gap: 12px; } +.stack-top > .editor-panel { min-height: 0; } +.stack-top .editor-wrap { display: flex; flex-direction: column; } +.stack-top .editor-wrap > * { min-height: 0; flex: 1; } +/* The tabs and, while visualizing, the visualizer's bar take the same strip, + so nothing below moves when switching. */ +.stack-strip { min-height: 44px; flex-shrink: 0; display: flex; align-items: stretch; background: var(--glp-surface); border-bottom: 1px solid var(--glp-border); } +.stack-strip > .editor-tabs { flex: 1; min-height: 0; border-bottom: 0; } +.stack-strip > .visualizer-bar { flex: 1; border: 0; border-radius: 0; } +.stack-top > .graphics-panel { width: min(42%, 520px); } +.stack-divider { flex: 0 0 14px; display: flex; align-items: center; justify-content: center; cursor: row-resize; touch-action: none; border-radius: var(--glp-radius); } +.stack-divider::after { content: ""; width: 44px; height: 4px; background: var(--glp-border-control); border-radius: 2px; opacity: .55; } +.stack-divider:hover::after, .stack-divider:focus-visible::after { opacity: 1; background: var(--glp-accent); } +.stack-divider:focus-visible { outline: 2px solid var(--glp-accent); outline-offset: -2px; } +.stack-console { min-height: 90px; flex: 1 1 0; display: flex; flex-direction: column; } +.stack-console > .output-panel { min-height: 0; flex: 1; display: flex; flex-direction: column; } +.stack-console .console { min-height: 0; max-height: none; flex: 1; } + +/* ▶ and ■ stacked in one place, the inactive one invisible, so the button + keeps the width of the wider one. */ +.run-button-labels { display: inline-grid; } +.run-button-labels > span { grid-area: 1 / 1; display: inline-flex; align-items: center; justify-content: center; gap: 7px; } +.run-button-labels > [aria-hidden="true"] { visibility: hidden; }