pyodide
Version:
The Pyodide JavaScript package
394 lines (363 loc) • 13.3 kB
HTML
<!--
Pyodide Console v2 (Experimental)
This is an experimental version of the Pyodide console that provides
an enhanced terminal experience using xterm.js. This implementation replaces
the jQuery Terminal used in the original console with a more feature-rich
terminal emulator that offers better performance and modern terminal capabilities.
Note: This console is still under development and may not have all the features
of the stable console.
-->
<html>
<head>
<title>Pyodide Console</title>
<meta charset="UTF-8" />
<meta
http-equiv="origin-trial"
content="Aq6vv/4syIkcyMszFgCc9LlH0kX88jdE7SXfCFnh2RQN0nhhL8o6PCQ2oE3a7n3mC7+d9n89Repw5HYBtjarDw4AAAB3eyJvcmlnaW4iOiJodHRwczovL3B5b2RpZGUub3JnOjQ0MyIsImZlYXR1cmUiOiJXZWJBc3NlbWJseUpTUHJvbWlzZUludGVncmF0aW9uIiwiZXhwaXJ5IjoxNzMwMjQ2Mzk5LCJpc1N1YmRvbWFpbiI6dHJ1ZX0="
/>
<meta
http-equiv="origin-trial"
content="Ai8IXb0XqedlM/Q2guWXFfBkKiYY9uaPZpdjHqc8y0ZvpAfK9SKzp/dIuFH+txG/HEKxt59uIkk39hhWrhNgbw4AAABieyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgwMDAiLCJmZWF0dXJlIjoiV2ViQXNzZW1ibHlKU1Byb21pc2VJbnRlZ3JhdGlvbiIsImV4cGlyeSI6MTczMDI0NjM5OX0="
/>
<link
rel="stylesheet"
href="https://unpkg.com/@xterm/xterm@5.4.0/css/xterm.css"
/>
<link
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐍</text></svg>"
rel="icon"
/>
<script src="https://unpkg.com/@xterm/xterm@5.4.0/lib/xterm.js"></script>
<script src="https://unpkg.com/@xterm/addon-fit@0.9.0/lib/addon-fit.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
background-color: #000000;
font-family: "Monaco", "Menlo", "Courier New", monospace;
overflow: hidden;
}
#terminal {
position: fixed;
inset: 10px;
}
#loading {
display: inline-block;
width: 50px;
height: 50px;
position: fixed;
top: 50%;
left: 50%;
border: 3px solid rgba(172, 237, 255, 0.5);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
-webkit-animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
-webkit-transform: rotate(360deg);
}
}
@-webkit-keyframes spin {
to {
-webkit-transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div id="loading"></div>
<div id="terminal"></div>
<script type="module">
async function main() {
const fitAddon = new FitAddon.FitAddon();
const term = new Terminal({
cursorBlink: true,
cursorStyle: "block",
convertEol: true,
scrollback: 2_000,
fontSize: 18,
lineHeight: 1.4,
fontFamily: "monospace",
theme: {
background: "#000000",
foreground: "rgba(255, 255, 255, 0.8)",
cursor: "rgba(255, 255, 255, 0.8)",
selection: "#404040",
error: "#ff0000",
},
});
window.term = term;
term.open(document.getElementById("terminal"));
term.loadAddon(fitAddon);
fitAddon.fit();
term.focus();
window.addEventListener("resize", () => {
setTimeout(() => fitAddon.fit(), 50);
});
// Re-fit after the page has fully loaded
window.addEventListener("load", () => {
setTimeout(() => fitAddon.fit(), 100);
});
// Initialize Pyodide
let indexURL = "./";
const urlParams = new URLSearchParams(window.location.search);
const buildParam = urlParams.get("build");
if (buildParam && ["full", "debug", "pyc"].includes(buildParam)) {
indexURL = indexURL.replace("/full/", "/" + buildParam + "/");
}
const { loadPyodide } = await import(indexURL + "pyodide.mjs");
const pyodide = await loadPyodide();
globalThis.pyodide = pyodide;
// Hide loading spinner
document.getElementById("loading").style.display = "none";
const { repr_shorten, BANNER, PyodideConsole } =
pyodide.pyimport("pyodide.console");
term.writeln(
`Welcome to the Pyodide ${pyodide.version} terminal emulator 🐍\n${BANNER}`
);
const pyconsole = PyodideConsole(pyodide.globals);
const namespace = pyodide.globals.get("dict")();
const await_fut = pyodide.runPython(
`
import builtins
from pyodide.ffi import to_js
async def await_fut(fut):
res = await fut
if res is not None:
builtins._ = res
return to_js([res], depth=1)
await_fut
`,
{ globals: namespace }
);
namespace.destroy();
pyconsole.stdout_callback = (s) => term.write(s);
pyconsole.stderr_callback = (s) => term.write(`\x1b[31m${s}\x1b[0m`);
// Handle fatal errors
pyodide._api.on_fatal = async (e) => {
if (e.name === "Exit") {
term.write(`\x1b[31m${e}\x1b[0m\r\n`);
term.write(
"\x1b[31mPyodide exited and can no longer be used.\x1b[0m\r\n"
);
} else {
term.write(
"\x1b[31mPyodide has suffered a fatal error. Please report this to the Pyodide maintainers.\x1b[0m\r\n"
);
term.write("\x1b[31mThe cause of the fatal error was:\x1b[0m\r\n");
term.write(`\x1b[31m${e.message || e}\x1b[0m\r\n`);
term.write(
"\x1b[31mLook in the browser console for more details.\x1b[0m\r\n"
);
}
};
// REPL implementation
const ps1 = ">>> ";
const ps2 = "... ";
let buffer = "";
let cursorIndex = 0; // index within buffer for in-line editing
let prompt = ps1;
const history = [];
let historyIndex = null; // null means not navigating history
term.write(prompt);
function addToHistory(command) {
const trimmed = command.trimEnd();
if (!trimmed) return;
const last = history[history.length - 1];
if (last !== trimmed) history.push(trimmed);
}
function refreshLine() {
// Write left part, save cursor, write right part, clear, restore cursor.
const clearCommand = "\x1b[0K";
const leftPart = prompt + buffer.slice(0, cursorIndex);
const rightPart = buffer.slice(cursorIndex);
term.write(
`\x1b[0G${leftPart}\x1b[s${rightPart}${clearCommand}\x1b[u`
);
}
function setBuffer(newBuffer, newCursorIndex = null) {
buffer = newBuffer;
if (newCursorIndex === null) {
cursorIndex = buffer.length;
} else {
cursorIndex = Math.max(0, Math.min(newCursorIndex, buffer.length));
}
refreshLine();
}
async function execLine(line) {
// Normalize non-breaking spaces to regular spaces
line = line.replace(/\u00a0/g, " ");
// clear the terminal
if (line === "clear") {
term.clear();
return;
}
const fut = pyconsole.push(line);
switch (fut.syntax_check) {
case "syntax-error":
term.write(`\x1b[31m${fut.formatted_error.trimEnd()}\x1b[0m`);
term.write("\r\n");
prompt = ps1;
addToHistory(line);
historyIndex = null;
fut.destroy();
break;
case "incomplete":
prompt = ps2;
addToHistory(line);
historyIndex = null;
return;
case "complete":
prompt = ps1;
try {
const wrapped = await_fut(fut);
const [value] = await wrapped;
if (value !== undefined) {
const output = repr_shorten.callKwargs(value, {
separator: "\n<long output truncated>\n",
});
term.write(output);
term.write("\r\n");
}
if (value instanceof pyodide.ffi.PyProxy) value.destroy();
wrapped.destroy();
} catch (e) {
const msg = fut.formatted_error || e.message;
term.write(`\x1b[31m${String(msg).trimEnd()}\x1b[0m`);
term.write("\r\n");
} finally {
fut.destroy();
}
addToHistory(line);
historyIndex = null;
break;
default:
term.write(
`\r\nUnexpected syntax_check value: ${fut.syntax_check}`
);
}
}
term.onData(async (data) => {
switch (data) {
case "\r": // Enter
term.write("\r\n");
await execLine(buffer);
buffer = "";
cursorIndex = 0;
term.write(prompt);
break;
case "\u0003": // Ctrl-C
pyconsole.buffer.clear();
buffer = "";
cursorIndex = 0;
term.write("^C\r\nKeyboardInterrupt\r\n" + ps1);
prompt = ps1;
historyIndex = null;
break;
case "\u0016": // Ctrl-V
// paste from clipboard
const clipboard = await navigator.clipboard.readText();
const newBuf =
buffer.slice(0, cursorIndex) +
clipboard +
buffer.slice(cursorIndex);
setBuffer(newBuf, newBuf.length);
break;
case "\u007F": // Backspace
if (cursorIndex > 0) {
const before = buffer.slice(0, cursorIndex - 1);
const after = buffer.slice(cursorIndex);
cursorIndex -= 1;
setBuffer(before + after, cursorIndex);
}
break;
case "\x1B[A": // Up arrow
if (prompt === ps1) {
if (historyIndex === null) historyIndex = history.length;
if (historyIndex > 0) {
historyIndex -= 1;
const newBuf = history[historyIndex] || "";
setBuffer(newBuf, newBuf.length);
}
}
break;
case "\x1B[B": // Down arrow
if (prompt === ps1 && historyIndex !== null) {
if (historyIndex < history.length - 1) {
historyIndex += 1;
const newBuf = history[historyIndex] || "";
setBuffer(newBuf, newBuf.length);
} else {
historyIndex = null;
setBuffer("", 0);
}
}
break;
case "\x1B[C": // Right arrow
if (cursorIndex < buffer.length) {
cursorIndex += 1;
refreshLine();
}
break;
case "\x1B[D": // Left arrow
if (cursorIndex > 0) {
cursorIndex -= 1;
refreshLine();
}
break;
default:
if (data) {
// Normalize non-breaking spaces to regular spaces
data = data.replace(/\u00a0/g, " ");
// Insert arbitrary string at cursor position
const before = buffer.slice(0, cursorIndex);
const after = buffer.slice(cursorIndex);
const newBuf = before + data + after;
const newCursor = cursorIndex + data.length;
setBuffer(newBuf, newCursor);
}
}
});
// 4. Extra features
let idbkvPromise;
async function getIDBKV() {
if (!idbkvPromise) {
idbkvPromise = await import(
"https://unpkg.com/idb-keyval@5.0.2/dist/esm/index.js"
);
}
return idbkvPromise;
}
async function mountDirectory(pyodideDirectory, directoryKey) {
if (pyodide.FS.analyzePath(pyodideDirectory).exists) {
return;
}
const { get, set } = await getIDBKV();
const opts = { id: "mountdirid", mode: "readwrite" };
let directoryHandle = await get(directoryKey);
if (!directoryHandle) {
directoryHandle = await showDirectoryPicker(opts);
await set(directoryKey, directoryHandle);
}
const permissionStatus = await directoryHandle.requestPermission(
opts
);
if (permissionStatus !== "granted") {
throw new Error("readwrite access to directory not granted");
}
await pyodide.mountNativeFS(pyodideDirectory, directoryHandle);
}
globalThis.mountDirectory = mountDirectory;
}
window.console_ready = main();
</script>
</body>
</html>