cpython/Tools/wasm/python.html

<!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, finishedCallback) {
        this.workerURL = workerURL
        this.worker = null
        this.standardIO = standardIO
        this.readyCallBack = readyCallBack
        this.finishedCallback = finishedCallback

        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 || {}
        })
    }

    reset() {
        if (this.worker) {
            this.worker.terminate()
            this.worker = null
        }
        this.standardIO.message('Worker process terminated.')
        this.initialiseWorker()
    }

    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.message(`Exited with status: ${event.data.returnCode}`)
            this.finishedCallback()
        }
    }
}

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
                    this.handleCursorErase(true);
                    break;
                case "\x04": // CTRL+D
                    // Send empty input
                    if (this.input === '') {
                        this.resolveInput('')
                        this.activeInput = false;
                    }
            }
        } 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 runButton = document.getElementById('run')
const replButton = document.getElementById('repl')
const stopButton = document.getElementById('stop')
const clearButton = document.getElementById('clear')

const codeBox = document.getElementById('codebox')

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()
        },
        message: (text) => { terminal.writeLine(`\r\n${text}\r\n`) },
    }

    const programRunning = (isRunning) => {
        if (isRunning) {
            replButton.setAttribute('disabled', true)
            runButton.setAttribute('disabled', true)
            stopButton.removeAttribute('disabled')
        } else {
            replButton.removeAttribute('disabled')
            runButton.removeAttribute('disabled')
            stopButton.setAttribute('disabled', true)
        }
    }

    runButton.addEventListener('click', (e) => {
        terminal.clear()
        programRunning(true)
        const code = codeBox.value
        pythonWorkerManager.run({args: ['main.py'], files: {'main.py': code}})
    })

    replButton.addEventListener('click', (e) => {
        terminal.clear()
        programRunning(true)
        // Need to use "-i -" to force interactive mode.
        // Looks like isatty always returns false in emscripten
        pythonWorkerManager.run({args: ['-i', '-'], files: {}})
    })

    stopButton.addEventListener('click', (e) => {
        programRunning(false)
        pythonWorkerManager.reset()
    })

    clearButton.addEventListener('click', (e) => {
        terminal.clear()
    })

    const readyCallback = () => {
        replButton.removeAttribute('disabled')
        runButton.removeAttribute('disabled')
        clearButton.removeAttribute('disabled')
    }

    const finishedCallback = () => {
        programRunning(false)
    }

    const pythonWorkerManager = new WorkerManager('./python.worker.js', stdio, readyCallback, finishedCallback)
}
    </script>
</head>
<body>
    <h1>Simple REPL for Python WASM</h1>
<textarea id="codebox" cols="108" rows="16">
print('Welcome to WASM!')
</textarea>
    <div class="button-container">
      <button id="run" disabled>Run</button>
      <button id="repl" disabled>Start REPL</button>
      <button id="stop" disabled>Stop</button>
      <button id="clear" disabled>Clear</button>
    </div>
    <div id="terminal"></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>