| <!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"> | |
| <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 | |
| } | |
| #code { | |
| width: 100%; | |
| height: 180px; | |
| } | |
| #info { | |
| padding-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 type="module"> | |
| class WorkerManager { | |
| constructor(workerURL, standardIO, readyCallBack) { | |
| this.workerURL = workerURL | |
| this.worker = null | |
| this.standardIO = standardIO | |
| this.readyCallBack = readyCallBack | |
| this.initialiseWorker() | |
| } | |
| async initialiseWorker() { | |
| if (!this.worker) { | |
| this.worker = new Worker(this.workerURL) | |
| this.worker.addEventListener('message', this.handleMessageFromWorker) | |
| } | |
| } | |
| async run(options) { | |
| this.worker.postMessage({ | |
| type: 'run', | |
| args: options.args || [], | |
| files: options.files || {} | |
| }) | |
| } | |
| 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.stderr(`Exited with status: ${event.data.returnCode}\r\n`) | |
| } | |
| } | |
| } | |
| class WasmTerminal { | |
| constructor() { | |
| this.inputBuffer = new BufferQueue(); | |
| this.input = '' | |
| this.resolveInput = null | |
| this.activeInput = false | |
| this.inputStartCursor = null | |
| 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) | |
| } | |
| 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 ANSI escape sequences | |
| } else if (ord === 0x1b) { | |
| // Handle special characters | |
| } 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.activeInput = false; | |
| break; | |
| case "\x7F": // BACKSPACE | |
| case "\x08": // CTRL+H | |
| case "\x04": // CTRL+D | |
| this.handleCursorErase(true); | |
| break; | |
| } | |
| } else { | |
| this.handleCursorInsert(data); | |
| } | |
| } | |
| writeLine(line) { | |
| this.xterm.write(line.slice(0, -1)) | |
| this.xterm.write('\r\n'); | |
| return line; | |
| } | |
| handleCursorInsert(data) { | |
| this.input += data; | |
| this.xterm.write(data) | |
| } | |
| handleCursorErase() { | |
| // Don't delete past the start of input | |
| if (this.xterm.buffer.active.cursorX <= this.inputStartCursor) { | |
| return | |
| } | |
| this.input = this.input.slice(0, -1) | |
| this.xterm.write('\x1B[D') | |
| this.xterm.write('\x1B[P') | |
| } | |
| 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) => { | |
| 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 replButton = document.getElementById('repl') | |
| 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() | |
| } | |
| } | |
| replButton.addEventListener('click', (e) => { | |
| // Need to use "-i -" to force interactive mode. | |
| // Looks like isatty always returns false in emscripten | |
| pythonWorkerManager.run({args: ['-i', '-'], files: {}}) | |
| }) | |
| clearButton.addEventListener('click', (e) => { | |
| terminal.clear() | |
| }) | |
| const readyCallback = () => { | |
| replButton.removeAttribute('disabled') | |
| clearButton.removeAttribute('disabled') | |
| } | |
| const pythonWorkerManager = new WorkerManager('./python.worker.js', stdio, readyCallback) | |
| } | |
| </script> | |
| </head> | |
| <body> | |
| <h1>Simple REPL for Python WASM</h1> | |
| <div id="terminal"></div> | |
| <div class="button-container"> | |
| <button id="repl" disabled>Start REPL</button> | |
| <button id="clear" disabled>Clear</button> | |
| </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> | |
| </body> | |
| </html> |