• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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