diff --git a/.gitignore b/.gitignore index a27cf1c6..761db583 100644 --- a/.gitignore +++ b/.gitignore @@ -25,8 +25,8 @@ yarn-debug.log* yarn-error.log* .eslintcache - +.vscode # script outputs translationIssues.txt - +# Ignore output of local pip install when building the tar src/workers/python/python_package/ diff --git a/package.json b/package.json index 02b5a714..f62aaef3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dodona/papyros", - "version": "0.3.8", + "version": "0.4.3", "private": false, "homepage": ".", "devDependencies": { diff --git a/scripts/ValidateTranslations.js b/scripts/ValidateTranslations.js index 865539fc..4082d8ee 100644 --- a/scripts/ValidateTranslations.js +++ b/scripts/ValidateTranslations.js @@ -35,7 +35,8 @@ const checks = [ "Papyros.locales.*", "Papyros.states.*", "Papyros.switch_input_mode_to.*", - "Papyros.input_placeholder.*" + "Papyros.input_placeholder.*", + "Papyros.run_modes.*" ] } ]; diff --git a/src/App.ts b/src/App.ts index 54f93e83..5536abb7 100644 --- a/src/App.ts +++ b/src/App.ts @@ -20,7 +20,7 @@ async function startPapyros(): Promise { standAlone: true, programmingLanguage: language, locale: locale, - inputMode: InputMode.Interactive, + inputMode: InputMode.Batch, channelOptions: { serviceWorkerName: DEFAULT_SERVICE_WORKER } @@ -45,9 +45,14 @@ async function startPapyros(): Promise { if (previousCode) { papyros.setCode(previousCode); } - papyros.codeRunner.editor.onChange(code => { - window.localStorage.setItem(LOCAL_STORAGE_CODE_KEY, code); - }); + papyros.codeRunner.editor.onChange( + { + onChange: (code: string) => { + window.localStorage.setItem(LOCAL_STORAGE_CODE_KEY, code); + }, + delay: 0 + } + ); await papyros.launch(); } diff --git a/src/Backend.ts b/src/Backend.ts index 519673d1..a9400822 100644 --- a/src/Backend.ts +++ b/src/Backend.ts @@ -65,6 +65,11 @@ export interface WorkerDiagnostic { message: string; } +export interface RunMode { + mode: string; + active: boolean; +} + export abstract class Backend { /** * SyncExtras object that grants access to helpful methods @@ -122,13 +127,24 @@ export abstract class Backend { return Promise.resolve(); } + /** + * Determine whether the modes supported by this Backend are active + * @param {string} code The current code in the editor + * @return {Array} The run modes of this Backend + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public runModes(code: string): Array { + return []; + } + /** * Executes the given code * @param {Extras} extras Helper properties to run code * @param {string} code The code to run + * @param {string} mode The mode to run the code in * @return {Promise} Promise of execution */ - public abstract runCode(extras: Extras, code: string): Promise; + public abstract runCode(extras: Extras, code: string, mode?: string): Promise; /** * Converts the context to a cloneable object containing useful properties diff --git a/src/CodeRunner.ts b/src/CodeRunner.ts index 15881340..3f76e146 100644 --- a/src/CodeRunner.ts +++ b/src/CodeRunner.ts @@ -3,12 +3,13 @@ import { SyncClient } from "comsync"; import { Backend } from "./Backend"; import { BackendEvent, BackendEventType } from "./BackendEvent"; import { BackendManager } from "./BackendManager"; -import { CodeEditor } from "./CodeEditor"; +import { CodeEditor } from "./editor/CodeEditor"; import { - APPLICATION_STATE_TEXT_ID, RUN_BTN_ID, + addPapyrosPrefix, + APPLICATION_STATE_TEXT_ID, CODE_BUTTONS_WRAPPER_ID, DEFAULT_EDITOR_DELAY, RUN_BTN_ID, STATE_SPINNER_ID, STOP_BTN_ID } from "./Constants"; -import { InputManager } from "./InputManager"; +import { InputManager, InputManagerRenderOptions, InputMode } from "./InputManager"; import { ProgrammingLanguage } from "./ProgrammingLanguage"; import { renderSpinningCircle } from "./util/HTMLShapes"; import { @@ -35,7 +36,7 @@ interface CodeRunnerRenderOptions { /** * Options for rendering the InputManager */ - inputOptions: RenderOptions; + inputOptions: InputManagerRenderOptions; /** * Options for rendering the editor */ @@ -116,20 +117,21 @@ export class CodeRunner extends Renderable { /** * Construct a new RunStateManager with the given listeners * @param {ProgrammingLanguage} programmingLanguage The language to use + * @param {InputMode} inputMode The input mode to use */ - constructor(programmingLanguage: ProgrammingLanguage) { + constructor(programmingLanguage: ProgrammingLanguage, inputMode: InputMode) { super(); this.programmingLanguage = programmingLanguage; this.editor = new CodeEditor(() => { if (this.state === RunState.Ready) { - this.runCode(this.editor.getCode()); + this.runCode(this.editor.getText()); } }); this.inputManager = new InputManager(async (input: string) => { const backend = await this.backend; backend.writeMessage(input); this.setState(RunState.Running); - }); + }, inputMode); this.outputManager = new OutputManager(); this.backend = Promise.resolve({} as SyncClient); this.buttons = []; @@ -137,12 +139,33 @@ export class CodeRunner extends Renderable { id: RUN_BTN_ID, buttonText: t("Papyros.run"), classNames: "_tw-text-white _tw-bg-blue-500" - }, () => this.runCode(this.editor.getCode())); + }, () => this.runCode(this.editor.getText())); this.addButton({ id: STOP_BTN_ID, buttonText: t("Papyros.stop"), classNames: "_tw-text-white _tw-bg-red-500" }, () => this.stop()); + 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-green-500" + }, () => this.runCode(this.editor.getText(), mode.mode)); + } else { + this.removeButton(id); + } + }); + this.renderButtons(); + }, + delay: DEFAULT_EDITOR_DELAY + }); + BackendManager.subscribe(BackendEventType.Input, () => this.setState(RunState.AwaitingInput)); this.loadingPackages = []; @@ -177,7 +200,7 @@ export class CodeRunner extends Renderable { }); this.editor.setLintingSource( async view => { - const workerDiagnostics = await workerProxy.lintCode(this.editor.getCode()); + const workerDiagnostics = await workerProxy.lintCode(this.editor.getText()); return workerDiagnostics.map(d => { const fromline = view.state.doc.line(d.lineNr); const toLine = view.state.doc.line(d.endLineNr); @@ -250,7 +273,11 @@ export class CodeRunner extends Renderable { * @param {string} message Optional message to indicate the state */ public setState(state: RunState, message?: string): void { - this.state = state; + if (state !== this.state) { + getElement(APPLICATION_STATE_TEXT_ID).innerText = + message || t(`Papyros.states.${state}`); + this.state = state; + } this.stopButton.disabled = [RunState.Ready, RunState.Loading].includes(state); if ([RunState.Ready, RunState.Loading].includes(state)) { this.showSpinner(state == RunState.Loading); @@ -259,20 +286,26 @@ export class CodeRunner extends Renderable { this.showSpinner(true); this.runButton.disabled = true; } - getElement(APPLICATION_STATE_TEXT_ID).innerText = - message || t(`Papyros.states.${state}`); } public getState(): RunState { return this.state; } + public removeButton(id: string): void { + const existingIndex = this.buttons.findIndex(b => b.id === id); + if (existingIndex !== -1) { + this.buttons.splice(existingIndex, 1); + } + } + /** * Add a button to display to the user * @param {ButtonOptions} options Options for rendering the button * @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), @@ -280,20 +313,26 @@ export class CodeRunner extends Renderable { }); } + private renderButtons(): void { + getElement(CODE_BUTTONS_WRAPPER_ID).innerHTML = + this.buttons.map(b => b.buttonHTML).join("\n"); + // Buttons are freshly added to the DOM, so attach listeners now + this.buttons.forEach(b => addListener(b.id, b.onClick, "click")); + // Ensure buttons are shown properly + this.setState(this.state); + } + protected override _render(options: CodeRunnerRenderOptions): HTMLElement { const rendered = renderWithOptions(options.statusPanelOptions, `
-
- ${this.buttons.map(b => b.buttonHTML).join("\n")} +
${renderSpinningCircle(STATE_SPINNER_ID, "_tw-border-gray-200 _tw-border-b-red-500")}
`); - // Buttons are freshly added to the DOM, so attach listeners now - this.buttons.forEach(b => addListener(b.id, b.onClick, "click")); - this.setState(this.state); + this.renderButtons(); this.inputManager.render(options.inputOptions); this.outputManager.render(options.outputOptions); this.editor.render(options.codeEditorOptions); @@ -305,9 +344,10 @@ export class CodeRunner extends Renderable { /** * @param {string} code The code to run + * @param {string} mode The mode to run with * @return {Promise} Promise of running the code */ - public async runCode(code: string): Promise { + public async runCode(code: string, mode?: string): Promise { // Setup pre-run this.setState(RunState.Running); BackendManager.publish({ @@ -321,7 +361,7 @@ export class CodeRunner extends Renderable { const backend = await this.backend; try { await backend.call( - backend.workerProxy.runCode, code + backend.workerProxy.runCode, code, mode ); } catch (error: any) { if (error.type === "InterruptError") { diff --git a/src/Constants.ts b/src/Constants.ts index 8dfd6179..a3cfdc31 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -20,6 +20,7 @@ export const EDITOR_WRAPPER_ID = addPapyrosPrefix("code-area"); 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 STOP_BTN_ID = addPapyrosPrefix("stop-btn"); export const SEND_INPUT_BTN_ID = addPapyrosPrefix("send-input-btn"); @@ -32,3 +33,4 @@ export const DARK_MODE_TOGGLE_ID = addPapyrosPrefix("toggle-dark-mode"); export const DEFAULT_PROGRAMMING_LANGUAGE = ProgrammingLanguage.Python; export const DEFAULT_LOCALE = "nl"; export const DEFAULT_SERVICE_WORKER = "InputServiceWorker.js"; +export const DEFAULT_EDITOR_DELAY = 750; // milliseconds diff --git a/src/InputManager.ts b/src/InputManager.ts index acc13ecc..7668bb25 100644 --- a/src/InputManager.ts +++ b/src/InputManager.ts @@ -12,6 +12,7 @@ import { UserInputHandler } from "./input/UserInputHandler"; import { BatchInputHandler } from "./input/BatchInputHandler"; import { BackendManager } from "./BackendManager"; import { Renderable, RenderOptions, renderWithOptions } from "./util/Rendering"; +import { EditorStyling } from "./editor/CodeMirrorEditor"; export enum InputMode { Interactive = "interactive", @@ -20,7 +21,14 @@ export enum InputMode { export const INPUT_MODES = [InputMode.Batch, InputMode.Interactive]; -export class InputManager extends Renderable { +export interface InputManagerRenderOptions extends RenderOptions { + /** + * Option to allow styling the editor area of the input handler + */ + inputStyling?: Partial; +} + +export class InputManager extends Renderable { private inputMode: InputMode; private inputHandlers: Map; private waiting: boolean; @@ -28,10 +36,10 @@ export class InputManager extends Renderable { private sendInput: (input: string) => void; - constructor(sendInput: (input: string) => void) { + constructor(sendInput: (input: string) => void, inputMode: InputMode) { super(); this.inputHandlers = this.buildInputHandlerMap(); - this.inputMode = InputMode.Interactive; + this.inputMode = inputMode; this.sendInput = sendInput; this.waiting = false; this.prompt = ""; @@ -62,7 +70,7 @@ export class InputManager extends Renderable { this.inputHandler.toggle(true); } - private get inputHandler(): UserInputHandler { + public get inputHandler(): UserInputHandler { return this.inputHandlers.get(this.inputMode)!; } @@ -70,7 +78,7 @@ export class InputManager extends Renderable { return this.waiting; } - protected override _render(options: RenderOptions): void { + protected override _render(options: InputManagerRenderOptions): void { let switchMode = ""; const otherMode = this.inputMode === InputMode.Interactive ? InputMode.Batch : InputMode.Interactive; @@ -88,7 +96,8 @@ ${switchMode}`); this.inputHandler.render({ parentElementId: USER_INPUT_WRAPPER_ID, - darkMode: options.darkMode + darkMode: options.darkMode, + inputStyling: options.inputStyling }); this.inputHandler.waitWithPrompt(this.waiting, this.prompt); } @@ -99,7 +108,7 @@ ${switchMode}`); this.inputHandler.waitWithPrompt(this.waiting, this.prompt); } - private async onUserInput(): Promise { + private onUserInput(): void { if (this.inputHandler.hasNext()) { const line = this.inputHandler.next(); this.sendInput(line); @@ -112,11 +121,10 @@ ${switchMode}`); /** * Asynchronously handle an input request by prompting the user for input * @param {BackendEvent} e Event containing the input data - * @return {Promise} Promise of handling the request */ - private async onInputRequest(e: BackendEvent): Promise { + private onInputRequest(e: BackendEvent): void { this.prompt = e.data; - return await this.onUserInput(); + this.onUserInput(); } private onRunStart(): void { diff --git a/src/Library.ts b/src/Library.ts index 8e4714bc..ec03b3ee 100644 --- a/src/Library.ts +++ b/src/Library.ts @@ -1,5 +1,5 @@ import { BackendEvent } from "./BackendEvent"; -import { CodeEditor } from "./CodeEditor"; +import { CodeEditor } from "./editor/CodeEditor"; import { InputManager, InputMode } from "./InputManager"; import { FriendlyError, OutputManager } from "./OutputManager"; import { Papyros, PapyrosConfig, PapyrosRenderOptions } from "./Papyros"; diff --git a/src/Papyros.css b/src/Papyros.css index c89d0453..e9f4f873 100644 --- a/src/Papyros.css +++ b/src/Papyros.css @@ -11,10 +11,14 @@ .tailwind *, .tailwind ::before, .tailwind ::after { - box-sizing: border-box; /* 1 */ - border-width: 0; /* 2 */ - border-style: solid; /* 2 */ - border-color: theme('borderColor.DEFAULT', currentColor); /* 2 */ + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: theme('borderColor.DEFAULT', currentColor); + /* 2 */ } .tailwind ::before, @@ -30,11 +34,16 @@ */ .tailwind-html { - line-height: 1.5; /* 1 */ - -webkit-text-size-adjust: 100%; /* 2 */ - -moz-tab-size: 4; /* 3 */ - tab-size: 4; /* 3 */ - font-family: theme('fontFamily.sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); /* 4 */ + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + tab-size: 4; + /* 3 */ + font-family: theme('fontFamily.sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + /* 4 */ } /* @@ -43,8 +52,10 @@ */ .tailwind-body { - margin: 0; /* 1 */ - line-height: inherit; /* 2 */ + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ } @@ -58,12 +69,18 @@ .tailwind input, .tailwind select, .tailwind textarea { - font-family: inherit; /* 1 */ - font-size: 100%; /* 1 */ - line-height: inherit; /* 1 */ - color: inherit; /* 1 */ - margin: 0; /* 2 */ - padding: 0; /* 3 */ + font-family: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + line-height: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ } /* @@ -88,8 +105,10 @@ Removes the default spacing and border for appropriate elements. .tailwind input::placeholder, .tailwind textarea::placeholder { - opacity: 1; /* 1 */ - color: theme('colors.gray.400', #9ca3af); /* 2 */ + opacity: 1; + /* 1 */ + color: theme('colors.gray.400', #9ca3af); + /* 2 */ } /* @@ -100,20 +119,17 @@ Ensure the default browser behavior of the `hidden` attribute. display: none; } - -/* Styling elements generated by CodeMirror */ - -.cm-scroller, .cm-gutters { /* Min height of the editor via both gutter and scroller */ - min-height: 20vh !important; -} -.cm-content, .papyros-font-family { +.cm-content, +.papyros-font-family { font-family: Monaco, Menlo, "Ubuntu Mono", Consolas, source-code-pro, monospace; } + /* Override top border generated by @codemirror/one-dark-theme */ .ͼo .cm-panels.cm-panels-bottom { /* dark-mode-content in tailwind.config.js*/ border-color: #37474F; } + /* Change styling of tooltips generated by CodeMirror */ .cm-completionInfo { white-space: pre; @@ -139,3 +155,12 @@ Ensure the default browser behavior of the `hidden` attribute. /* same value as placeholder-grey in tailwind.config.js */ color: #888; } + +/* 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; +} +/* Round the corners of textfields created by CodeMirror */ +.cm-textfield { + @apply _tw-rounded-lg !important; +} diff --git a/src/Papyros.ts b/src/Papyros.ts index 805a68a0..cb99a169 100644 --- a/src/Papyros.ts +++ b/src/Papyros.ts @@ -7,7 +7,7 @@ import { PANEL_WRAPPER_ID, DARK_MODE_TOGGLE_ID, MAIN_APP_ID, OUTPUT_AREA_WRAPPER_ID } from "./Constants"; -import { InputMode } from "./InputManager"; +import { InputManagerRenderOptions, InputMode } from "./InputManager"; import { ProgrammingLanguage } from "./ProgrammingLanguage"; import { t, loadTranslations, getLocales, @@ -80,7 +80,7 @@ export interface PapyrosRenderOptions { /** * RenderOptions for the input field */ - inputOptions?: RenderOptions; + inputOptions?: InputManagerRenderOptions; /** * RenderOptions for the output field */ @@ -114,7 +114,7 @@ export class Papyros extends Renderable { // Load translations as other components depend on them loadTranslations(); I18n.locale = config.locale; - this.codeRunner = new CodeRunner(config.programmingLanguage); + this.codeRunner = new CodeRunner(config.programmingLanguage, config.inputMode); } /** @@ -177,14 +177,14 @@ export class Papyros extends Renderable { * @param {string} code The code to use in the editor */ public setCode(code: string): void { - this.codeRunner.editor.setCode(code); + this.codeRunner.editor.setText(code); } /** * @return {string} The currently written code */ public getCode(): string { - return this.codeRunner.editor.getCode(); + return this.codeRunner.editor.getText(); } /** diff --git a/src/Translations.d.ts b/src/Translations.d.ts index fc0c0bea..3d7f0bc1 100644 --- a/src/Translations.d.ts +++ b/src/Translations.d.ts @@ -2,10 +2,23 @@ * I18n translations object: * */ -interface Translations { +interface Translation { + /** + * Phrase for given translation key + */ + [key: string]: string; +} +interface NestedTranslations { /** * Each key yields a translated string or a nested object */ - [key: string]: string | Translations; + [key: string]: Translation | NestedTranslations; +} +interface CodeMirrorTranslations { + /** + * CodeMirror expects a flat object per language + */ + [key: string]: Translation } -export declare const TRANSLATIONS: Translations; +export declare const TRANSLATIONS: NestedTranslations; +export declare const CODE_MIRROR_TRANSLATIONS: CodeMirrorTranslations; diff --git a/src/Translations.js b/src/Translations.js index 4ceec69e..6ff56d09 100644 --- a/src/Translations.js +++ b/src/Translations.js @@ -47,7 +47,12 @@ const ENGLISH_TRANSLATION = { "no_output": "The code did not produce any output.", "service_worker_error": "The service worker failed to load.", "launch_error": "Papyros failed to load. Do you want to reload?", - "loading": "Loading %{packages}." + "loading": "Loading %{packages}.", + "run_modes": { + "doctest": "Doctests" + }, + "used_input": "This line was used as input by your code.", + "used_input_with_prompt": "This line was used as input for the following prompt: %{prompt}" }; const DUTCH_TRANSLATION = { @@ -95,12 +100,53 @@ const DUTCH_TRANSLATION = { "no_output": "De code produceerde geen uitvoer.", "service_worker_error": "Er liep iets fout bij het laden van de service worker.", "launch_error": "Er liep iets fout bij het laden van Papyros. Wil je herladen?", - "loading": "Bezig met het installeren van %{packages}." + "loading": "Bezig met het installeren van %{packages}.", + "run_modes": { + "doctest": "Doctests" + }, + "used_input": "Deze regel werd gebruikt als invoer.", + "used_input_with_prompt": "Deze regel werd gebruikt als invoer voor de volgende vraag: %{prompt}" }; +const DUTCH_PHRASES = { + // @codemirror/view + "Control character": "Controlekarakter", + // @codemirror/fold + "Folded lines": "Ingeklapte regels", + "Unfolded lines": "Uitgeklapte regels", + "to": "tot", + "folded code": "ingeklapte code", + "unfold": "uitklappen", + "Fold line": "Regel inklappen", + "Unfold line": "Regel uitklappen", + // @codemirror/search + "Go to line": "Spring naar regel", + "go": "OK", + "Find": "Zoeken", + "Replace": "Vervangen", + "next": "volgende", + "previous": "vorige", + "all": "alle", + "match case": "Hoofdlettergevoelig", + "replace": "vervangen", + "replace all": "alles vervangen", + "close": "sluiten", + "current match": "huidige overeenkomst", + "on line": "op regel", + // @codemirror/lint + "Diagnostics": "Problemen", + "No diagnostics": "Geen problemen", + } + const TRANSLATIONS = { en: { "Papyros": ENGLISH_TRANSLATION }, nl: { "Papyros": DUTCH_TRANSLATION } }; + +const CODE_MIRROR_TRANSLATIONS = { + en: {}, + nl: DUTCH_PHRASES +}; // JS exports to allow use in TS and JS files module.exports.TRANSLATIONS = TRANSLATIONS; +module.exports.CODE_MIRROR_TRANSLATIONS = CODE_MIRROR_TRANSLATIONS; diff --git a/src/editor/BatchInputEditor.ts b/src/editor/BatchInputEditor.ts new file mode 100644 index 00000000..0dd21313 --- /dev/null +++ b/src/editor/BatchInputEditor.ts @@ -0,0 +1,73 @@ +import { CodeMirrorEditor } from "./CodeMirrorEditor"; +import { UsedInputGutters, UsedInputGutterInfo } from "./Gutters"; + +/** + * Editor to handle and highlight user input + */ +export class BatchInputEditor extends CodeMirrorEditor { + /** + * Style classes used to highlight lines + */ + private static HIGHLIGHT_CLASSES = ["cm-activeLine"]; + /** + * Gutters to show which lines were used + */ + private usedInputGutters: UsedInputGutters; + + constructor() { + super(new Set([CodeMirrorEditor.PLACEHOLDER, CodeMirrorEditor.STYLE]), { + classes: ["papyros-input-editor", "_tw-overflow-auto", + "_tw-border-solid", "_tw-border-gray-200", "_tw-border-2", "_tw-rounded-lg", + "dark:_tw-bg-dark-mode-bg", "dark:_tw-border-dark-mode-content", + "focus:_tw-outline-none", "focus:_tw-ring-1", "focus:_tw-ring-blue-500"], + minHeight: "10vh", + maxHeight: "20vh" + } + ); + this.usedInputGutters = new UsedInputGutters(); + this.addExtension(this.usedInputGutters.toExtension()); + } + + /** + * Apply highlighting to the lines in the Editor + * @param {boolean} disable Whether to disable editing the lines if marked + * @param {function(number): UsedInputGutterInfo} getInfo Function to obtain gutter + * info per line (1-based indexing) + */ + public highlight(disable: boolean, getInfo: (lineNr: number) => UsedInputGutterInfo): void { + this.editorView.dom.querySelectorAll(".cm-line").forEach((line, i) => { + const info = getInfo(i + 1); + BatchInputEditor.HIGHLIGHT_CLASSES.forEach(c => { + line.classList.toggle(c, info.on); + }); + line.setAttribute("contenteditable", "" + (!disable || !info.on)); + this.usedInputGutters.setMarker(this.editorView, info); + }); + } + + /** + * @return {Array} Array of valid user input + * Data in the last line that is not terminated by a newline is omitted + */ + public getLines(): Array { + const lines = []; + // Always need to call next atleast once + // Use iter to have line-separating information + let lineIterator = this.editorView.state.doc.iter().next(); + while (!lineIterator.done) { + lines.push(lineIterator.value); + lineIterator = lineIterator.next(); + } + // Filter lines based on presence of line separators + let last = lines.length - 1; + while (last >= 0) { + const removed = lines.splice(last, 1)[0]; + if (removed === "\n") { // Line followed by separator + last -= 2; + } else { // Last line without separator, omit it + last -= 1; + } + } + return lines; + } +} diff --git a/src/CodeEditor.ts b/src/editor/CodeEditor.ts similarity index 51% rename from src/CodeEditor.ts rename to src/editor/CodeEditor.ts index 8e8c6902..8fe18971 100644 --- a/src/CodeEditor.ts +++ b/src/editor/CodeEditor.ts @@ -1,7 +1,6 @@ /* eslint-disable valid-jsdoc */ -import { ProgrammingLanguage } from "./ProgrammingLanguage"; -import { t } from "./util/Util"; -import { Renderable, RenderOptions, renderWithOptions } from "./util/Rendering"; +import { ProgrammingLanguage } from "../ProgrammingLanguage"; +import { t } from "../util/Util"; import { CompletionSource, autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap @@ -17,44 +16,25 @@ import { foldGutter, indentOnInput, bracketMatching, foldKeymap, syntaxHighlighting } from "@codemirror/language"; import { highlightSelectionMatches, searchKeymap } from "@codemirror/search"; -import { Compartment, EditorState, Extension } from "@codemirror/state"; +import { EditorState, Extension } from "@codemirror/state"; import { oneDark } from "@codemirror/theme-one-dark"; import { EditorView, showPanel, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, - rectangularSelection, highlightActiveLine, keymap, placeholder, ViewUpdate + rectangularSelection, highlightActiveLine, keymap } from "@codemirror/view"; import { Diagnostic, linter, lintGutter, lintKeymap } from "@codemirror/lint"; - -enum Option { - ProgrammingLanguage = "programming_language", - Placeholder = "placeholder", - Indentation = "indentation", - Panel = "panel", - Autocompletion = "autocompletion", - Linting = "linting", - Style = "style", - OnChange = "change" -} -const OPTIONS = [ - Option.ProgrammingLanguage, Option.Placeholder, - Option.Indentation, Option.Panel, - Option.Autocompletion, Option.Linting, - Option.Style, Option.OnChange -]; +import { CodeMirrorEditor } from "./CodeMirrorEditor"; /** * Component that provides useful features to users writing code */ -export class CodeEditor extends Renderable { - /** - * Reference to the user interface of the editor - */ - public readonly editorView: EditorView; - /** - * Mapping from CodeEditorOptions to a configurable compartment - */ - private compartments: Map; +export class CodeEditor extends CodeMirrorEditor { + public static PROGRAMMING_LANGUAGE = "programming_language"; + public static INDENTATION = "indentation"; + public static PANEL = "panel"; + public static AUTOCOMPLETION = "autocompletion"; + public static LINTING = "linting"; /** * Construct a new CodeEditor @@ -63,72 +43,44 @@ export class CodeEditor extends Renderable { * @param {number} indentLength The length in spaces for the indent unit */ constructor(onRunRequest: () => void, initialCode = "", indentLength = 4) { - super(); - this.compartments = new Map(OPTIONS.map(opt => [opt, new Compartment()])); - const configurableExtensions = [...this.compartments.values()] - .map(compartment => compartment.of([])); - this.editorView = new EditorView( - { - state: EditorState.create({ - doc: initialCode, - extensions: - [ - ...configurableExtensions, - keymap.of([ - { - key: "Mod-Enter", run: () => { - onRunRequest(); - return true; - } - }, - // The original Ctrl-Enter keybind gets assigned to Shift-Enter - { - key: "Shift-Enter", run: insertBlankLine - } - ]), - ...CodeEditor.getExtensions() - ] - }) - }); - this.setIndentLength(indentLength); - } - - /** - * Helper method to dispatch configuration changes at runtime - * @param {Array<[Option, Extension]>} items Array of items to reconfigure - * The option indicates the relevant compartment - * The extension indicates the new configuration - */ - private reconfigure(...items: Array<[Option, Extension]>): void { - this.editorView.dispatch({ - effects: items.map(([opt, ext]) => this.compartments.get(opt)!.reconfigure(ext)) + super(new Set([ + CodeEditor.PROGRAMMING_LANGUAGE, CodeEditor.INDENTATION, + CodeEditor.PANEL, CodeEditor.AUTOCOMPLETION, CodeEditor.LINTING + ]), { + classes: ["papyros-code-editor", "_tw-overflow-auto", + "_tw-border-solid", "_tw-border-gray-200", "_tw-border-2", + "_tw-rounded-lg", "dark:_tw-border-dark-mode-content"], + minHeight: "20vh", + maxHeight: "72vh", + theme: {} }); + this.addExtension([ + keymap.of([ + { + key: "Mod-Enter", run: () => { + onRunRequest(); + return true; + } + }, + // The original Ctrl-Enter keybind gets assigned to Shift-Enter + { + key: "Shift-Enter", run: insertBlankLine + } + ]), + ...CodeEditor.getExtensions() + ]); + this.setText(initialCode); + this.setIndentLength(indentLength); } - /** - * Render the editor with the given options and panel - * @param {RenderOptions} options Options for rendering - * @param {HTMLElement} panel The panel to display at the bottom - * @return {HTMLElement} The rendered element - */ - protected override _render(options: RenderOptions): void { + public override setDarkMode(darkMode: boolean): void { let styleExtensions: Extension = []; - if (options.darkMode) { + if (darkMode) { styleExtensions = oneDark; } else { styleExtensions = syntaxHighlighting(defaultHighlightStyle, { fallback: true }); } - this.reconfigure([Option.Style, styleExtensions]); - // Ensure that the classes are added to a child of the parent so that - // dark mode classes are properly activated - // CodeMirror dom resets its classList, so that is not an option - const wrappingDiv = document.createElement("div"); - wrappingDiv.classList - .add("_tw-overflow-auto", "_tw-max-h-9/10", "_tw-min-h-1/4", - "_tw-border-solid", "_tw-border-gray-200", "_tw-border-2", - "_tw-rounded-lg", "dark:_tw-border-dark-mode-content"); - wrappingDiv.replaceChildren(this.editorView.dom); - renderWithOptions(options, wrappingDiv); + this.reconfigure([CodeMirrorEditor.STYLE, styleExtensions]); } /** @@ -137,10 +89,10 @@ export class CodeEditor extends Renderable { public setProgrammingLanguage(language: ProgrammingLanguage) : void { this.reconfigure( - [Option.ProgrammingLanguage, CodeEditor.getLanguageSupport(language)], - [Option.Placeholder, placeholder(t("Papyros.code_placeholder", - { programmingLanguage: language }))] + [CodeEditor.PROGRAMMING_LANGUAGE, CodeEditor.getLanguageSupport(language)] ); + this.setPlaceholder(t("Papyros.code_placeholder", + { programmingLanguage: language })); } /** @@ -148,7 +100,7 @@ export class CodeEditor extends Renderable { */ public setCompletionSource(completionSource: CompletionSource): void { this.reconfigure( - [Option.Autocompletion, autocompletion({ override: [completionSource] })] + [CodeEditor.AUTOCOMPLETION, autocompletion({ override: [completionSource] })] ); } @@ -160,11 +112,8 @@ export class CodeEditor extends Renderable { : void { this.reconfigure( [ - Option.Linting, - [ - linter(lintSource), - lintGutter() - ] + CodeEditor.LINTING, + linter(lintSource) ] ); } @@ -174,20 +123,7 @@ export class CodeEditor extends Renderable { */ public setIndentLength(indentLength: number): void { this.reconfigure( - [Option.Indentation, indentUnit.of(CodeEditor.getIndentUnit(indentLength))] - ); - } - - /** - * @param {Function} onChange Listener that performs actions on the new contents - */ - public onChange(onChange: ((newContent: string) => void)): void { - this.reconfigure( - [Option.OnChange, EditorView.updateListener.of((v: ViewUpdate) => { - if (v.docChanged) { - onChange(v.state.doc.toString()); - } - })] + [CodeEditor.INDENTATION, indentUnit.of(CodeEditor.getIndentUnit(indentLength))] ); } @@ -196,35 +132,12 @@ export class CodeEditor extends Renderable { */ public setPanel(panel: HTMLElement): void { this.reconfigure( - [Option.Panel, showPanel.of(() => { + [CodeEditor.PANEL, showPanel.of(() => { return { dom: panel }; })] ); } - /** - * @return {string} The code within the editor - */ - public getCode(): string { - return this.editorView.state.doc.toString(); - } - - /** - * @param {string} code The new code to be shown in the editor - */ - public setCode(code: string): void { - this.editorView.dispatch( - { changes: { from: 0, to: this.getCode().length, insert: code } } - ); - } - - /** - * Put focus on the CodeEditor - */ - public focus(): void { - this.editorView.focus(); - } - /** * @param {number} indentLength The amount of spaces to use * @return {string} The indentation unit to be used by CodeMirror @@ -266,6 +179,7 @@ export class CodeEditor extends Renderable { * - active line highlighting * - active line gutter highlighting * - selection match highlighting + * - gutter for linting * Keymaps: * - the default command bindings * - bracket closing @@ -277,6 +191,7 @@ export class CodeEditor extends Renderable { */ private static getExtensions(): Array { return [ + lintGutter(), lineNumbers(), highlightSpecialChars(), history(), diff --git a/src/editor/CodeMirrorEditor.ts b/src/editor/CodeMirrorEditor.ts new file mode 100644 index 00000000..c1dc77d1 --- /dev/null +++ b/src/editor/CodeMirrorEditor.ts @@ -0,0 +1,251 @@ +import { Compartment, EditorState, Extension, StateEffect } from "@codemirror/state"; +import { EditorView, placeholder, ViewUpdate } from "@codemirror/view"; +import { Renderable, RenderOptions, renderWithOptions } from "../util/Rendering"; +import { StyleSpec } from "style-mod"; +import { oneDark } from "@codemirror/theme-one-dark"; +import { CODE_MIRROR_TRANSLATIONS } from "../Translations"; +import I18n from "i18n-js"; + +/** + * Data structure containing common elements for styling + */ +export interface EditorStyling { + /** + * Array of HTML classes to apply to this editor + */ + classes: Array; + /** + * The maximum height of the editor + */ + maxHeight: string; + /** + * The minimum height of the editor + */ + minHeight: string; + /** + * Extra theme options to be passed to EditorView.theme + */ + theme?: { + [selectorSpec: string]: StyleSpec + } +} + +/** + * Interface for listeners to textual changes in the editor + */ +export interface DocChangeListener { + /** + * Method to call with the new document value + */ + onChange: (code: string) => void; + /** + * How many milliseconds should pass since the last change + * before notifying (in case computations are expensive) + */ + delay?: number; +} + +/** + * Interface for storing data related to delayed function calls + */ +interface TimeoutData { + /** + * The time in ms at which the last call occurred + */ + lastCalled: number; + /** + * The timeout identifier associated with the delayed call + * null if not currently scheduled + */ + timeout: NodeJS.Timeout | null; +} + + +/** + * Base class for Editors implemented using CodeMirror 6 + * https://codemirror.net/6/ + */ +export abstract class CodeMirrorEditor extends Renderable { + public static STYLE = "style"; + public static PLACEHOLDER = "placeholder"; + public static THEME = "theme"; + public static LANGUAGE = "language"; + /** + * CodeMirror EditorView representing the internal editor + */ + public readonly editorView: EditorView; + /** + * Mapping of strings to Compartments associated with that property + */ + protected compartments: Map; + /** + * Data to style this Editor + */ + protected styling: EditorStyling; + /** + * Mapping for each change listener to its timeout identifier and last call time + */ + protected listenerTimeouts: Map; + + /** + * @param {Set} compartments Identifiers for configurable extensions + * @param {EditorStyling} styling Data to style this editor + */ + constructor(compartments: Set, styling: EditorStyling) { + super(); + this.styling = styling; + this.listenerTimeouts = new Map(); + // Ensure default compartments are present + compartments.add(CodeMirrorEditor.STYLE); + compartments.add(CodeMirrorEditor.PLACEHOLDER); + compartments.add(CodeMirrorEditor.THEME); + compartments.add(CodeMirrorEditor.LANGUAGE); + this.compartments = new Map(); + const configurableExtensions: Array = []; + compartments.forEach(opt => { + const compartment = new Compartment(); + this.compartments.set(opt, compartment); + configurableExtensions.push(compartment.of([])); + }); + this.editorView = new EditorView({ + state: EditorState.create({ + extensions: [ + configurableExtensions, + EditorView.updateListener.of((v: ViewUpdate) => { + if (v.docChanged) { + this.handleChange(); + } + }) + ] + }) + }); + this.setStyling(styling); + } + + /** + * @param {Extension} extension The extension to add to the Editor + */ + protected addExtension(extension: Extension): void { + this.editorView.dispatch({ + effects: StateEffect.appendConfig.of(extension) + }); + } + + /** + * @return {string} The text within the editor + */ + public getText(): string { + return this.editorView.state.doc.toString(); + } + + /** + * @param {string} text The new value to be shown in the editor + */ + public setText(text: string): void { + this.editorView.dispatch( + { changes: { from: 0, to: this.getText().length, insert: text } } + ); + } + + /** + * Helper method to dispatch configuration changes at runtime + * @param {Array<[Option, Extension]>} items Array of items to reconfigure + * The option indicates the relevant compartment + * The extension indicates the new configuration + */ + public reconfigure(...items: Array<[string, Extension]>): void { + this.editorView.dispatch({ + effects: items.map(([opt, ext]) => this.compartments.get(opt)!.reconfigure(ext)) + }); + } + + /** + * Apply focus to the Editor + */ + public focus(): void { + this.editorView.focus(); + } + + /** + * @param {string} placeholderValue The contents of the placeholder + */ + public setPlaceholder(placeholderValue: string): void { + this.reconfigure([ + CodeMirrorEditor.PLACEHOLDER, + placeholder(placeholderValue) + ]); + } + + /** + * @param {boolean} darkMode Whether to use dark mode + */ + public setDarkMode(darkMode: boolean): void { + let styleExtensions: Extension = []; + if (darkMode) { + styleExtensions = oneDark; + } else { + styleExtensions = []; + } + this.reconfigure([CodeMirrorEditor.STYLE, styleExtensions]); + } + + /** + * Override the style used by this Editor + * @param {Partial} styling Object with keys of EditorStyling to override styles + */ + public setStyling(styling: Partial): void { + Object.assign(this.styling, styling); + this.reconfigure([ + CodeMirrorEditor.THEME, + EditorView.theme({ + ".cm-scroller": { overflow: "auto" }, + "&": { maxHeight: this.styling.maxHeight, height: "100%" }, + ".cm-gutter,.cm-content": { minHeight: this.styling.minHeight }, + ...(this.styling.theme || {}) + }) + ]); + } + + protected override _render(options: RenderOptions): void { + this.setDarkMode(options.darkMode || false); + this.reconfigure([ + CodeMirrorEditor.LANGUAGE, + EditorState.phrases.of(CODE_MIRROR_TRANSLATIONS[I18n.locale]) + ]); + const wrappingDiv = document.createElement("div"); + wrappingDiv.classList.add(...this.styling.classes); + wrappingDiv.replaceChildren(this.editorView.dom); + renderWithOptions(options, wrappingDiv); + } + + /** + * Process the changes by informing the listeners of the new contents + */ + private handleChange(): void { + const currentDoc = this.getText(); + const now = Date.now(); + this.listenerTimeouts.forEach((timeoutData, listener) => { + // Clear existing scheduled calls + if (timeoutData.timeout !== null) { + clearTimeout(timeoutData.timeout); + } + timeoutData.lastCalled = now; + if (listener.delay && listener.delay > 0) { + timeoutData.timeout = setTimeout(() => { + timeoutData.timeout = null; + listener.onChange(currentDoc); + }, listener.delay); + } else { + listener.onChange(currentDoc); + } + timeoutData.lastCalled = now; + }); + } + + /** + * @param {DocChangeListener} changeListener Listener that performs actions on the new contents + */ + public onChange(changeListener: DocChangeListener): void { + this.listenerTimeouts.set(changeListener, { timeout: null, lastCalled: 0 }); + } +} diff --git a/src/editor/Gutters.ts b/src/editor/Gutters.ts new file mode 100644 index 00000000..a8c5b70b --- /dev/null +++ b/src/editor/Gutters.ts @@ -0,0 +1,219 @@ +import { StateEffectType, StateField } from "@codemirror/state"; +import { Extension, StateEffect } from "@codemirror/state"; +import { BlockInfo, gutter, GutterMarker } from "@codemirror/view"; +import { EditorView } from "@codemirror/view"; + +/** + * Helper class to create markers in the gutter + */ +class SimpleMarker extends GutterMarker { + constructor( + // Function to create the DOM element + private createMarker: () => Text + ) { + super(); + } + public override toDOM(): Text { + return this.createMarker(); + } +} +/** + * Data used in Gutter elements + */ +export interface GutterInfo { + /** + * The number of the line (1-based) + */ + lineNr: number; + /** + * Whether the Gutter element should be shown + */ + on: boolean; +} +/** + * Configuration for Gutters + */ +export interface IGutterConfig { + /** + * Name of this Gutter + */ + name: string; + /** + * Handler for when a Gutter element is clicked + */ + onClick?: (view: EditorView, info: Info) => void, + /** + * Extra extensions to use for the Gutters + */ + extraExtensions?: Extension; +} +export abstract class Gutters< + Info extends GutterInfo = GutterInfo, + Config extends IGutterConfig = IGutterConfig, + > { + /** + * Config used to initialize the Gutters + */ + protected config: Config; + /** + * Effect to signal changes in the Gutters + */ + protected effect: StateEffectType; + /** + * Current state of the Gutters + * Consists of a mapping for line numbers to Info objects + */ + protected state: StateField>; + + constructor(config: Config) { + this.config = config; + this.effect = StateEffect.define(); + this.state = StateField.define>({ + create: () => { + return new Map(); + }, + update: (current, transaction) => { + const updatedMap: Map = new Map(current); + for (const e of transaction.effects) { + if (e.is(this.effect)) { + updatedMap.set(e.value.lineNr, e.value); + } + } + return updatedMap; + } + }); + } + + /** + * Render a marker with the given info + * @param {Info} info Info used to render the marker + * Will only be called when info.on is True + */ + protected abstract marker(info: Info): GutterMarker; + + /** + * Set a marker with the given info + * @param {EditorView} view View in which the Gutters live + * @param {Info} info Info used to render the marker + */ + public setMarker(view: EditorView, info: Info): void { + view.dispatch({ + effects: this.effect.of(info) + }); + } + + /** + * @param {EditorView} view The view in which the Gutters live + * @return {Set} The 1-based line numbers with a breakpoint + */ + public getMarkedLines(view: EditorView): Set { + const markedLines: Set = new Set(); + const guttersInfo: Map = view.state.field(this.state); + guttersInfo.forEach((info: GutterInfo, lineNr: number) => { + if (info.on) { + markedLines.add(lineNr); + } + }); + return markedLines; + } + + /** + * @return {Extension} The Gutters as a CodeMirror Extension + */ + toExtension(): Extension { + // TODO correct type: https://github.com/codemirror/codemirror.next/issues/839 + const handlers: any = {}; + if (this.config.onClick) { + handlers["mousedown"] = (view: EditorView, line: BlockInfo) => { + const markings = view.state.field(this.state); + const lineNr = view.state.doc.lineAt(line.from).number; + const markerInfo = markings.get(lineNr)!; + // Line numbers start at 1 + this.config.onClick!(view, markerInfo); + }; + } + return [ + this.state, + gutter({ + class: `cm-${this.config.name}-gutter`, + lineMarker: (view, line) => { + // Lookup whether the element should be drawn + const guttersInfo: Map = view.state.field(this.state); + const lineNr = view.state.doc.lineAt(line.from).number; + if (guttersInfo.has(lineNr) && guttersInfo.get(lineNr)!.on) { + return this.marker(guttersInfo.get(lineNr)!); + } else { + return null; + } + }, + lineMarkerChange: update => { + return update.startState.field(this.state) !== update.state.field(this.state); + }, + initialSpacer: () => { + return this.marker({ lineNr: -1, on: true } as Info)!; + }, + domEventHandlers: handlers + }), + this.config.extraExtensions || [] + ]; + } +} + +/** + * Gutters to show and allow toggling of breakpoints + */ +export class BreakpointsGutter extends Gutters { + constructor() { + super({ + name: "breakpoint", + onClick: (view: EditorView, info: GutterInfo) => { + info.on = !info.on; + this.setMarker(view, info); + }, + extraExtensions: [ + EditorView.baseTheme({ + ".cm-breakpoint-gutter .cm-gutterElement": { + color: "red", + paddingLeft: "5px", + cursor: "default" + } + }) + ] + }); + } + + protected override marker(): GutterMarker { + return new SimpleMarker(() => document.createTextNode("🔴")); + } +} + +/** + * Extra data used to represent input gutters + */ +export interface UsedInputGutterInfo extends GutterInfo { + /** + * Text value to display when hovering over the Gutter element + */ + title: string; +} +/** + * Gutters to show a checkmark for used input + */ +export class UsedInputGutters extends Gutters { + constructor() { + super({ + name: "input" + }); + } + + protected override marker(info: UsedInputGutterInfo): GutterMarker { + return new SimpleMarker(() => { + const node = document.createElement("div"); + node.classList.add("_tw-text-lime-400"); + node.replaceChildren(document.createTextNode("✔")); + node.setAttribute("title", info.title); + // Text interface tells us that more complex node will be processed into Text nodes + return node as any as Text; + }); + } +} diff --git a/src/examples/PythonExamples.ts b/src/examples/PythonExamples.ts index 9248cf64..a0aecf2d 100644 --- a/src/examples/PythonExamples.ts +++ b/src/examples/PythonExamples.ts @@ -58,10 +58,6 @@ def wrong_factorial(n): 265252859812191058636308480000000 """ return 0 - -if __name__ == "__main__": - import doctest - doctest.testmod() `, "Async": `import asyncio diff --git a/src/input/BatchInputHandler.ts b/src/input/BatchInputHandler.ts index faf42300..ed9af051 100644 --- a/src/input/BatchInputHandler.ts +++ b/src/input/BatchInputHandler.ts @@ -1,15 +1,25 @@ -import { INPUT_TA_ID } from "../Constants"; -import { InputMode } from "../InputManager"; +import { InputManagerRenderOptions, InputMode } from "../InputManager"; import { UserInputHandler } from "./UserInputHandler"; -import { - RenderOptions, renderWithOptions -} from "../util/Rendering"; +import { t } from "../util/Util"; +import { BatchInputEditor } from "../editor/BatchInputEditor"; export class BatchInputHandler extends UserInputHandler { /** * The index of the next line in lines to send */ private lineNr: number; + /** + * Messages used when asking for user input + */ + private prompts: Array; + /** + * Whether a run is occurring + */ + private running: boolean; + /** + * Editor containing the input of the user + */ + public readonly batchEditor: BatchInputEditor; /** * The previous input of the user * Is restored upon switching back to InputMode.Batch @@ -24,13 +34,37 @@ export class BatchInputHandler extends UserInputHandler { super(inputCallback); this.lineNr = 0; this.previousInput = ""; + this.running = false; + this.prompts = []; + this.batchEditor = new BatchInputEditor(); + this.batchEditor.onChange({ + onChange: this.handleInputChanged.bind(this), + delay: 0 + }); + } + + /** + * Handle new input, potentially sending it to the awaiting receiver + * @param {string} newInput The new user input + */ + private handleInputChanged(newInput: string): void { + const newLines = newInput ? newInput.split("\n") : []; + if (newLines.length < this.lineNr) { + this.lineNr = newLines.length; + } + if (this.waiting && newLines.length > this.lineNr + 1) { + // Require explicitly pressing enter + this.inputCallback(); + } + this.highlight(this.running); + this.previousInput = newInput; } public override toggle(active: boolean): void { if (active) { - this.inputArea.value = this.previousInput; + this.batchEditor.setText(this.previousInput); } else { - this.previousInput = this.inputArea.value; + this.previousInput = this.batchEditor.getText(); } } @@ -43,50 +77,64 @@ export class BatchInputHandler extends UserInputHandler { * @return {Array} The entered lines */ protected get lines(): Array { - const l = this.inputArea.value.split("\n"); - if (!l[l.length - 1]) { // last line is empty - l.splice(l.length - 1); // do not consider it valid input - } - return l; + return this.batchEditor.getLines(); } public override hasNext(): boolean { return this.lineNr < this.lines.length; } + private highlight(disable: boolean, whichLines = (i: number) => i < this.lineNr): void { + this.batchEditor.highlight(disable, (lineNr: number) => { + let message = t("Papyros.used_input"); + const index = lineNr - 1; + const shouldShow = whichLines(index); + if (index < this.prompts.length && this.prompts[index]) { + message = t("Papyros.used_input_with_prompt", + { prompt: this.prompts[index] }); + } + return { lineNr, on: shouldShow, title: message }; + }); + } + public override next(): string { const nextLine = this.lines[this.lineNr]; this.lineNr += 1; + this.highlight(true); return nextLine; } public override onRunStart(): void { + this.running = true; this.lineNr = 0; + this.highlight(false, () => false); } public override onRunEnd(): void { - // Intentionally empty + this.running = false; + this.highlight(false); } - protected override _render(options: RenderOptions): void { - renderWithOptions(options, ` -`); - this.inputArea.addEventListener("keydown", (ev: KeyboardEvent) => { - if (this.waiting && ev.key.toLowerCase() === "enter") { - // If user replaced lines, use them - if (this.lines.length < this.lineNr) { - this.lineNr = this.lines.length - 1; - } - this.inputCallback(); - } - }); - this.inputArea.addEventListener("change", () => { - this.previousInput = this.inputArea.value; - }); - this.inputArea.value = this.previousInput; + public override waitWithPrompt(waiting: boolean, prompt?: string): void { + if (waiting) { + this.prompts.push(prompt || ""); + } + super.waitWithPrompt(waiting, prompt); + } + + protected setPlaceholder(placeholderValue: string): void { + this.batchEditor.setPlaceholder(placeholderValue); + } + + public focus(): void { + this.batchEditor.focus(); + } + + protected override _render(options: InputManagerRenderOptions): void { + this.batchEditor.render(options); + if (options.inputStyling) { + this.batchEditor.setStyling(options.inputStyling); + } + this.highlight(this.running); } } diff --git a/src/input/InteractiveInputHandler.ts b/src/input/InteractiveInputHandler.ts index 1600d0d3..1d87ba58 100644 --- a/src/input/InteractiveInputHandler.ts +++ b/src/input/InteractiveInputHandler.ts @@ -18,6 +18,13 @@ export class InteractiveInputHandler extends UserInputHandler { return getElement(SEND_INPUT_BTN_ID); } + /** + * Retrieve the HTMLInputElement for this InputHandler + */ + private get inputArea(): HTMLInputElement { + return getElement(INPUT_TA_ID); + } + public override getInputMode(): InputMode { return InputMode.Interactive; } @@ -43,6 +50,14 @@ export class InteractiveInputHandler extends UserInputHandler { } } + protected override setPlaceholder(placeholder: string): void { + this.inputArea.setAttribute("placeholder", placeholder); + } + + public focus(): void { + this.inputArea.focus(); + } + public override toggle(): void { this.reset(); } @@ -65,7 +80,7 @@ export class InteractiveInputHandler extends UserInputHandler { renderWithOptions(options, `
@@ -79,4 +94,9 @@ export class InteractiveInputHandler extends UserInputHandler { } }); } + + protected reset(): void { + super.reset(); + this.inputArea.value = ""; + } } diff --git a/src/input/UserInputHandler.ts b/src/input/UserInputHandler.ts index 8b0071cf..64052291 100644 --- a/src/input/UserInputHandler.ts +++ b/src/input/UserInputHandler.ts @@ -1,17 +1,18 @@ -import { INPUT_TA_ID } from "../Constants"; -import { InputMode } from "../InputManager"; -import { t, getElement } from "../util/Util"; +import { InputManagerRenderOptions, InputMode } from "../InputManager"; +import { t } from "../util/Util"; import { Renderable } from "../util/Rendering"; /** * Base class for components that handle input from the user */ -export abstract class UserInputHandler extends Renderable { +export abstract class UserInputHandler extends Renderable { /** * Whether we are waiting for the user to input data */ protected waiting: boolean; - + /** + * Function to call when the user provided new input + */ protected inputCallback: () => void; /** @@ -58,11 +59,14 @@ export abstract class UserInputHandler extends Renderable { public abstract toggle(active: boolean): void; /** - * Retrieve the HTMLInputElement for this InputHandler + * @param {string} placeholder The placeholder to show */ - public get inputArea(): HTMLInputElement { - return getElement(INPUT_TA_ID); - } + protected abstract setPlaceholder(placeholder: string): void; + + /** + * Focus the area in which the user enters input + */ + public abstract focus(): void; /** * Wait for input of the user for a certain prompt @@ -71,15 +75,14 @@ export abstract class UserInputHandler extends Renderable { */ public waitWithPrompt(waiting: boolean, prompt = ""): void { this.waiting = waiting; - this.inputArea.setAttribute("placeholder", - prompt || t(`Papyros.input_placeholder.${this.getInputMode()}`)); + this.setPlaceholder(prompt || t(`Papyros.input_placeholder.${this.getInputMode()}`)); if (waiting) { // Focusing is a rendering operation // Subclasses can execute code after this operation, skipping the rendering // Using setTimeout ensures rendering will be done when the main thread has time // eslint-disable-next-line max-len // More info here: https://stackoverflow.com/questions/1096436/document-getelementbyidid-focus-is-not-working-for-firefox-or-chrome - setTimeout(() => this.inputArea.focus(), 0); + setTimeout(() => this.focus(), 0); } } @@ -87,6 +90,6 @@ export abstract class UserInputHandler extends Renderable { * Helper method to reset internal state */ protected reset(): void { - this.inputArea.value = ""; + this.waiting = false; } } diff --git a/src/util/Rendering.ts b/src/util/Rendering.ts index b2756779..72c6868f 100644 --- a/src/util/Rendering.ts +++ b/src/util/Rendering.ts @@ -104,7 +104,7 @@ export interface ButtonOptions { */ export function renderButton(options: ButtonOptions): string { appendClasses(options, - "_tw-m-1 _tw-px-3 _tw-py-1 _tw-rounded-lg _tw-cursor-pointer disabled:_tw-opacity-50 disabled:_tw-cursor-not-allowed"); + "papyros-button"); return `