mirror of
https://github.com/python/cpython.git
synced 2025-11-06 00:22:07 +00:00
703 lines
28 KiB
HTML
703 lines
28 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, Adam Hartz">
|
|
<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;
|
|
}
|
|
#editor {
|
|
padding: 5px;
|
|
border: 1px solid black;
|
|
width: 100%;
|
|
height: 300px;
|
|
}
|
|
#info {
|
|
padding-top: 20px;
|
|
}
|
|
.error {
|
|
border: 1px solid red;
|
|
background-color: #ffd9d9;
|
|
padding: 5px;
|
|
margin-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
|
|
src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.43.1/ace.js"
|
|
crossorigin
|
|
integrity="sha512-kmA5vhcxOkZI0ReiKJMGNb8/KKbgbExIlnt6aXuPtl86AgHBEi6OHHOz2wsTazBDGZKxe7fmiE+pIuZJQks4+A=="
|
|
></script>
|
|
<script type="module">
|
|
const _magic_ctrlc_string = "__WASM_REPL_CTRLC_" + (Date.now()) + "__";
|
|
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, {
|
|
type: "module",
|
|
});
|
|
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() {
|
|
try {
|
|
this.history = JSON.parse(sessionStorage.getItem('__python_wasm_repl.history'));
|
|
this.historyBuffer = this.history.slice();
|
|
} catch(e) {
|
|
this.history = [];
|
|
this.historyBuffer = [];
|
|
}
|
|
this.reset();
|
|
|
|
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);
|
|
}
|
|
|
|
reset() {
|
|
this.inputBuffer = new BufferQueue();
|
|
this.input = "";
|
|
this.resolveInput = null;
|
|
this.activeInput = false;
|
|
this.inputStartCursor = null;
|
|
|
|
this.cursorPosition = 0;
|
|
this.historyIndex = -1;
|
|
this.beforeHistoryNav = "";
|
|
}
|
|
|
|
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 more escape sequences?
|
|
} else if (ord === 0x1b) {
|
|
// Handle special characters
|
|
switch (data.slice(1)) {
|
|
case "[A": // up
|
|
this.historyBack();
|
|
break;
|
|
case "[B": // down
|
|
this.historyForward();
|
|
break;
|
|
case "[C": // right
|
|
this.cursorRight();
|
|
break;
|
|
case "[D": // left
|
|
this.cursorLeft();
|
|
break;
|
|
case "[H": // home key
|
|
this.cursorHome(true);
|
|
break;
|
|
case "[F": // end key
|
|
this.cursorEnd(true);
|
|
break;
|
|
case "[3~": // delete key
|
|
this.deleteAtCursor();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
} 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.cursorPosition = 0;
|
|
this.activeInput = false;
|
|
break;
|
|
case "\x03": // CTRL+C
|
|
this.input = "";
|
|
this.cursorPosition = 0;
|
|
this.historyIndex = -1;
|
|
this.resolveInput(_magic_ctrlc_string + "\n");
|
|
break;
|
|
case "\x09": // TAB
|
|
this.handleTab();
|
|
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.cursorPosition = 0;
|
|
this.activeInput = false;
|
|
}
|
|
}
|
|
} else {
|
|
this.handleCursorInsert(data);
|
|
this.updateHistory();
|
|
}
|
|
};
|
|
|
|
clearLine() {
|
|
this.xterm.write("\x1b[K");
|
|
}
|
|
|
|
writeLine(line) {
|
|
this.xterm.write(line.slice(0, -1));
|
|
this.xterm.write("\r\n");
|
|
return line;
|
|
}
|
|
|
|
handleCursorInsert(data) {
|
|
const trailing = this.input.slice(this.cursorPosition);
|
|
this.input =
|
|
this.input.slice(0, this.cursorPosition) +
|
|
data +
|
|
trailing;
|
|
this.cursorPosition += data.length;
|
|
this.xterm.write(data);
|
|
if (trailing.length !== 0) {
|
|
this.xterm.write(trailing);
|
|
this.xterm.write("\x1b[" + trailing.length + "D");
|
|
}
|
|
this.updateHistory();
|
|
}
|
|
|
|
handleTab() {
|
|
// handle tabs: from the current position, add spaces until
|
|
// this.cursorPosition is a multiple of 4.
|
|
const prefix = this.input.slice(0, this.cursorPosition);
|
|
const suffix = this.input.slice(this.cursorPosition);
|
|
const count = 4 - (this.cursorPosition % 4);
|
|
const toAdd = " ".repeat(count);
|
|
this.input = prefix + toAdd + suffix;
|
|
this.cursorHome(false);
|
|
this.clearLine();
|
|
this.xterm.write(this.input);
|
|
if (suffix) {
|
|
this.xterm.write("\x1b[" + suffix.length + "D");
|
|
}
|
|
this.cursorPosition += count;
|
|
this.updateHistory();
|
|
}
|
|
|
|
handleCursorErase() {
|
|
// Don't delete past the start of input
|
|
if (
|
|
this.xterm.buffer.active.cursorX <=
|
|
this.inputStartCursor
|
|
) {
|
|
return;
|
|
}
|
|
const trailing = this.input.slice(this.cursorPosition);
|
|
this.input =
|
|
this.input.slice(0, this.cursorPosition - 1) + trailing;
|
|
this.cursorLeft();
|
|
this.clearLine();
|
|
if (trailing.length !== 0) {
|
|
this.xterm.write(trailing);
|
|
this.xterm.write("\x1b[" + trailing.length + "D");
|
|
}
|
|
this.updateHistory();
|
|
}
|
|
|
|
deleteAtCursor() {
|
|
if (this.cursorPosition < this.input.length) {
|
|
const trailing = this.input.slice(
|
|
this.cursorPosition + 1,
|
|
);
|
|
this.input =
|
|
this.input.slice(0, this.cursorPosition) + trailing;
|
|
this.clearLine();
|
|
if (trailing.length !== 0) {
|
|
this.xterm.write(trailing);
|
|
this.xterm.write("\x1b[" + trailing.length + "D");
|
|
}
|
|
this.updateHistory();
|
|
}
|
|
}
|
|
|
|
cursorRight() {
|
|
if (this.cursorPosition < this.input.length) {
|
|
this.cursorPosition += 1;
|
|
this.xterm.write("\x1b[C");
|
|
}
|
|
}
|
|
|
|
cursorLeft() {
|
|
if (this.cursorPosition > 0) {
|
|
this.cursorPosition -= 1;
|
|
this.xterm.write("\x1b[D");
|
|
}
|
|
}
|
|
|
|
cursorHome(updatePosition) {
|
|
if (this.cursorPosition > 0) {
|
|
this.xterm.write("\x1b[" + this.cursorPosition + "D");
|
|
if (updatePosition) {
|
|
this.cursorPosition = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
cursorEnd() {
|
|
if (this.cursorPosition < this.input.length) {
|
|
this.xterm.write(
|
|
"\x1b[" +
|
|
(this.input.length - this.cursorPosition) +
|
|
"C",
|
|
);
|
|
this.cursorPosition = this.input.length;
|
|
}
|
|
}
|
|
|
|
updateHistory() {
|
|
if (this.historyIndex !== -1) {
|
|
this.historyBuffer[this.historyIndex] = this.input;
|
|
} else {
|
|
this.beforeHistoryNav = this.input;
|
|
}
|
|
}
|
|
|
|
historyBack() {
|
|
if (this.history.length === 0) {
|
|
return;
|
|
} else if (this.historyIndex === -1) {
|
|
// we're not currently navigating the history; store
|
|
// the current command and then look at the end of our
|
|
// history buffer
|
|
this.beforeHistoryNav = this.input;
|
|
this.historyIndex = this.history.length - 1;
|
|
} else if (this.historyIndex > 0) {
|
|
this.historyIndex -= 1;
|
|
}
|
|
this.input = this.historyBuffer[this.historyIndex];
|
|
this.cursorHome(false);
|
|
this.clearLine();
|
|
this.xterm.write(this.input);
|
|
this.cursorPosition = this.input.length;
|
|
}
|
|
|
|
historyForward() {
|
|
if (this.history.length === 0 || this.historyIndex === -1) {
|
|
// we're not currently navigating the history; NOP.
|
|
return;
|
|
} else if (this.historyIndex < this.history.length - 1) {
|
|
this.historyIndex += 1;
|
|
this.input = this.historyBuffer[this.historyIndex];
|
|
} else if (this.historyIndex == this.history.length - 1) {
|
|
// we're coming back from the last history value; reset
|
|
// the input to whatever it was when we started going
|
|
// through the history
|
|
this.input = this.beforeHistoryNav;
|
|
this.historyIndex = -1;
|
|
}
|
|
this.cursorHome(false);
|
|
this.clearLine();
|
|
this.xterm.write(this.input);
|
|
this.cursorPosition = this.input.length;
|
|
}
|
|
|
|
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) => {
|
|
if (
|
|
value.replace(/\s/g, "").length != 0 &&
|
|
value != _magic_ctrlc_string + "\n"
|
|
) {
|
|
if (this.historyIndex !== -1) {
|
|
this.historyBuffer[this.historyIndex] =
|
|
this.history[this.historyIndex];
|
|
}
|
|
this.history.push(value.slice(0, -1));
|
|
this.historyBuffer.push(value.slice(0, -1));
|
|
this.historyIndex = -1;
|
|
this.cursorPosition = 0;
|
|
try {
|
|
sessionStorage.setItem('__python_wasm_repl.history', JSON.stringify(this.history));
|
|
} catch(e) {
|
|
}
|
|
}
|
|
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");
|
|
|
|
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();
|
|
terminal.reset(); // reset the history
|
|
programRunning(true);
|
|
const code = editor.getValue();
|
|
pythonWorkerManager.run({
|
|
args: ["main.py"],
|
|
files: { "main.py": code },
|
|
});
|
|
});
|
|
|
|
replButton.addEventListener("click", (e) => {
|
|
terminal.clear();
|
|
terminal.reset(); // reset the history
|
|
const REPL = `
|
|
class WASMREPLKeyboardInterrupt(KeyboardInterrupt):
|
|
pass
|
|
|
|
import sys
|
|
import code
|
|
import builtins
|
|
|
|
def _interrupt_aware_input(prompt=''):
|
|
line = builtins.input(prompt)
|
|
if line.strip() == "${_magic_ctrlc_string}":
|
|
raise KeyboardInterrupt()
|
|
return line
|
|
|
|
cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
|
|
banner = f'Python {sys.version} on {sys.platform}\\n{cprt}'
|
|
|
|
code.interact(banner=banner, readfunc=_interrupt_aware_input, exitmsg='')
|
|
`;
|
|
programRunning(true);
|
|
pythonWorkerManager.run({ args: ["-c", REPL], 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);
|
|
pythonWorkerManager.reset();
|
|
};
|
|
|
|
const pythonWorkerManager = new WorkerManager(
|
|
"./python.worker.mjs",
|
|
stdio,
|
|
readyCallback,
|
|
finishedCallback,
|
|
);
|
|
};
|
|
var editor;
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
editor = ace.edit("editor");
|
|
editor.session.setMode("ace/mode/python");
|
|
});
|
|
</script>
|
|
</head>
|
|
<body>
|
|
<div id="repldemo">
|
|
<h1>Simple REPL for Python WASM</h1>
|
|
<div id="editor">print('Welcome to WASM!')</div>
|
|
<div class="button-container">
|
|
<button id="run" disabled>Run code</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>
|
|
</div>
|
|
<div id="buffererror" class="error" style="display: none">
|
|
<p>
|
|
<code>SharedArrayBuffer</code>, which is required for this demo,
|
|
is not available in your browser environment. One common cause
|
|
of this failure is loading <code>index.html</code> directly in
|
|
your browser instead of using <code>server.py</code> as
|
|
described in
|
|
<a
|
|
href="https://github.com/python/cpython/blob/main/Tools/wasm/README.md#the-web-example"
|
|
>
|
|
Tools/wasm/README.md
|
|
</a>.
|
|
</p>
|
|
<p>
|
|
For more details about security requirements for
|
|
<code>SharedArrayBuffer</code>, see
|
|
<a
|
|
href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer#security_requirements"
|
|
>this MDN page</a
|
|
>.
|
|
</p>
|
|
<script>
|
|
if (typeof SharedArrayBuffer === 'undefined') {
|
|
document.getElementById('repldemo').style.display = 'none';
|
|
document.getElementById('buffererror').style.display = 'block';
|
|
}
|
|
</script>
|
|
</div>
|
|
</body>
|
|
</html>
|