| <!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/[email protected]/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/[email protected]/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> |