| <!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/Tools/wasm/README.md" |
| > |
| Tools/wasm/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/Tools/wasm/README.md#the-web-example" |
| > |
| Tools/wasm/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> |