diff --git a/package.json b/package.json index b221686f..2150048d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dodona/papyros", - "version": "2.0.0", + "version": "2.1.0", "private": false, "homepage": ".", "devDependencies": { @@ -45,6 +45,7 @@ "@codemirror/state": "^6.3.2", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.22.1", + "@dodona/trace-component": "1.1.6", "@lezer/common": "^1.1.0", "comlink": "^4.4.1", "comsync": "^0.0.9", diff --git a/scripts/build_library.sh b/scripts/build_library.sh index 1ca80b5d..6671d6d7 100644 --- a/scripts/build_library.sh +++ b/scripts/build_library.sh @@ -4,5 +4,5 @@ cd src/workers/python python3 build_package.py cd - tsc -mv src/workers/python/python_package.tar.gz.load_by_url dist/workers/python +cp src/workers/python/python_package.tar.gz.load_by_url dist/workers/python npx tailwindcss -i ./src/Papyros.css -o ./dist/Papyros.css \ No newline at end of file diff --git a/src/App.ts b/src/App.ts index b4bcf2cf..4deb91d6 100644 --- a/src/App.ts +++ b/src/App.ts @@ -59,7 +59,10 @@ async function startPapyros(): Promise { standAloneOptions: { parentElementId: "root" }, - darkMode: darkMode + darkMode: darkMode, + traceOptions: { + parentElementId: "trace-root" + } }); setUpEditor(papyros.codeRunner.editor, LOCAL_STORAGE_KEYS.code); papyros.codeRunner.editor.focus(); diff --git a/src/Backend.ts b/src/Backend.ts index 2766feea..c776da5c 100644 --- a/src/Backend.ts +++ b/src/Backend.ts @@ -30,9 +30,10 @@ export interface WorkerDiagnostic { message: string; } -export interface RunMode { - mode: string; - active: boolean; +export enum RunMode { + Run = "run", + Debug = "debug", + Doctest = "doctest" } export abstract class Backend { diff --git a/src/BackendEvent.ts b/src/BackendEvent.ts index 4c8da8de..e5f4be51 100644 --- a/src/BackendEvent.ts +++ b/src/BackendEvent.ts @@ -9,17 +9,12 @@ export enum BackendEventType { Sleep = "sleep", Error = "error", Interrupt = "interrupt", - Loading = "loading" + Loading = "loading", + Frame = "frame", + FrameChange = "frame-change", + Stop = "stop", } -/** - * All possible types for ease of iteration - */ -export const BACKEND_EVENT_TYPES = [ - BackendEventType.Start, BackendEventType.End, - BackendEventType.Input, BackendEventType.Output, - BackendEventType.Sleep, BackendEventType.Error, - BackendEventType.Interrupt, BackendEventType.Loading -]; + /** * Interface for events used for communication between threads */ diff --git a/src/BackendManager.ts b/src/BackendManager.ts index 8d4061cd..d28df9d3 100644 --- a/src/BackendManager.ts +++ b/src/BackendManager.ts @@ -101,7 +101,7 @@ export abstract class BackendManager { if (e.type === BackendEventType.Start) { BackendManager.halted = false; } - if (!BackendManager.halted && this.subscriberMap.has(e.type)) { + if ((!BackendManager.halted || e.type === BackendEventType.FrameChange) && this.subscriberMap.has(e.type)) { this.subscriberMap.get(e.type)!.forEach(cb => cb(e)); } } diff --git a/src/CodeRunner.ts b/src/CodeRunner.ts index 14a91dfd..707f67d1 100644 --- a/src/CodeRunner.ts +++ b/src/CodeRunner.ts @@ -1,26 +1,38 @@ import { proxy } from "comlink"; import { SyncClient } from "comsync"; -import { Backend } from "./Backend"; +import { Backend, RunMode } from "./Backend"; import { BackendEvent, BackendEventType } from "./BackendEvent"; import { BackendManager } from "./BackendManager"; import { CodeEditor } from "./editor/CodeEditor"; import { addPapyrosPrefix, - APPLICATION_STATE_TEXT_ID, CODE_BUTTONS_WRAPPER_ID, DEFAULT_EDITOR_DELAY, RUN_BTN_ID, - STATE_SPINNER_ID, STOP_BTN_ID + APPLICATION_STATE_TEXT_ID, + CODE_BUTTONS_WRAPPER_ID, + DEFAULT_EDITOR_DELAY, + RUN_BUTTONS_WRAPPER_ID, + STATE_SPINNER_ID, + STOP_BTN_ID } from "./Constants"; import { InputManager, InputManagerRenderOptions, InputMode } from "./InputManager"; import { ProgrammingLanguage } from "./ProgrammingLanguage"; import { renderSpinningCircle } from "./util/HTMLShapes"; +import { addListener, downloadResults, getElement, parseData, t } from "./util/Util"; import { - addListener, getElement, - t, downloadResults, parseData -} from "./util/Util"; -import { - RenderOptions, renderWithOptions, - renderButton, ButtonOptions, Renderable, appendClasses + appendClasses, + ButtonOptions, + Renderable, + renderButton, + RenderOptions, + renderWithOptions } from "./util/Rendering"; import { OutputManager } from "./OutputManager"; +import { Debugger } from "./Debugger"; + +const MODE_ICONS: Record = { + "debug": "", + "doctest": "", + "run": "" +}; interface DynamicButton { id: string; @@ -45,6 +57,7 @@ interface CodeRunnerRenderOptions { * RenderOptions for the output field */ outputOptions: RenderOptions; + traceOptions: RenderOptions; } /** @@ -72,6 +85,29 @@ export interface LoadingData { status: "loading" | "loaded" | "failed"; } + +/** + * Helper class to avoid code duplication when handling buttons + * It is an ordered array that does not allow duplicate ids + */ +class ButtonArray extends Array { + public add(button: ButtonOptions, onClick: () => void): void { + this.remove(button.id); + this.push({ + id: button.id, + buttonHTML: renderButton(button), + onClick + }); + } + + public remove(id: string): void { + const existingIndex = this.findIndex(b => b.id === id); + if (existingIndex !== -1) { + this.splice(existingIndex, 1); + } + } +} + /** * Helper component to manage and visualize the current RunState */ @@ -92,6 +128,7 @@ export class CodeRunner extends Renderable { * Component to handle output generated by the user's code */ public readonly outputManager: OutputManager; + public readonly traceViewer: Debugger; /** * The backend that executes the code asynchronously */ @@ -101,9 +138,13 @@ export class CodeRunner extends Renderable { */ private state: RunState; /** - * Buttons managed by this component + * Foreign buttons inserted into this component + */ + private userButtons: ButtonArray; + /** + * Internal buttons for different run modes */ - private buttons: Array; + private runButtons: ButtonArray; /** * Array of packages that are being installed @@ -138,23 +179,14 @@ export class CodeRunner extends Renderable { }, inputMode); this.outputManager = new OutputManager(); this.backend = Promise.resolve({} as SyncClient); - this.buttons = []; + this.userButtons = new ButtonArray(); + this.runButtons = new ButtonArray(); + this.updateRunButtons([RunMode.Debug]); this.editor.onChange({ onChange: async code => { const backend = await this.backend; const modes = await backend.workerProxy.runModes(code); - modes.forEach(mode => { - const id = addPapyrosPrefix(mode.mode); - if (mode.active) { - this.addButton({ - id: id, - buttonText: t(`Papyros.run_modes.${mode.mode}`), - classNames: "_tw-text-white _tw-bg-neutral-bg" - }, () => this.runCode(this.editor.getText(), mode.mode)); - } else { - this.removeButton(id); - } - }); + this.updateRunButtons(modes); this.renderButtons(); }, delay: DEFAULT_EDITOR_DELAY @@ -167,9 +199,27 @@ export class CodeRunner extends Renderable { e => this.onLoad(e)); BackendManager.subscribe(BackendEventType.Start, e => this.onStart(e)); + BackendManager.subscribe(BackendEventType.Stop, () => this.stop()); this.previousState = RunState.Ready; this.runStartTime = new Date().getTime(); this.state = RunState.Ready; + this.traceViewer = new Debugger(); + } + + private updateRunButtons(modes: Array): void { + this.runButtons = new ButtonArray(); + this.addRunButton(RunMode.Run, "btn-primary"); + modes.forEach(m => this.addRunButton(m)); + } + + private addRunButton(mode: RunMode, classNames = "btn-secondary"): void { + const id = addPapyrosPrefix(mode)+"-code-btn"; + this.runButtons.add({ + id: id, + buttonText: t(`Papyros.run_modes.${mode}`), + classNames, + icon: MODE_ICONS[mode] + }, () => this.runCode(this.editor.getText(), mode)); } /** @@ -277,10 +327,8 @@ export class CodeRunner extends Renderable { * @param {string} id Identifier of the button to remove */ private removeButton(id: string): void { - const existingIndex = this.buttons.findIndex(b => b.id === id); - if (existingIndex !== -1) { - this.buttons.splice(existingIndex, 1); - } + this.userButtons.remove(id); + this.renderButtons(this.userButtons, CODE_BUTTONS_WRAPPER_ID); } /** @@ -289,54 +337,46 @@ export class CodeRunner extends Renderable { * @param {function} onClick Listener for click events on the button */ public addButton(options: ButtonOptions, onClick: () => void): void { - this.removeButton(options.id); - this.buttons.push({ - id: options.id, - buttonHTML: renderButton(options), - onClick: onClick - }); + this.userButtons.add(options, onClick); + this.renderButtons(this.userButtons, CODE_BUTTONS_WRAPPER_ID); } /** * Generate a button that the user can click to process code * Can either run the code or interrupt it if already running - * @return {DynamicButton} A button to interact with the code according to the current state + * @return {DynamicButton} A list of buttons to interact with the code according to the current state */ - private getCodeActionButton(): DynamicButton { + private getCodeActionButtons(): DynamicButton[] { let buttonOptions: ButtonOptions; - let buttonHandler: () => void; if ([RunState.Ready, RunState.Loading].includes(this.state)) { - buttonOptions = { - id: RUN_BTN_ID, - buttonText: t("Papyros.run"), - classNames: "_tw-text-white _tw-bg-blue-500" - }; - buttonHandler = () => this.runCode(this.editor.getText()); + return this.runButtons; } else { buttonOptions = { id: STOP_BTN_ID, buttonText: t("Papyros.stop"), - classNames: "_tw-text-white _tw-bg-red-500" + classNames: "btn-danger", + icon: "" }; - buttonHandler = () => this.stop(); + + return [{ + id: buttonOptions.id, + buttonHTML: renderButton(buttonOptions), + onClick: () => this.stop() + }]; } - appendClasses(buttonOptions, "_tw-min-w-[60px]"); - return { - id: buttonOptions.id, - buttonHTML: renderButton(buttonOptions), - onClick: buttonHandler - }; } /** * Specific helper method to render only the buttons required by the user + * @param {DynamicButton[]} buttons The buttons to render + * @param {string} id The id of the element to render the buttons in */ - private renderButtons(): void { - const buttons = [this.getCodeActionButton(), ...this.buttons]; - getElement(CODE_BUTTONS_WRAPPER_ID).innerHTML = - buttons.map(b => b.buttonHTML).join("\n"); + private renderButtons(buttons: DynamicButton[] | undefined = undefined, id = RUN_BUTTONS_WRAPPER_ID): void { + const btns = buttons || this.getCodeActionButtons(); + getElement(id).innerHTML = + btns.map(b => b.buttonHTML).join("\n"); // Buttons are freshly added to the DOM, so attach listeners now - buttons.forEach(b => addListener(b.id, b.onClick, "click")); + btns.forEach(b => addListener(b.id, b.onClick, "click")); } protected override _render(options: CodeRunnerRenderOptions): HTMLElement { @@ -344,21 +384,27 @@ export class CodeRunner extends Renderable { // eslint-disable-next-line max-len "_tw-border-solid _tw-border-gray-200 _tw-border-b-2 dark:_tw-border-dark-mode-content"); const rendered = renderWithOptions(options.statusPanelOptions, ` -
-
-
-
-
+
+
${renderSpinningCircle(STATE_SPINNER_ID, "_tw-border-gray-200 _tw-border-b-red-500")} +
+
+
+
+
+
+
`); this.setState(this.state); + this.renderButtons(this.userButtons, CODE_BUTTONS_WRAPPER_ID); this.inputManager.render(options.inputOptions); this.outputManager.render(options.outputOptions); this.editor.render(options.codeEditorOptions); this.editor.setPanel(rendered); // Set language again to update the placeholder this.editor.setProgrammingLanguage(this.programmingLanguage); + this.traceViewer.render(options.traceOptions); return rendered; } @@ -405,9 +451,6 @@ export class CodeRunner extends Renderable { // Was interrupted, End message already published interrupted = true; } - this.setState(RunState.Ready, t( - interrupted ? "Papyros.interrupted" : "Papyros.finished", - { time: (new Date().getTime() - this.runStartTime) / 1000 })); if (terminated) { await this.start(); } else if (await backend.workerProxy.hasOverflow()) { @@ -421,6 +464,9 @@ export class CodeRunner extends Renderable { ); }); } + this.setState(RunState.Ready, t( + interrupted ? "Papyros.interrupted" : "Papyros.finished", + { time: (new Date().getTime() - this.runStartTime) / 1000 })); } } diff --git a/src/Constants.ts b/src/Constants.ts index a3cfdc31..2315c45f 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -21,7 +21,7 @@ export const PANEL_WRAPPER_ID = addPapyrosPrefix("code-status-panel"); export const STATE_SPINNER_ID = addPapyrosPrefix("state-spinner"); export const APPLICATION_STATE_TEXT_ID = addPapyrosPrefix("application-state-text"); export const CODE_BUTTONS_WRAPPER_ID = addPapyrosPrefix("code-buttons"); -export const RUN_BTN_ID = addPapyrosPrefix("run-code-btn"); +export const RUN_BUTTONS_WRAPPER_ID = addPapyrosPrefix("run-buttons"); export const STOP_BTN_ID = addPapyrosPrefix("stop-btn"); export const SEND_INPUT_BTN_ID = addPapyrosPrefix("send-input-btn"); export const SWITCH_INPUT_MODE_A_ID = addPapyrosPrefix("switch-input-mode"); diff --git a/src/Debugger.ts b/src/Debugger.ts new file mode 100644 index 00000000..1b3ba87e --- /dev/null +++ b/src/Debugger.ts @@ -0,0 +1,110 @@ +import { Renderable, RenderOptions, renderWithOptions } from "./util/Rendering"; +import { getElement, t } from "./util/Util"; +import { TraceComponent } from "@dodona/trace-component"; +import { BackendManager } from "./BackendManager"; +import { BackendEventType } from "./BackendEvent"; +import "@dodona/trace-component"; +import { Frame } from "@dodona/trace-component/dist/trace_types"; + +const TRACE_COMPONENT_ID = "trace-component"; +const EXECUTION_LIMIT = 10000; + +export type FrameState = { + line: number; + outputs: number; + inputs: number; +}; + +function createDelayer(): (callback: () => void, ms: number) => void { + let timer: any; + return (callback, ms) => { + clearTimeout(timer); + timer = setTimeout(callback, ms); + }; +} +const delay = createDelayer(); + +export class Debugger extends Renderable { + private frameStates: FrameState[] = []; + private currentOutputs: number = 0; + private currentInputs: number = 0; + private traceComponent: TraceComponent | undefined; + private traceBuffer: Frame[] = []; + + constructor() { + super(); + this.reset(); + + BackendManager.subscribe(BackendEventType.Start, () => { + this.reset(); + + BackendManager.publish({ + type: BackendEventType.FrameChange, + data: { + line: 0, + outputs: 0, + inputs: 0 + } + }); + }); + BackendManager.subscribe(BackendEventType.Output, () => { + this.currentOutputs++; + }); + BackendManager.subscribe(BackendEventType.Input, () => { + this.currentInputs++; + }); + BackendManager.subscribe(BackendEventType.Frame, e => { + const frame = JSON.parse(e.data); + const frameState = { + line: frame.line, + outputs: this.currentOutputs, + inputs: this.currentInputs + }; + this.frameStates.push(frameState); + this.traceBuffer.push(frame); + if (this.traceBuffer.length > 100) { + this.clearBuffer(); + } else { + delay(() => this.clearBuffer(), 100); + } + if (this.frameStates.length >= EXECUTION_LIMIT) { + BackendManager.publish({ + type: BackendEventType.Stop, + data: "Execution limit reached" + }); + } + }); + } + + protected override _render(options: RenderOptions): void { + renderWithOptions(options, ` + + `); + + this.traceComponent = getElement(TRACE_COMPONENT_ID) as TraceComponent; + this.traceComponent.translations = t("Papyros.debugger") as any; + this.traceComponent.addEventListener("frame-change", e => { + const frame = (e as CustomEvent).detail.frame; + BackendManager.publish({ + type: BackendEventType.FrameChange, + data: this.frameStates[frame] + }); + }); + } + + public reset(): void { + this.frameStates = []; + this.currentOutputs = 0; + this.currentInputs = 0; + if (this.traceComponent) { + this.traceComponent.trace = []; + } + } + + public clearBuffer(): void { + for (const frame of this.traceBuffer) { + this.traceComponent?.addFrame(frame); + } + this.traceBuffer = []; + } +} diff --git a/src/OutputManager.ts b/src/OutputManager.ts index e188834a..5d6684a0 100644 --- a/src/OutputManager.ts +++ b/src/OutputManager.ts @@ -70,6 +70,18 @@ export class OutputManager extends Renderable { BackendManager.subscribe(BackendEventType.Output, e => this.showOutput(e)); BackendManager.subscribe(BackendEventType.Error, e => this.showError(e)); BackendManager.subscribe(BackendEventType.End, () => this.onRunEnd()); + BackendManager.subscribe(BackendEventType.FrameChange, e => { + const outputsToHighlight = e.data.outputs; + const outputElements = this.outputArea.children; + for (let i = 0; i < outputElements.length; i++) { + const outputElement = outputElements[i]; + if (i < outputsToHighlight) { + outputElement.setAttribute("style", "opacity: inherit;"); + } else { + outputElement.setAttribute("style", "opacity: 0.1;"); + } + } + }); } /** diff --git a/src/Papyros.css b/src/Papyros.css index ac201f39..0dfc13cf 100644 --- a/src/Papyros.css +++ b/src/Papyros.css @@ -159,10 +159,42 @@ Removes the default spacing and border for appropriate elements. } /* Also override CodeMirror buttons to use this style */ -.papyros-button, .cm-button { - @apply _tw-m-1 _tw-px-3 _tw-py-1 _tw-rounded-lg _tw-cursor-pointer disabled:_tw-opacity-50 disabled:_tw-cursor-not-allowed !important; +.papyros-button, +.cm-button, +.tailwind .papyros-button, +.tailwind .cm-button{ + @apply _tw-m-1 _tw-px-3 _tw-py-1 _tw-rounded-lg _tw-cursor-pointer disabled:_tw-opacity-50 disabled:_tw-cursor-not-allowed; + min-width: 60px; } /* Round the corners of textfields created by CodeMirror */ .cm-textfield { @apply _tw-rounded-lg !important; } + +.papyros-button.btn-primary { + @apply _tw-bg-blue-500 _tw-text-white; +} + +.papyros-button.btn-secondary { + @apply _tw-bg-gray-500 _tw-text-white; +} + +.papyros-button.btn-danger { + @apply _tw-bg-red-500 _tw-text-white; +} + +.papyros-button.with-icon { + padding-left: 8px; +} + +.papyros-button.with-icon svg{ + vertical-align: middle; +} + +.cm-gutter.cm-debugline-gutter { + display: none !important; +} + +.cm-gutter.cm-debugline-gutter.show { + display: flex !important; +} \ No newline at end of file diff --git a/src/Papyros.ts b/src/Papyros.ts index 0bd0602a..cee29fcb 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -1,25 +1,32 @@ /* eslint-disable max-len */ import I18n from "i18n-js"; import { - EDITOR_WRAPPER_ID, PROGRAMMING_LANGUAGE_SELECT_ID, - LOCALE_SELECT_ID, INPUT_AREA_WRAPPER_ID, EXAMPLE_SELECT_ID, - PANEL_WRAPPER_ID, DARK_MODE_TOGGLE_ID, - MAIN_APP_ID, OUTPUT_AREA_WRAPPER_ID + DARK_MODE_TOGGLE_ID, + EDITOR_WRAPPER_ID, + EXAMPLE_SELECT_ID, + INPUT_AREA_WRAPPER_ID, + LOCALE_SELECT_ID, + MAIN_APP_ID, + OUTPUT_AREA_WRAPPER_ID, + PANEL_WRAPPER_ID, + PROGRAMMING_LANGUAGE_SELECT_ID } from "./Constants"; import { InputManagerRenderOptions, InputMode } from "./InputManager"; import { ProgrammingLanguage } from "./ProgrammingLanguage"; -import { - t, loadTranslations, getLocales, - removeSelection, - addListener, getElement, cleanCurrentUrl -} from "./util/Util"; -import { RunState, CodeRunner } from "./CodeRunner"; +import { addListener, cleanCurrentUrl, getElement, getLocales, loadTranslations, removeSelection, t } from "./util/Util"; +import { CodeRunner, RunState } from "./CodeRunner"; import { getCodeForExample, getExampleNames } from "./examples/Examples"; import { AtomicsChannelOptions, makeChannel, ServiceWorkerChannelOptions } from "sync-message"; import { BackendManager } from "./BackendManager"; import { - RenderOptions, renderWithOptions, renderSelect, renderSelectOptions, - ButtonOptions, Renderable, renderLabel, appendClasses + appendClasses, + ButtonOptions, + Renderable, + renderLabel, + RenderOptions, + renderSelect, + renderSelectOptions, + renderWithOptions } from "./util/Rendering"; const LANGUAGE_MAP = new Map([ @@ -27,6 +34,7 @@ const LANGUAGE_MAP = new Map([ ["javascript", ProgrammingLanguage.JavaScript] ]); + /** * Configuration options for this instance of Papyros */ @@ -88,6 +96,7 @@ export interface PapyrosRenderOptions { * Whether to render in dark mode */ darkMode?: boolean; + traceOptions?: RenderOptions; } /** @@ -269,13 +278,12 @@ export class Papyros extends Renderable { ${exampleSelect}
`; renderWithOptions(renderOptions.standAloneOptions!, ` -
+
${navBar}
${header} -
+
${renderLabel(t("Papyros.code"), renderOptions.codeEditorOptions!.parentElementId)} @@ -290,6 +298,8 @@ export class Papyros extends Renderable {
+ +
`); @@ -325,7 +335,8 @@ export class Papyros extends Renderable { statusPanelOptions: renderOptions.statusPanelOptions!, inputOptions: renderOptions.inputOptions!, codeEditorOptions: renderOptions.codeEditorOptions!, - outputOptions: renderOptions.outputOptions! + outputOptions: renderOptions.outputOptions!, + traceOptions: renderOptions.traceOptions!, }); } diff --git a/src/Translations.js b/src/Translations.js index fe568c1d..25920c12 100644 --- a/src/Translations.js +++ b/src/Translations.js @@ -49,16 +49,23 @@ const ENGLISH_TRANSLATION = { "launch_error": "Papyros failed to load. Do you want to reload?", "loading": "Loading %{packages}.", "run_modes": { - "doctest": "Run doctests" + "doctest": "Run doctests", + "debug": "Debug", + "run": "Run" }, "used_input": "This line has already been used as input.", - "used_input_with_prompt": "This line was used as input for the following prompt: %{prompt}" + "used_input_with_prompt": "This line was used as input for the following prompt: %{prompt}", + "debugger": { + "title": "Drag the slider to walk through your code.", + "text_1": "This window shows how your program works step by step. Explore to see how your program builds and stores information.", + "text_2": "You can also use the %{previous} and %{next} buttons to go to the previous or next step. The %{first} and %{last} buttons can be used to directly jump to the first or last step respectively." + } }; const DUTCH_TRANSLATION = { "Papyros": "Papyros", "code": "Code", - "code_placeholder": "Schrijf hier je %{programmingLanguage} code en klik op 'Run' om uit te voeren...", + "code_placeholder": "Schrijf hier je %{programmingLanguage} code en klik op 'Uitvoeren' om uit te voeren...", "input": "Invoer", "input_placeholder": { "interactive": "Geef invoer in en druk op enter", @@ -68,7 +75,7 @@ const DUTCH_TRANSLATION = { "input_disabled": "Je kan enkel invoer invullen als je code erom vraagt in interactieve modus", "output": "Uitvoer", "output_placeholder": "Hier komt de uitvoer van je code.", - "run": "Run", + "run": "Uitvoeren", "stop": "Stop", "states": { "running": "Aan het uitvoeren", @@ -102,10 +109,17 @@ const DUTCH_TRANSLATION = { "launch_error": "Er liep iets fout bij het laden van Papyros. Wil je herladen?", "loading": "Bezig met het installeren van %{packages}.", "run_modes": { - "doctest": "Run doctests" + "doctest": "Doctests uitvoeren", + "debug": "Debuggen", + "run": "Uitvoeren" }, "used_input": "Deze regel werd al gebruikt als invoer.", - "used_input_with_prompt": "Deze regel werd gebruikt als invoer voor de volgende vraag: %{prompt}" + "used_input_with_prompt": "Deze regel werd gebruikt als invoer voor de volgende vraag: %{prompt}", + "debugger": { + "title": "Verken je code stap voor stap", + "text_1": "Dit venster toont de werking van je programma in detail. Ontdek hoe je programma informatie opbouwt en bewaart.", + "text_2": "Gebruik de schuifbalk om door je code te wandelen. Je kan ook de %{previous} en %{next} knoppen gebruiken om naar de vorige of volgende stap te gaan. De %{first} en %{last} knoppen kunnen gebruikt worden om direct naar de eerste of laatste stap te gaan." + } }; // Override some default English phrases to also use capitalized text diff --git a/src/editor/CodeEditor.ts b/src/editor/CodeEditor.ts index 572d8586..18efe826 100644 --- a/src/editor/CodeEditor.ts +++ b/src/editor/CodeEditor.ts @@ -1,31 +1,40 @@ /* eslint-disable valid-jsdoc */ import { ProgrammingLanguage } from "../ProgrammingLanguage"; import { t } from "../util/Util"; -import { - autocompletion, - closeBrackets, closeBracketsKeymap, completionKeymap -} from "@codemirror/autocomplete"; -import { - defaultKeymap, historyKeymap, indentWithTab, - history, insertBlankLine -} from "@codemirror/commands"; +import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete"; +import { defaultKeymap, history, historyKeymap, indentWithTab, insertBlankLine } from "@codemirror/commands"; import { javascript } from "@codemirror/lang-javascript"; import { python } from "@codemirror/lang-python"; import { - defaultHighlightStyle, indentUnit, LanguageSupport, - foldGutter, indentOnInput, bracketMatching, foldKeymap, syntaxHighlighting + bracketMatching, + defaultHighlightStyle, + foldGutter, + foldKeymap, + indentOnInput, + indentUnit, + LanguageSupport, + syntaxHighlighting } from "@codemirror/language"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; import { EditorState, Extension } from "@codemirror/state"; import { oneDarkHighlightStyle } from "@codemirror/theme-one-dark"; import { - EditorView, showPanel, lineNumbers, highlightActiveLineGutter, - highlightSpecialChars, drawSelection, - rectangularSelection, highlightActiveLine, keymap + drawSelection, + EditorView, + highlightActiveLine, + highlightActiveLineGutter, + highlightSpecialChars, + keymap, + lineNumbers, + rectangularSelection, + showPanel } from "@codemirror/view"; import { Diagnostic, linter, lintGutter, lintKeymap } from "@codemirror/lint"; import { CodeMirrorEditor } from "./CodeMirrorEditor"; import { darkTheme } from "./DarkTheme"; +import { DebugLineGutter } from "./Gutters"; +import { BackendManager } from "../BackendManager"; +import { BackendEventType } from "../BackendEvent"; /** * Component that provides useful features to users writing code @@ -37,6 +46,8 @@ export class CodeEditor extends CodeMirrorEditor { public static AUTOCOMPLETION = "autocompletion"; public static LINTING = "linting"; + private debugLineGutter: DebugLineGutter; + /** * Construct a new CodeEditor * @param {Function} onRunRequest Callback for when the user wants to run the code @@ -55,6 +66,7 @@ export class CodeEditor extends CodeMirrorEditor { maxHeight: "72vh", theme: {} }); + this.debugLineGutter = new DebugLineGutter(); this.addExtension([ keymap.of([ { @@ -68,10 +80,16 @@ export class CodeEditor extends CodeMirrorEditor { key: "Shift-Enter", run: insertBlankLine } ]), + this.debugLineGutter.toExtension(), ...CodeEditor.getExtensions() ]); this.setText(initialCode); this.setIndentLength(indentLength); + + BackendManager.subscribe(BackendEventType.FrameChange, e => { + const line = e.data.line; + this.debugLineGutter.markLine(this.editorView, line); + }); } public override setDarkMode(darkMode: boolean): void { diff --git a/src/editor/Gutters.ts b/src/editor/Gutters.ts index dff78b2b..9d642015 100644 --- a/src/editor/Gutters.ts +++ b/src/editor/Gutters.ts @@ -235,3 +235,51 @@ export class UsedInputGutters extends Gutters { }); } } + +/** + * shows the debugged line + */ +export class DebugLineGutter extends Gutters { + private activeLine: number = 0; + + constructor() { + super({ + name: "debugline", + extraExtensions: [ + EditorView.baseTheme({ + ".cm-debugline-gutter .cm-gutterElement": { + lineHeight: "12px", + marginRight: "-5px", + fontSize: "40px" + } + }) + ] + }); + } + + protected override marker(): GutterMarker { + return new SimpleMarker(() => document.createTextNode("⇨")); + } + + private hide(): void { + document.querySelector(".cm-debugline-gutter")?.classList.remove("show"); + } + + private show(): void { + document.querySelector(".cm-debugline-gutter")?.classList.add("show"); + } + + public markLine(view: EditorView, lineNr: number): void { + if (this.activeLine > 0) { + this.setMarker(view, { lineNr: this.activeLine, on: false }); + } + + if (lineNr > 0) { + this.setMarker(view, { lineNr, on: true }); + this.show(); + } else { + this.hide(); + } + this.activeLine = lineNr; + } +} diff --git a/src/input/BatchInputHandler.ts b/src/input/BatchInputHandler.ts index bfcbae65..698cecc5 100644 --- a/src/input/BatchInputHandler.ts +++ b/src/input/BatchInputHandler.ts @@ -2,6 +2,8 @@ import { InputManagerRenderOptions, InputMode } from "../InputManager"; import { UserInputHandler } from "./UserInputHandler"; import { t } from "../util/Util"; import { BatchInputEditor } from "../editor/BatchInputEditor"; +import { BackendManager } from "../BackendManager"; +import { BackendEventType } from "../BackendEvent"; export class BatchInputHandler extends UserInputHandler { /** @@ -41,6 +43,10 @@ export class BatchInputHandler extends UserInputHandler { onChange: this.handleInputChanged.bind(this), delay: 0 }); + BackendManager.subscribe(BackendEventType.FrameChange, e => { + const inputsToHighlight = e.data.inputs; + this.highlight(this.running, (i: number) => i < inputsToHighlight); + }); } /** diff --git a/src/input/InteractiveInputHandler.ts b/src/input/InteractiveInputHandler.ts index 29817491..22a699cd 100644 --- a/src/input/InteractiveInputHandler.ts +++ b/src/input/InteractiveInputHandler.ts @@ -76,7 +76,7 @@ export class InteractiveInputHandler extends UserInputHandler { const buttonHTML = renderButton({ id: SEND_INPUT_BTN_ID, // eslint-disable-next-line max-len - classNames: "_tw-text-black _tw-bg-white _tw-border-2 dark:_tw-text-white dark:_tw-bg-dark-mode-bg", + classNames: "btn-secondary", buttonText: t("Papyros.enter") }); renderWithOptions(options, ` diff --git a/src/util/Rendering.ts b/src/util/Rendering.ts index 72c6868f..7b66f9d7 100644 --- a/src/util/Rendering.ts +++ b/src/util/Rendering.ts @@ -95,6 +95,10 @@ export interface ButtonOptions { * Optional classes to apply to the button */ classNames?: string; + /** + * Optional icon to display in the button + */ + icon?: string; } /** @@ -105,9 +109,15 @@ export interface ButtonOptions { export function renderButton(options: ButtonOptions): string { appendClasses(options, "papyros-button"); + if (options.icon) { + appendClasses(options, + "with-icon"); + } + return ` `; } diff --git a/src/workers/python/PythonWorker.ts b/src/workers/python/PythonWorker.ts index acd68724..a31f69ea 100644 --- a/src/workers/python/PythonWorker.ts +++ b/src/workers/python/PythonWorker.ts @@ -80,11 +80,11 @@ export class PythonWorker extends Backend { } public override runModes(code: string): Array { - const modes = super.runModes(code); - modes.push({ - mode: "doctest", - active: this.papyros.has_doctests(code) - }); + let modes = super.runModes(code); + if (this.papyros.has_doctests(code)) { + modes = [RunMode.Doctest, ...modes]; + } + modes = [RunMode.Debug, ...modes]; return modes; } diff --git a/src/workers/python/build_package.py b/src/workers/python/build_package.py index 5054e861..6153b775 100644 --- a/src/workers/python/build_package.py +++ b/src/workers/python/build_package.py @@ -45,4 +45,4 @@ def check_tar(tarname, out_dir="."): if __name__ == "__main__": - create_package("python_package", "python-runner friendly_traceback pylint<3.0.0 tomli typing-extensions", extra_deps="papyros") + create_package("python_package", "python-runner friendly_traceback pylint<3.0.0 tomli typing-extensions json-tracer>=0.4.2", extra_deps="papyros") diff --git a/src/workers/python/papyros/papyros.py b/src/workers/python/papyros/papyros.py index 0489d378..81de08e9 100644 --- a/src/workers/python/papyros/papyros.py +++ b/src/workers/python/papyros/papyros.py @@ -11,7 +11,6 @@ from contextlib import contextmanager, redirect_stdout, redirect_stderr from pyodide_worker_runner import install_imports from pyodide import JsException, create_proxy - from .util import to_py SYS_RECURSION_LIMIT = 500 @@ -21,7 +20,7 @@ def __init__( self, *, source_code="", - filename="/my_program.py", + filename="/__main__.py", callback=None, buffer_constructor=None, limit=SYS_RECURSION_LIMIT @@ -146,7 +145,14 @@ async def run_async(self, source_code, mode="exec", top_level_await=True): code_obj = self.pre_run(source_code, mode=mode, top_level_await=top_level_await) if code_obj: self.callback("start", data="RunCode", contentType="text/plain") - result = self.execute(code_obj, mode) + if mode == "debug": + from tracer import JSONTracer + def frame_callback(frame): + self.callback("frame", data=frame, contentType="application/json") + + result = JSONTracer(False, False, False, frame_callback=frame_callback).runscript(source_code) + else: + result = self.execute(code_obj, mode) while isinstance(result, Awaitable): result = await result self.callback("end", data="CodeFinished", contentType="text/plain") diff --git a/yarn.lock b/yarn.lock index b48e716b..98e5cc30 100644 --- a/yarn.lock +++ b/yarn.lock @@ -504,6 +504,14 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@dodona/trace-component@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@dodona/trace-component/-/trace-component-1.1.6.tgz#dc9e526d61db1cf2f050aab31fdd9539beefb69f" + integrity sha512-1aVhfQvsPjVqJmo920rv0wn+JfkMf/RkI+reOhBSC8JJTWGajltfiqW0KNhsR59YKEIoZf49dbpsw2atO/Omxw== + dependencies: + "@jsplumb/browser-ui" "^6.2.10" + lit "^3.0.0" + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -803,6 +811,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jsplumb/browser-ui@^6.2.10": + version "6.2.10" + resolved "https://registry.yarnpkg.com/@jsplumb/browser-ui/-/browser-ui-6.2.10.tgz#86b85ed42110563d2816e3712677cdbbbf33366f" + integrity sha512-trk++mK5q6hceJL79teemzcilJ+8DrZT/lMK0+B80AtHqZHr0YwMCf+so2JBb2Z/MDZ0fUEU9MbELY6OPhhs5g== + "@leichtgewicht/ip-codec@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" @@ -843,6 +856,18 @@ "@lezer/highlight" "^1.0.0" "@lezer/lr" "^1.0.0" +"@lit-labs/ssr-dom-shim@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.1.2.tgz#d693d972974a354034454ec1317eb6afd0b00312" + integrity sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g== + +"@lit/reactive-element@^2.0.0": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-2.0.2.tgz#779ae9d265407daaf7737cb892df5ec2a86e22a0" + integrity sha512-SVOwLAWUQg3Ji1egtOt1UiFe4zdDpnWHyc5qctSceJ5XIu0Uc76YmGpIjZgx9YJ0XtdW0Jm507sDvjOu+HnB8w== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.1.2" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -1157,6 +1182,11 @@ resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.5.tgz#cb6e2a691b70cb177c6e3ae9c1d2e8b2ea8cd304" integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== +"@types/trusted-types@^2.0.2": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/ws@^8.5.5": version "8.5.10" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" @@ -3800,6 +3830,31 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +lit-element@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-4.0.2.tgz#1a519896d5ab7c7be7a8729f400499e38779c093" + integrity sha512-/W6WQZUa5VEXwC7H9tbtDMdSs9aWil3Ou8hU6z2cOKWbsm/tXPAcsoaHVEtrDo0zcOIE5GF6QgU55tlGL2Nihg== + dependencies: + "@lit-labs/ssr-dom-shim" "^1.1.2" + "@lit/reactive-element" "^2.0.0" + lit-html "^3.1.0" + +lit-html@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-3.1.0.tgz#a7b93dd682073f2e2029656f4e9cd91e8034c196" + integrity sha512-FwAjq3iNsaO6SOZXEIpeROlJLUlrbyMkn4iuv4f4u1H40Jw8wkeR/OUXZUHUoiYabGk8Y4Y0F/rgq+R4MrOLmA== + dependencies: + "@types/trusted-types" "^2.0.2" + +lit@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lit/-/lit-3.1.0.tgz#76429b85dc1f5169fed499a0f7e89e2e619010c9" + integrity sha512-rzo/hmUqX8zmOdamDAeydfjsGXbbdtAFqMhmocnh2j9aDYqbu0fjXygjCa0T99Od9VQ/2itwaGrjZz/ZELVl7w== + dependencies: + "@lit/reactive-element" "^2.0.0" + lit-element "^4.0.0" + lit-html "^3.1.0" + loader-runner@^4.2.0: version "4.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1"