1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta http-equiv="X-UA-Compatible" content="IE=edge"> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 <meta name="author" content="Katie Bell"> 8 <meta name="description" content="Simple REPL for Python WASM"> 9 <title>wasm-python terminal</title> 10 <link rel="stylesheet" href="https://unpkg.com/xterm@4.18.0/css/xterm.css" crossorigin integrity="sha384-4eEEn/eZgVHkElpKAzzPx/Kow/dTSgFk1BNe+uHdjHa+NkZJDh5Vqkq31+y7Eycd"/> 11 <style> 12 body { 13 font-family: arial; 14 max-width: 800px; 15 margin: 0 auto 16 } 17 #code { 18 width: 100%; 19 height: 180px; 20 } 21 #info { 22 padding-top: 20px; 23 } 24 .button-container { 25 display: flex; 26 justify-content: end; 27 height: 50px; 28 align-items: center; 29 gap: 10px; 30 } 31 button { 32 padding: 6px 18px; 33 } 34 </style> 35 <script src="https://unpkg.com/xterm@4.18.0/lib/xterm.js" crossorigin integrity="sha384-yYdNmem1ioP5Onm7RpXutin5A8TimLheLNQ6tnMi01/ZpxXdAwIm2t4fJMx1Djs+"/></script> 36 <script type="module"> 37class WorkerManager { 38 constructor(workerURL, standardIO, readyCallBack, finishedCallback) { 39 this.workerURL = workerURL 40 this.worker = null 41 this.standardIO = standardIO 42 this.readyCallBack = readyCallBack 43 this.finishedCallback = finishedCallback 44 45 this.initialiseWorker() 46 } 47 48 async initialiseWorker() { 49 if (!this.worker) { 50 this.worker = new Worker(this.workerURL) 51 this.worker.addEventListener('message', this.handleMessageFromWorker) 52 } 53 } 54 55 async run(options) { 56 this.worker.postMessage({ 57 type: 'run', 58 args: options.args || [], 59 files: options.files || {} 60 }) 61 } 62 63 reset() { 64 if (this.worker) { 65 this.worker.terminate() 66 this.worker = null 67 } 68 this.standardIO.message('Worker process terminated.') 69 this.initialiseWorker() 70 } 71 72 handleStdinData(inputValue) { 73 if (this.stdinbuffer && this.stdinbufferInt) { 74 let startingIndex = 1 75 if (this.stdinbufferInt[0] > 0) { 76 startingIndex = this.stdinbufferInt[0] 77 } 78 const data = new TextEncoder().encode(inputValue) 79 data.forEach((value, index) => { 80 this.stdinbufferInt[startingIndex + index] = value 81 }) 82 83 this.stdinbufferInt[0] = startingIndex + data.length - 1 84 Atomics.notify(this.stdinbufferInt, 0, 1) 85 } 86 } 87 88 handleMessageFromWorker = (event) => { 89 const type = event.data.type 90 if (type === 'ready') { 91 this.readyCallBack() 92 } else if (type === 'stdout') { 93 this.standardIO.stdout(event.data.stdout) 94 } else if (type === 'stderr') { 95 this.standardIO.stderr(event.data.stderr) 96 } else if (type === 'stdin') { 97 // Leave it to the terminal to decide whether to chunk it into lines 98 // or send characters depending on the use case. 99 this.stdinbuffer = event.data.buffer 100 this.stdinbufferInt = new Int32Array(this.stdinbuffer) 101 this.standardIO.stdin().then((inputValue) => { 102 this.handleStdinData(inputValue) 103 }) 104 } else if (type === 'finished') { 105 this.standardIO.message(`Exited with status: ${event.data.returnCode}`) 106 this.finishedCallback() 107 } 108 } 109} 110 111class WasmTerminal { 112 113 constructor() { 114 this.inputBuffer = new BufferQueue(); 115 this.input = '' 116 this.resolveInput = null 117 this.activeInput = false 118 this.inputStartCursor = null 119 120 this.xterm = new Terminal( 121 { scrollback: 10000, fontSize: 14, theme: { background: '#1a1c1f' }, cols: 100} 122 ); 123 124 this.xterm.onKey((keyEvent) => { 125 // Fix for iOS Keyboard Jumping on space 126 if (keyEvent.key === " ") { 127 keyEvent.domEvent.preventDefault(); 128 } 129 }); 130 131 this.xterm.onData(this.handleTermData) 132 } 133 134 open(container) { 135 this.xterm.open(container); 136 } 137 138 handleTermData = (data) => { 139 const ord = data.charCodeAt(0); 140 data = data.replace(/\r(?!\n)/g, "\n") // Convert lone CRs to LF 141 142 // Handle pasted data 143 if (data.length > 1 && data.includes("\n")) { 144 let alreadyWrittenChars = 0; 145 // If line already had data on it, merge pasted data with it 146 if (this.input != '') { 147 this.inputBuffer.addData(this.input); 148 alreadyWrittenChars = this.input.length; 149 this.input = ''; 150 } 151 this.inputBuffer.addData(data); 152 // If input is active, write the first line 153 if (this.activeInput) { 154 let line = this.inputBuffer.nextLine(); 155 this.writeLine(line.slice(alreadyWrittenChars)); 156 this.resolveInput(line); 157 this.activeInput = false; 158 } 159 // When input isn't active, add to line buffer 160 } else if (!this.activeInput) { 161 // Skip non-printable characters 162 if (!(ord === 0x1b || ord == 0x7f || ord < 32)) { 163 this.inputBuffer.addData(data); 164 } 165 // TODO: Handle ANSI escape sequences 166 } else if (ord === 0x1b) { 167 // Handle special characters 168 } else if (ord < 32 || ord === 0x7f) { 169 switch (data) { 170 case "\x0c": // CTRL+L 171 this.clear(); 172 break; 173 case "\n": // ENTER 174 case "\x0a": // CTRL+J 175 case "\x0d": // CTRL+M 176 this.resolveInput(this.input + this.writeLine('\n')); 177 this.input = ''; 178 this.activeInput = false; 179 break; 180 case "\x7F": // BACKSPACE 181 case "\x08": // CTRL+H 182 this.handleCursorErase(true); 183 break; 184 case "\x04": // CTRL+D 185 // Send empty input 186 if (this.input === '') { 187 this.resolveInput('') 188 this.activeInput = false; 189 } 190 } 191 } else { 192 this.handleCursorInsert(data); 193 } 194 } 195 196 writeLine(line) { 197 this.xterm.write(line.slice(0, -1)) 198 this.xterm.write('\r\n'); 199 return line; 200 } 201 202 handleCursorInsert(data) { 203 this.input += data; 204 this.xterm.write(data) 205 } 206 207 handleCursorErase() { 208 // Don't delete past the start of input 209 if (this.xterm.buffer.active.cursorX <= this.inputStartCursor) { 210 return 211 } 212 this.input = this.input.slice(0, -1) 213 this.xterm.write('\x1B[D') 214 this.xterm.write('\x1B[P') 215 } 216 217 prompt = async () => { 218 this.activeInput = true 219 // Hack to allow stdout/stderr to finish before we figure out where input starts 220 setTimeout(() => {this.inputStartCursor = this.xterm.buffer.active.cursorX}, 1) 221 // If line buffer has a line ready, send it immediately 222 if (this.inputBuffer.hasLineReady()) { 223 return new Promise((resolve, reject) => { 224 resolve(this.writeLine(this.inputBuffer.nextLine())); 225 this.activeInput = false; 226 }) 227 // If line buffer has an incomplete line, use it for the active line 228 } else if (this.inputBuffer.lastLineIsIncomplete()) { 229 // Hack to ensure cursor input start doesn't end up after user input 230 setTimeout(() => {this.handleCursorInsert(this.inputBuffer.nextLine())}, 1); 231 } 232 return new Promise((resolve, reject) => { 233 this.resolveInput = (value) => { 234 resolve(value) 235 } 236 }) 237 } 238 239 clear() { 240 this.xterm.clear(); 241 } 242 243 print(charCode) { 244 let array = [charCode]; 245 if (charCode == 10) { 246 array = [13, 10]; // Replace \n with \r\n 247 } 248 this.xterm.write(new Uint8Array(array)); 249 } 250} 251 252class BufferQueue { 253 constructor(xterm) { 254 this.buffer = [] 255 } 256 257 isEmpty() { 258 return this.buffer.length == 0 259 } 260 261 lastLineIsIncomplete() { 262 return !this.isEmpty() && !this.buffer[this.buffer.length-1].endsWith("\n") 263 } 264 265 hasLineReady() { 266 return !this.isEmpty() && this.buffer[0].endsWith("\n") 267 } 268 269 addData(data) { 270 let lines = data.match(/.*(\n|$)/g) 271 if (this.lastLineIsIncomplete()) { 272 this.buffer[this.buffer.length-1] += lines.shift() 273 } 274 for (let line of lines) { 275 this.buffer.push(line) 276 } 277 } 278 279 nextLine() { 280 return this.buffer.shift() 281 } 282} 283 284const runButton = document.getElementById('run') 285const replButton = document.getElementById('repl') 286const stopButton = document.getElementById('stop') 287const clearButton = document.getElementById('clear') 288 289const codeBox = document.getElementById('codebox') 290 291window.onload = () => { 292 const terminal = new WasmTerminal() 293 terminal.open(document.getElementById('terminal')) 294 295 const stdio = { 296 stdout: (charCode) => { terminal.print(charCode) }, 297 stderr: (charCode) => { terminal.print(charCode) }, 298 stdin: async () => { 299 return await terminal.prompt() 300 }, 301 message: (text) => { terminal.writeLine(`\r\n${text}\r\n`) }, 302 } 303 304 const programRunning = (isRunning) => { 305 if (isRunning) { 306 replButton.setAttribute('disabled', true) 307 runButton.setAttribute('disabled', true) 308 stopButton.removeAttribute('disabled') 309 } else { 310 replButton.removeAttribute('disabled') 311 runButton.removeAttribute('disabled') 312 stopButton.setAttribute('disabled', true) 313 } 314 } 315 316 runButton.addEventListener('click', (e) => { 317 terminal.clear() 318 programRunning(true) 319 const code = codeBox.value 320 pythonWorkerManager.run({args: ['main.py'], files: {'main.py': code}}) 321 }) 322 323 replButton.addEventListener('click', (e) => { 324 terminal.clear() 325 programRunning(true) 326 // Need to use "-i -" to force interactive mode. 327 // Looks like isatty always returns false in emscripten 328 pythonWorkerManager.run({args: ['-i', '-'], files: {}}) 329 }) 330 331 stopButton.addEventListener('click', (e) => { 332 programRunning(false) 333 pythonWorkerManager.reset() 334 }) 335 336 clearButton.addEventListener('click', (e) => { 337 terminal.clear() 338 }) 339 340 const readyCallback = () => { 341 replButton.removeAttribute('disabled') 342 runButton.removeAttribute('disabled') 343 clearButton.removeAttribute('disabled') 344 } 345 346 const finishedCallback = () => { 347 programRunning(false) 348 } 349 350 const pythonWorkerManager = new WorkerManager('./python.worker.js', stdio, readyCallback, finishedCallback) 351} 352 </script> 353</head> 354<body> 355 <h1>Simple REPL for Python WASM</h1> 356<textarea id="codebox" cols="108" rows="16"> 357print('Welcome to WASM!') 358</textarea> 359 <div class="button-container"> 360 <button id="run" disabled>Run</button> 361 <button id="repl" disabled>Start REPL</button> 362 <button id="stop" disabled>Stop</button> 363 <button id="clear" disabled>Clear</button> 364 </div> 365 <div id="terminal"></div> 366 <div id="info"> 367 The simple REPL provides a limited Python experience in the browser. 368 <a href="https://github.com/python/cpython/blob/main/Tools/wasm/README.md"> 369 Tools/wasm/README.md</a> contains a list of known limitations and 370 issues. Networking, subprocesses, and threading are not available. 371 </div> 372</body> 373</html> 374