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/[email protected]/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/[email protected]/lib/xterm.js" crossorigin integrity="sha384-yYdNmem1ioP5Onm7RpXutin5A8TimLheLNQ6tnMi01/ZpxXdAwIm2t4fJMx1Djs+"/></script> 36 <script type="module"> 37class WorkerManager { 38 constructor(workerURL, standardIO, readyCallBack) { 39 this.workerURL = workerURL 40 this.worker = null 41 this.standardIO = standardIO 42 this.readyCallBack = readyCallBack 43 44 this.initialiseWorker() 45 } 46 47 async initialiseWorker() { 48 if (!this.worker) { 49 this.worker = new Worker(this.workerURL) 50 this.worker.addEventListener('message', this.handleMessageFromWorker) 51 } 52 } 53 54 async run(options) { 55 this.worker.postMessage({ 56 type: 'run', 57 args: options.args || [], 58 files: options.files || {} 59 }) 60 } 61 62 handleStdinData(inputValue) { 63 if (this.stdinbuffer && this.stdinbufferInt) { 64 let startingIndex = 1 65 if (this.stdinbufferInt[0] > 0) { 66 startingIndex = this.stdinbufferInt[0] 67 } 68 const data = new TextEncoder().encode(inputValue) 69 data.forEach((value, index) => { 70 this.stdinbufferInt[startingIndex + index] = value 71 }) 72 73 this.stdinbufferInt[0] = startingIndex + data.length - 1 74 Atomics.notify(this.stdinbufferInt, 0, 1) 75 } 76 } 77 78 handleMessageFromWorker = (event) => { 79 const type = event.data.type 80 if (type === 'ready') { 81 this.readyCallBack() 82 } else if (type === 'stdout') { 83 this.standardIO.stdout(event.data.stdout) 84 } else if (type === 'stderr') { 85 this.standardIO.stderr(event.data.stderr) 86 } else if (type === 'stdin') { 87 // Leave it to the terminal to decide whether to chunk it into lines 88 // or send characters depending on the use case. 89 this.stdinbuffer = event.data.buffer 90 this.stdinbufferInt = new Int32Array(this.stdinbuffer) 91 this.standardIO.stdin().then((inputValue) => { 92 this.handleStdinData(inputValue) 93 }) 94 } else if (type === 'finished') { 95 this.standardIO.stderr(`Exited with status: ${event.data.returnCode}\r\n`) 96 } 97 } 98} 99 100class WasmTerminal { 101 102 constructor() { 103 this.inputBuffer = new BufferQueue(); 104 this.input = '' 105 this.resolveInput = null 106 this.activeInput = false 107 this.inputStartCursor = null 108 109 this.xterm = new Terminal( 110 { scrollback: 10000, fontSize: 14, theme: { background: '#1a1c1f' }, cols: 100} 111 ); 112 113 this.xterm.onKey((keyEvent) => { 114 // Fix for iOS Keyboard Jumping on space 115 if (keyEvent.key === " ") { 116 keyEvent.domEvent.preventDefault(); 117 } 118 }); 119 120 this.xterm.onData(this.handleTermData) 121 } 122 123 open(container) { 124 this.xterm.open(container); 125 } 126 127 handleTermData = (data) => { 128 const ord = data.charCodeAt(0); 129 data = data.replace(/\r(?!\n)/g, "\n") // Convert lone CRs to LF 130 131 // Handle pasted data 132 if (data.length > 1 && data.includes("\n")) { 133 let alreadyWrittenChars = 0; 134 // If line already had data on it, merge pasted data with it 135 if (this.input != '') { 136 this.inputBuffer.addData(this.input); 137 alreadyWrittenChars = this.input.length; 138 this.input = ''; 139 } 140 this.inputBuffer.addData(data); 141 // If input is active, write the first line 142 if (this.activeInput) { 143 let line = this.inputBuffer.nextLine(); 144 this.writeLine(line.slice(alreadyWrittenChars)); 145 this.resolveInput(line); 146 this.activeInput = false; 147 } 148 // When input isn't active, add to line buffer 149 } else if (!this.activeInput) { 150 // Skip non-printable characters 151 if (!(ord === 0x1b || ord == 0x7f || ord < 32)) { 152 this.inputBuffer.addData(data); 153 } 154 // TODO: Handle ANSI escape sequences 155 } else if (ord === 0x1b) { 156 // Handle special characters 157 } else if (ord < 32 || ord === 0x7f) { 158 switch (data) { 159 case "\x0c": // CTRL+L 160 this.clear(); 161 break; 162 case "\n": // ENTER 163 case "\x0a": // CTRL+J 164 case "\x0d": // CTRL+M 165 this.resolveInput(this.input + this.writeLine('\n')); 166 this.input = ''; 167 this.activeInput = false; 168 break; 169 case "\x7F": // BACKSPACE 170 case "\x08": // CTRL+H 171 case "\x04": // CTRL+D 172 this.handleCursorErase(true); 173 break; 174 } 175 } else { 176 this.handleCursorInsert(data); 177 } 178 } 179 180 writeLine(line) { 181 this.xterm.write(line.slice(0, -1)) 182 this.xterm.write('\r\n'); 183 return line; 184 } 185 186 handleCursorInsert(data) { 187 this.input += data; 188 this.xterm.write(data) 189 } 190 191 handleCursorErase() { 192 // Don't delete past the start of input 193 if (this.xterm.buffer.active.cursorX <= this.inputStartCursor) { 194 return 195 } 196 this.input = this.input.slice(0, -1) 197 this.xterm.write('\x1B[D') 198 this.xterm.write('\x1B[P') 199 } 200 201 prompt = async () => { 202 this.activeInput = true 203 // Hack to allow stdout/stderr to finish before we figure out where input starts 204 setTimeout(() => {this.inputStartCursor = this.xterm.buffer.active.cursorX}, 1) 205 // If line buffer has a line ready, send it immediately 206 if (this.inputBuffer.hasLineReady()) { 207 return new Promise((resolve, reject) => { 208 resolve(this.writeLine(this.inputBuffer.nextLine())); 209 this.activeInput = false; 210 }) 211 // If line buffer has an incomplete line, use it for the active line 212 } else if (this.inputBuffer.lastLineIsIncomplete()) { 213 // Hack to ensure cursor input start doesn't end up after user input 214 setTimeout(() => {this.handleCursorInsert(this.inputBuffer.nextLine())}, 1); 215 } 216 return new Promise((resolve, reject) => { 217 this.resolveInput = (value) => { 218 resolve(value) 219 } 220 }) 221 } 222 223 clear() { 224 this.xterm.clear(); 225 } 226 227 print(charCode) { 228 let array = [charCode]; 229 if (charCode == 10) { 230 array = [13, 10]; // Replace \n with \r\n 231 } 232 this.xterm.write(new Uint8Array(array)); 233 } 234} 235 236class BufferQueue { 237 constructor(xterm) { 238 this.buffer = [] 239 } 240 241 isEmpty() { 242 return this.buffer.length == 0 243 } 244 245 lastLineIsIncomplete() { 246 return !this.isEmpty() && !this.buffer[this.buffer.length-1].endsWith("\n") 247 } 248 249 hasLineReady() { 250 return !this.isEmpty() && this.buffer[0].endsWith("\n") 251 } 252 253 addData(data) { 254 let lines = data.match(/.*(\n|$)/g) 255 if (this.lastLineIsIncomplete()) { 256 this.buffer[this.buffer.length-1] += lines.shift() 257 } 258 for (let line of lines) { 259 this.buffer.push(line) 260 } 261 } 262 263 nextLine() { 264 return this.buffer.shift() 265 } 266} 267 268const replButton = document.getElementById('repl') 269const clearButton = document.getElementById('clear') 270 271window.onload = () => { 272 const terminal = new WasmTerminal() 273 terminal.open(document.getElementById('terminal')) 274 275 const stdio = { 276 stdout: (charCode) => { terminal.print(charCode) }, 277 stderr: (charCode) => { terminal.print(charCode) }, 278 stdin: async () => { 279 return await terminal.prompt() 280 } 281 } 282 283 replButton.addEventListener('click', (e) => { 284 // Need to use "-i -" to force interactive mode. 285 // Looks like isatty always returns false in emscripten 286 pythonWorkerManager.run({args: ['-i', '-'], files: {}}) 287 }) 288 289 clearButton.addEventListener('click', (e) => { 290 terminal.clear() 291 }) 292 293 const readyCallback = () => { 294 replButton.removeAttribute('disabled') 295 clearButton.removeAttribute('disabled') 296 } 297 298 const pythonWorkerManager = new WorkerManager('./python.worker.js', stdio, readyCallback) 299} 300 </script> 301</head> 302<body> 303 <h1>Simple REPL for Python WASM</h1> 304 <div id="terminal"></div> 305 <div class="button-container"> 306 <button id="repl" disabled>Start REPL</button> 307 <button id="clear" disabled>Clear</button> 308 </div> 309 <div id="info"> 310 The simple REPL provides a limited Python experience in the browser. 311 <a href="https://github.com/python/cpython/blob/main/Tools/wasm/README.md"> 312 Tools/wasm/README.md</a> contains a list of known limitations and 313 issues. Networking, subprocesses, and threading are not available. 314 </div> 315</body> 316</html> 317