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