| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta http-equiv="X-UA-Compatible" content="IE=edge"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <meta name="author" content="Katie Bell, Adam Hartz"> | |
| <meta name="description" content="Simple REPL for Python WASM"> | |
| <title>wasm-python terminal</title> | |
| <link | |
| rel="stylesheet" | |
| href="https://unpkg.com/xterm@4.18.0/css/xterm.css" | |
| crossorigin | |
| integrity="sha384-4eEEn/eZgVHkElpKAzzPx/Kow/dTSgFk1BNe+uHdjHa+NkZJDh5Vqkq31+y7Eycd" | |
| > | |
| <style> | |
| body { | |
| font-family: arial; | |
| max-width: 800px; | |
| margin: 0 auto; | |
| } | |
| #editor { | |
| padding: 5px; | |
| border: 1px solid black; | |
| width: 100%; | |
| height: 300px; | |
| } | |
| #info { | |
| padding-top: 20px; | |
| } | |
| .error { | |
| border: 1px solid red; | |
| background-color: #ffd9d9; | |
| padding: 5px; | |
| margin-top: 20px; | |
| } | |
| .button-container { | |
| display: flex; | |
| justify-content: end; | |
| height: 50px; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| button { | |
| padding: 6px 18px; | |
| } | |
| </style> | |
| <script | |
| src="https://unpkg.com/xterm@4.18.0/lib/xterm.js" | |
| crossorigin | |
| integrity="sha384-yYdNmem1ioP5Onm7RpXutin5A8TimLheLNQ6tnMi01/ZpxXdAwIm2t4fJMx1Djs+" | |
| ></script> | |
| <script | |
| src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.43.1/ace.js" | |
| crossorigin | |
| integrity="sha512-kmA5vhcxOkZI0ReiKJMGNb8/KKbgbExIlnt6aXuPtl86AgHBEi6OHHOz2wsTazBDGZKxe7fmiE+pIuZJQks4+A==" | |
| ></script> | |
| <script type="module"> | |
| const _magic_ctrlc_string = "__WASM_REPL_CTRLC_" + (Date.now()) + "__"; | |
| class WorkerManager { | |
| constructor( | |
| workerURL, | |
| standardIO, | |
| readyCallBack, | |
| finishedCallback, | |
| ) { | |
| this.workerURL = workerURL; | |
| this.worker = null; | |
| this.standardIO = standardIO; | |
| this.readyCallBack = readyCallBack; | |
| this.finishedCallback = finishedCallback; | |
| this.initialiseWorker(); | |
| } | |
| async initialiseWorker() { | |
| if (!this.worker) { | |
| this.worker = new Worker(this.workerURL, { | |
| type: "module", | |
| }); | |
| this.worker.addEventListener( | |
| "message", | |
| this.handleMessageFromWorker, | |
| ); | |
| } | |
| } | |
| async run(options) { | |
| this.worker.postMessage({ | |
| type: "run", | |
| args: options.args || [], | |
| files: options.files || {}, | |
| }); | |
| } | |
| reset() { | |
| if (this.worker) { | |
| this.worker.terminate(); | |
| this.worker = null; | |
| } | |
| this.standardIO.message("Worker process terminated."); | |
| this.initialiseWorker(); | |
| } | |
| handleStdinData(inputValue) { | |
| if (this.stdinbuffer && this.stdinbufferInt) { | |
| let startingIndex = 1; | |
| if (this.stdinbufferInt[0] > 0) { | |
| startingIndex = this.stdinbufferInt[0]; | |
| } | |
| const data = new TextEncoder().encode(inputValue); | |
| data.forEach((value, index) => { | |
| this.stdinbufferInt[startingIndex + index] = value; | |
| }); | |
| this.stdinbufferInt[0] = | |
| startingIndex + data.length - 1; | |
| Atomics.notify(this.stdinbufferInt, 0, 1); | |
| } | |
| } | |
| handleMessageFromWorker = (event) => { | |
| const type = event.data.type; | |
| if (type === "ready") { | |
| this.readyCallBack(); | |
| } else if (type === "stdout") { | |
| this.standardIO.stdout(event.data.stdout); | |
| } else if (type === "stderr") { | |
| this.standardIO.stderr(event.data.stderr); | |
| } else if (type === "stdin") { | |
| // Leave it to the terminal to decide whether to chunk it into lines | |
| // or send characters depending on the use case. | |
| this.stdinbuffer = event.data.buffer; | |
| this.stdinbufferInt = new Int32Array(this.stdinbuffer); | |
| this.standardIO.stdin().then((inputValue) => { | |
| this.handleStdinData(inputValue); | |
| }); | |
| } else if (type === "finished") { | |
| this.standardIO.message( | |
| `Exited with status: ${event.data.returnCode}`, | |
| ); | |
| this.finishedCallback(); | |
| } | |
| }; | |
| } | |
| class WasmTerminal { | |
| constructor() { | |
| try { | |
| this.history = JSON.parse(sessionStorage.getItem('__python_wasm_repl.history')); | |
| this.historyBuffer = this.history.slice(); | |
| } catch(e) { | |
| this.history = []; | |
| this.historyBuffer = []; | |
| } | |
| this.reset(); | |
| this.xterm = new Terminal({ | |
| scrollback: 10000, | |
| fontSize: 14, | |
| theme: { background: "#1a1c1f" }, | |
| cols: 100, | |
| }); | |
| this.xterm.onKey((keyEvent) => { | |
| // Fix for iOS Keyboard Jumping on space | |
| if (keyEvent.key === " ") { | |
| keyEvent.domEvent.preventDefault(); | |
| } | |
| }); | |
| this.xterm.onData(this.handleTermData); | |
| } | |
| reset() { | |
| this.inputBuffer = new BufferQueue(); | |
| this.input = ""; | |
| this.resolveInput = null; | |
| this.activeInput = false; | |
| this.inputStartCursor = null; | |
| this.cursorPosition = 0; | |
| this.historyIndex = -1; | |
| this.beforeHistoryNav = ""; | |
| } | |
| open(container) { | |
| this.xterm.open(container); | |
| } | |
| handleTermData = (data) => { | |
| const ord = data.charCodeAt(0); | |
| data = data.replace(/\r(?!\n)/g, "\n"); // Convert lone CRs to LF | |
| // Handle pasted data | |
| if (data.length > 1 && data.includes("\n")) { | |
| let alreadyWrittenChars = 0; | |
| // If line already had data on it, merge pasted data with it | |
| if (this.input != "") { | |
| this.inputBuffer.addData(this.input); | |
| alreadyWrittenChars = this.input.length; | |
| this.input = ""; | |
| } | |
| this.inputBuffer.addData(data); | |
| // If input is active, write the first line | |
| if (this.activeInput) { | |
| let line = this.inputBuffer.nextLine(); | |
| this.writeLine(line.slice(alreadyWrittenChars)); | |
| this.resolveInput(line); | |
| this.activeInput = false; | |
| } | |
| // When input isn't active, add to line buffer | |
| } else if (!this.activeInput) { | |
| // Skip non-printable characters | |
| if (!(ord === 0x1b || ord == 0x7f || ord < 32)) { | |
| this.inputBuffer.addData(data); | |
| } | |
| // TODO: Handle more escape sequences? | |
| } else if (ord === 0x1b) { | |
| // Handle special characters | |
| switch (data.slice(1)) { | |
| case "[A": // up | |
| this.historyBack(); | |
| break; | |
| case "[B": // down | |
| this.historyForward(); | |
| break; | |
| case "[C": // right | |
| this.cursorRight(); | |
| break; | |
| case "[D": // left | |
| this.cursorLeft(); | |
| break; | |
| case "[H": // home key | |
| this.cursorHome(true); | |
| break; | |
| case "[F": // end key | |
| this.cursorEnd(true); | |
| break; | |
| case "[3~": // delete key | |
| this.deleteAtCursor(); | |
| break; | |
| default: | |
| break; | |
| } | |
| } else if (ord < 32 || ord === 0x7f) { | |
| switch (data) { | |
| case "\x0c": // CTRL+L | |
| this.clear(); | |
| break; | |
| case "\n": // ENTER | |
| case "\x0a": // CTRL+J | |
| case "\x0d": // CTRL+M | |
| this.resolveInput( | |
| this.input + this.writeLine("\n"), | |
| ); | |
| this.input = ""; | |
| this.cursorPosition = 0; | |
| this.activeInput = false; | |
| break; | |
| case "\x03": // CTRL+C | |
| this.input = ""; | |
| this.cursorPosition = 0; | |
| this.historyIndex = -1; | |
| this.resolveInput(_magic_ctrlc_string + "\n"); | |
| break; | |
| case "\x09": // TAB | |
| this.handleTab(); | |
| break; | |
| case "\x7F": // BACKSPACE | |
| case "\x08": // CTRL+H | |
| this.handleCursorErase(true); | |
| break; | |
| case "\x04": // CTRL+D | |
| // Send empty input | |
| if (this.input === "") { | |
| this.resolveInput(""); | |
| this.cursorPosition = 0; | |
| this.activeInput = false; | |
| } | |
| } | |
| } else { | |
| this.handleCursorInsert(data); | |
| this.updateHistory(); | |
| } | |
| }; | |
| clearLine() { | |
| this.xterm.write("\x1b[K"); | |
| } | |
| writeLine(line) { | |
| this.xterm.write(line.slice(0, -1)); | |
| this.xterm.write("\r\n"); | |
| return line; | |
| } | |
| handleCursorInsert(data) { | |
| const trailing = this.input.slice(this.cursorPosition); | |
| this.input = | |
| this.input.slice(0, this.cursorPosition) + | |
| data + | |
| trailing; | |
| this.cursorPosition += data.length; | |
| this.xterm.write(data); | |
| if (trailing.length !== 0) { | |
| this.xterm.write(trailing); | |
| this.xterm.write("\x1b[" + trailing.length + "D"); | |
| } | |
| this.updateHistory(); | |
| } | |
| handleTab() { | |
| // handle tabs: from the current position, add spaces until | |
| // this.cursorPosition is a multiple of 4. | |
| const prefix = this.input.slice(0, this.cursorPosition); | |
| const suffix = this.input.slice(this.cursorPosition); | |
| const count = 4 - (this.cursorPosition % 4); | |
| const toAdd = " ".repeat(count); | |
| this.input = prefix + toAdd + suffix; | |
| this.cursorHome(false); | |
| this.clearLine(); | |
| this.xterm.write(this.input); | |
| if (suffix) { | |
| this.xterm.write("\x1b[" + suffix.length + "D"); | |
| } | |
| this.cursorPosition += count; | |
| this.updateHistory(); | |
| } | |
| handleCursorErase() { | |
| // Don't delete past the start of input | |
| if ( | |
| this.xterm.buffer.active.cursorX <= | |
| this.inputStartCursor | |
| ) { | |
| return; | |
| } | |
| const trailing = this.input.slice(this.cursorPosition); | |
| this.input = | |
| this.input.slice(0, this.cursorPosition - 1) + trailing; | |
| this.cursorLeft(); | |
| this.clearLine(); | |
| if (trailing.length !== 0) { | |
| this.xterm.write(trailing); | |
| this.xterm.write("\x1b[" + trailing.length + "D"); | |
| } | |
| this.updateHistory(); | |
| } | |
| deleteAtCursor() { | |
| if (this.cursorPosition < this.input.length) { | |
| const trailing = this.input.slice( | |
| this.cursorPosition + 1, | |
| ); | |
| this.input = | |
| this.input.slice(0, this.cursorPosition) + trailing; | |
| this.clearLine(); | |
| if (trailing.length !== 0) { | |
| this.xterm.write(trailing); | |
| this.xterm.write("\x1b[" + trailing.length + "D"); | |
| } | |
| this.updateHistory(); | |
| } | |
| } | |
| cursorRight() { | |
| if (this.cursorPosition < this.input.length) { | |
| this.cursorPosition += 1; | |
| this.xterm.write("\x1b[C"); | |
| } | |
| } | |
| cursorLeft() { | |
| if (this.cursorPosition > 0) { | |
| this.cursorPosition -= 1; | |
| this.xterm.write("\x1b[D"); | |
| } | |
| } | |
| cursorHome(updatePosition) { | |
| if (this.cursorPosition > 0) { | |
| this.xterm.write("\x1b[" + this.cursorPosition + "D"); | |
| if (updatePosition) { | |
| this.cursorPosition = 0; | |
| } | |
| } | |
| } | |
| cursorEnd() { | |
| if (this.cursorPosition < this.input.length) { | |
| this.xterm.write( | |
| "\x1b[" + | |
| (this.input.length - this.cursorPosition) + | |
| "C", | |
| ); | |
| this.cursorPosition = this.input.length; | |
| } | |
| } | |
| updateHistory() { | |
| if (this.historyIndex !== -1) { | |
| this.historyBuffer[this.historyIndex] = this.input; | |
| } else { | |
| this.beforeHistoryNav = this.input; | |
| } | |
| } | |
| historyBack() { | |
| if (this.history.length === 0) { | |
| return; | |
| } else if (this.historyIndex === -1) { | |
| // we're not currently navigating the history; store | |
| // the current command and then look at the end of our | |
| // history buffer | |
| this.beforeHistoryNav = this.input; | |
| this.historyIndex = this.history.length - 1; | |
| } else if (this.historyIndex > 0) { | |
| this.historyIndex -= 1; | |
| } | |
| this.input = this.historyBuffer[this.historyIndex]; | |
| this.cursorHome(false); | |
| this.clearLine(); | |
| this.xterm.write(this.input); | |
| this.cursorPosition = this.input.length; | |
| } | |
| historyForward() { | |
| if (this.history.length === 0 || this.historyIndex === -1) { | |
| // we're not currently navigating the history; NOP. | |
| return; | |
| } else if (this.historyIndex < this.history.length - 1) { | |
| this.historyIndex += 1; | |
| this.input = this.historyBuffer[this.historyIndex]; | |
| } else if (this.historyIndex == this.history.length - 1) { | |
| // we're coming back from the last history value; reset | |
| // the input to whatever it was when we started going | |
| // through the history | |
| this.input = this.beforeHistoryNav; | |
| this.historyIndex = -1; | |
| } | |
| this.cursorHome(false); | |
| this.clearLine(); | |
| this.xterm.write(this.input); | |
| this.cursorPosition = this.input.length; | |
| } | |
| prompt = async () => { | |
| this.activeInput = true; | |
| // Hack to allow stdout/stderr to finish before we figure out where input starts | |
| setTimeout(() => { | |
| this.inputStartCursor = | |
| this.xterm.buffer.active.cursorX; | |
| }, 1); | |
| // If line buffer has a line ready, send it immediately | |
| if (this.inputBuffer.hasLineReady()) { | |
| return new Promise((resolve, reject) => { | |
| resolve( | |
| this.writeLine(this.inputBuffer.nextLine()), | |
| ); | |
| this.activeInput = false; | |
| }); | |
| // If line buffer has an incomplete line, use it for the active line | |
| } else if (this.inputBuffer.lastLineIsIncomplete()) { | |
| // Hack to ensure cursor input start doesn't end up after user input | |
| setTimeout(() => { | |
| this.handleCursorInsert( | |
| this.inputBuffer.nextLine() | |
| ); | |
| }, 1); | |
| } | |
| return new Promise((resolve, reject) => { | |
| this.resolveInput = (value) => { | |
| if ( | |
| value.replace(/\s/g, "").length != 0 && | |
| value != _magic_ctrlc_string + "\n" | |
| ) { | |
| if (this.historyIndex !== -1) { | |
| this.historyBuffer[this.historyIndex] = | |
| this.history[this.historyIndex]; | |
| } | |
| this.history.push(value.slice(0, -1)); | |
| this.historyBuffer.push(value.slice(0, -1)); | |
| this.historyIndex = -1; | |
| this.cursorPosition = 0; | |
| try { | |
| sessionStorage.setItem('__python_wasm_repl.history', JSON.stringify(this.history)); | |
| } catch(e) { | |
| } | |
| } | |
| resolve(value); | |
| }; | |
| }); | |
| }; | |
| clear() { | |
| this.xterm.clear(); | |
| } | |
| print(charCode) { | |
| let array = [charCode]; | |
| if (charCode == 10) { | |
| array = [13, 10]; // Replace \n with \r\n | |
| } | |
| this.xterm.write(new Uint8Array(array)); | |
| } | |
| } | |
| class BufferQueue { | |
| constructor(xterm) { | |
| this.buffer = []; | |
| } | |
| isEmpty() { | |
| return this.buffer.length == 0; | |
| } | |
| lastLineIsIncomplete() { | |
| return ( | |
| !this.isEmpty() && | |
| !this.buffer[this.buffer.length - 1].endsWith("\n") | |
| ); | |
| } | |
| hasLineReady() { | |
| return !this.isEmpty() && this.buffer[0].endsWith("\n"); | |
| } | |
| addData(data) { | |
| let lines = data.match(/.*(\n|$)/g); | |
| if (this.lastLineIsIncomplete()) { | |
| this.buffer[this.buffer.length - 1] += lines.shift(); | |
| } | |
| for (let line of lines) { | |
| this.buffer.push(line); | |
| } | |
| } | |
| nextLine() { | |
| return this.buffer.shift(); | |
| } | |
| } | |
| const runButton = document.getElementById("run"); | |
| const replButton = document.getElementById("repl"); | |
| const stopButton = document.getElementById("stop"); | |
| const clearButton = document.getElementById("clear"); | |
| window.onload = () => { | |
| const terminal = new WasmTerminal(); | |
| terminal.open(document.getElementById("terminal")); | |
| const stdio = { | |
| stdout: (charCode) => { | |
| terminal.print(charCode); | |
| }, | |
| stderr: (charCode) => { | |
| terminal.print(charCode); | |
| }, | |
| stdin: async () => { | |
| return await terminal.prompt(); | |
| }, | |
| message: (text) => { | |
| terminal.writeLine(`\r\n${text}\r\n`); | |
| }, | |
| }; | |
| const programRunning = (isRunning) => { | |
| if (isRunning) { | |
| replButton.setAttribute("disabled", true); | |
| runButton.setAttribute("disabled", true); | |
| stopButton.removeAttribute("disabled"); | |
| } else { | |
| replButton.removeAttribute("disabled"); | |
| runButton.removeAttribute("disabled"); | |
| stopButton.setAttribute("disabled", true); | |
| } | |
| }; | |
| runButton.addEventListener("click", (e) => { | |
| terminal.clear(); | |
| terminal.reset(); // reset the history | |
| programRunning(true); | |
| const code = editor.getValue(); | |
| pythonWorkerManager.run({ | |
| args: ["main.py"], | |
| files: { "main.py": code }, | |
| }); | |
| }); | |
| replButton.addEventListener("click", (e) => { | |
| terminal.clear(); | |
| terminal.reset(); // reset the history | |
| const REPL = ` | |
| class WASMREPLKeyboardInterrupt(KeyboardInterrupt): | |
| pass | |
| import sys | |
| import code | |
| import builtins | |
| def _interrupt_aware_input(prompt=''): | |
| line = builtins.input(prompt) | |
| if line.strip() == "${_magic_ctrlc_string}": | |
| raise KeyboardInterrupt() | |
| return line | |
| cprt = 'Type "help", "copyright", "credits" or "license" for more information.' | |
| banner = f'Python {sys.version} on {sys.platform}\\n{cprt}' | |
| code.interact(banner=banner, readfunc=_interrupt_aware_input, exitmsg='') | |
| `; | |
| programRunning(true); | |
| pythonWorkerManager.run({ args: ["-c", REPL], files: {} }); | |
| }); | |
| stopButton.addEventListener("click", (e) => { | |
| programRunning(false); | |
| pythonWorkerManager.reset(); | |
| }); | |
| clearButton.addEventListener("click", (e) => { | |
| terminal.clear(); | |
| }); | |
| const readyCallback = () => { | |
| replButton.removeAttribute("disabled"); | |
| runButton.removeAttribute("disabled"); | |
| clearButton.removeAttribute("disabled"); | |
| }; | |
| const finishedCallback = () => { | |
| programRunning(false); | |
| pythonWorkerManager.reset(); | |
| }; | |
| const pythonWorkerManager = new WorkerManager( | |
| "./python.worker.mjs", | |
| stdio, | |
| readyCallback, | |
| finishedCallback, | |
| ); | |
| }; | |
| var editor; | |
| document.addEventListener("DOMContentLoaded", () => { | |
| editor = ace.edit("editor"); | |
| editor.session.setMode("ace/mode/python"); | |
| }); | |
| </script> | |
| </head> | |
| <body> | |
| <div id="repldemo"> | |
| <h1>Simple REPL for Python WASM</h1> | |
| <div id="editor">print('Welcome to WASM!')</div> | |
| <div class="button-container"> | |
| <button id="run" disabled>Run code</button> | |
| <button id="repl" disabled>Start REPL</button> | |
| <button id="stop" disabled>Stop</button> | |
| <button id="clear" disabled>Clear</button> | |
| </div> | |
| <div id="terminal"></div> | |
| <div id="info"> | |
| The simple REPL provides a limited Python experience in the | |
| browser. | |
| <a | |
| href="https://github.com/python/cpython/blob/main/Platforms/emscripten/README.md" | |
| > | |
| Platforms/emscripten/README.md | |
| </a> | |
| contains a list of known limitations and issues. Networking, | |
| subprocesses, and threading are not available. | |
| </div> | |
| </div> | |
| <div id="buffererror" class="error" style="display: none"> | |
| <p> | |
| <code>SharedArrayBuffer</code>, which is required for this demo, | |
| is not available in your browser environment. One common cause | |
| of this failure is loading <code>index.html</code> directly in | |
| your browser instead of using <code>server.py</code> as | |
| described in | |
| <a | |
| href="https://github.com/python/cpython/blob/main/Platforms/emscripten/README.md#the-web-example" | |
| > | |
| Platforms/emscripten/README.md | |
| </a>. | |
| </p> | |
| <p> | |
| For more details about security requirements for | |
| <code>SharedArrayBuffer</code>, see | |
| <a | |
| href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements" | |
| >this MDN page</a | |
| >. | |
| </p> | |
| <script> | |
| if (typeof SharedArrayBuffer === 'undefined') { | |
| document.getElementById('repldemo').style.display = 'none'; | |
| document.getElementById('buffererror').style.display = 'block'; | |
| } | |
| </script> | |
| </div> | |
| </body> | |
| </html> |