cpython/Tools/wasm/python.html
Katie Bell 010aaa32fb
gh-97747: Improvements to WASM browser REPL. (#97665)
Improvements to WASM browser REPL.

Adds a text box to write and run code outside the REPL, a stop button, and handling of Ctrl-D for EOF.
2024-05-31 09:58:46 +02:00

374 lines
12 KiB
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/xterm@4.18.0/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/xterm@4.18.0/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>