mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 18:27:29 -05:00
* Allow MicroPython Terminal on Main * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
240 lines
8.4 KiB
JavaScript
240 lines
8.4 KiB
JavaScript
// PyScript pyodide terminal plugin
|
|
import { hooks, inputFailure } from "../../core.js";
|
|
import { defineProperties } from "polyscript/exports";
|
|
|
|
const bootstrapped = new WeakSet();
|
|
|
|
// this callback will be serialized as string and it never needs
|
|
// to be invoked multiple times. Each xworker here is bootstrapped
|
|
// only once thanks to the `sync.is_pyterminal()` check.
|
|
const workerReady = ({ interpreter, io, run, type }, { sync }) => {
|
|
if (type !== "mpy" || !sync.is_pyterminal()) return;
|
|
|
|
const { pyterminal_ready, pyterminal_read, pyterminal_write } = sync;
|
|
|
|
interpreter.registerJsModule("_pyscript_input", {
|
|
input: pyterminal_read,
|
|
});
|
|
|
|
run(
|
|
[
|
|
"from _pyscript_input import input",
|
|
"from polyscript import currentScript as _",
|
|
"__terminal__ = _.terminal",
|
|
"del _",
|
|
].join(";"),
|
|
);
|
|
|
|
const missingReturn = new Uint8Array([13]);
|
|
io.stdout = (buffer) => {
|
|
if (buffer[0] === 10) pyterminal_write(missingReturn);
|
|
pyterminal_write(buffer);
|
|
};
|
|
io.stderr = (error) => {
|
|
pyterminal_write(String(error.message || error));
|
|
};
|
|
|
|
// tiny shim of the code module with only interact
|
|
// to bootstrap a REPL like environment
|
|
interpreter.registerJsModule("code", {
|
|
interact() {
|
|
const encoder = new TextEncoderStream();
|
|
encoder.readable.pipeTo(
|
|
new WritableStream({
|
|
write(buffer) {
|
|
for (const c of buffer) interpreter.replProcessChar(c);
|
|
},
|
|
}),
|
|
);
|
|
|
|
const writer = encoder.writable.getWriter();
|
|
sync.pyterminal_stream_write = (buffer) => writer.write(buffer);
|
|
pyterminal_ready();
|
|
|
|
interpreter.replInit();
|
|
},
|
|
});
|
|
};
|
|
|
|
export default async (element) => {
|
|
// lazy load these only when a valid terminal is found
|
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
|
import(/* webpackIgnore: true */ "../../3rd-party/xterm.js"),
|
|
import(/* webpackIgnore: true */ "../../3rd-party/xterm_addon-fit.js"),
|
|
import(
|
|
/* webpackIgnore: true */ "../../3rd-party/xterm_addon-web-links.js"
|
|
),
|
|
]);
|
|
|
|
const terminalOptions = {
|
|
disableStdin: false,
|
|
cursorBlink: true,
|
|
cursorStyle: "block",
|
|
};
|
|
|
|
let stream;
|
|
|
|
// common main thread initialization for both worker
|
|
// or main case, bootstrapping the terminal on its target
|
|
const init = () => {
|
|
let target = element;
|
|
const selector = element.getAttribute("target");
|
|
if (selector) {
|
|
target =
|
|
document.getElementById(selector) ||
|
|
document.querySelector(selector);
|
|
if (!target) throw new Error(`Unknown target ${selector}`);
|
|
} else {
|
|
target = document.createElement("py-terminal");
|
|
target.style.display = "block";
|
|
element.after(target);
|
|
}
|
|
const terminal = new Terminal({
|
|
theme: {
|
|
background: "#191A19",
|
|
foreground: "#F5F2E7",
|
|
},
|
|
...terminalOptions,
|
|
});
|
|
const fitAddon = new FitAddon();
|
|
terminal.loadAddon(fitAddon);
|
|
terminal.loadAddon(new WebLinksAddon());
|
|
terminal.open(target);
|
|
fitAddon.fit();
|
|
terminal.focus();
|
|
defineProperties(element, {
|
|
terminal: { value: terminal },
|
|
process: {
|
|
value: async (code) => {
|
|
for (const line of code.split(/(?:\r\n|\r|\n)/)) {
|
|
await stream.write(`${line}\r`);
|
|
}
|
|
},
|
|
},
|
|
});
|
|
return terminal;
|
|
};
|
|
|
|
// branch logic for the worker
|
|
if (element.hasAttribute("worker")) {
|
|
// add a hook on the main thread to setup all sync helpers
|
|
// also bootstrapping the XTerm target on main *BUT* ...
|
|
hooks.main.onWorker.add(function worker(_, xworker) {
|
|
// ... as multiple workers will add multiple callbacks
|
|
// be sure no xworker is ever initialized twice!
|
|
if (bootstrapped.has(xworker)) return;
|
|
bootstrapped.add(xworker);
|
|
|
|
// still cleanup this callback for future scripts/workers
|
|
hooks.main.onWorker.delete(worker);
|
|
|
|
const terminal = init();
|
|
|
|
const { sync } = xworker;
|
|
|
|
// handle the read mode on input
|
|
let promisedChunks = null;
|
|
let readChunks = "";
|
|
|
|
sync.is_pyterminal = () => true;
|
|
|
|
// put the terminal in a read-only state
|
|
// frees the worker on \r
|
|
sync.pyterminal_read = (buffer) => {
|
|
terminal.write(buffer);
|
|
promisedChunks = Promise.withResolvers();
|
|
return promisedChunks.promise;
|
|
};
|
|
|
|
// write if not reading input
|
|
sync.pyterminal_write = (buffer) => {
|
|
if (!promisedChunks) terminal.write(buffer);
|
|
};
|
|
|
|
// add the onData terminal listener which forwards to the worker
|
|
// everything typed in a queued char-by-char way
|
|
sync.pyterminal_ready = () => {
|
|
let queue = Promise.resolve();
|
|
stream = {
|
|
write: (buffer) =>
|
|
(queue = queue.then(() =>
|
|
sync.pyterminal_stream_write(buffer),
|
|
)),
|
|
};
|
|
terminal.onData((buffer) => {
|
|
if (promisedChunks) {
|
|
readChunks += buffer;
|
|
terminal.write(buffer);
|
|
if (readChunks.endsWith("\r")) {
|
|
terminal.write("\n");
|
|
promisedChunks.resolve(readChunks.slice(0, -1));
|
|
promisedChunks = null;
|
|
readChunks = "";
|
|
}
|
|
} else {
|
|
stream.write(buffer);
|
|
}
|
|
});
|
|
};
|
|
});
|
|
|
|
// setup remote thread JS/Python code for whenever the
|
|
// worker is ready to become a terminal
|
|
hooks.worker.onReady.add(workerReady);
|
|
} else {
|
|
// ⚠️ In an ideal world the inputFailure should never be used on main.
|
|
// However, Pyodide still can't compete with MicroPython REPL mode
|
|
// so while it's OK to keep that entry on main as default, we need
|
|
// to remove it ASAP from `mpy` use cases, otherwise MicroPython would
|
|
// also throw whenever an `input(...)` is required / digited.
|
|
hooks.main.codeBeforeRun.delete(inputFailure);
|
|
|
|
// in the main case, just bootstrap XTerm without
|
|
// allowing any input as that's not possible / awkward
|
|
hooks.main.onReady.add(function main({ interpreter, io, run, type }) {
|
|
if (type !== "mpy") return;
|
|
|
|
hooks.main.onReady.delete(main);
|
|
|
|
const terminal = init();
|
|
|
|
const missingReturn = new Uint8Array([13]);
|
|
io.stdout = (buffer) => {
|
|
if (buffer[0] === 10) terminal.write(missingReturn);
|
|
terminal.write(buffer);
|
|
};
|
|
|
|
// expose the __terminal__ one-off reference
|
|
globalThis.__py_terminal__ = terminal;
|
|
run(
|
|
[
|
|
"from js import prompt as input",
|
|
"from js import __py_terminal__ as __terminal__",
|
|
].join(";"),
|
|
);
|
|
delete globalThis.__py_terminal__;
|
|
|
|
// NOTE: this is NOT the same as the one within
|
|
// the onWorkerReady callback!
|
|
interpreter.registerJsModule("code", {
|
|
interact() {
|
|
const encoder = new TextEncoderStream();
|
|
encoder.readable.pipeTo(
|
|
new WritableStream({
|
|
write(buffer) {
|
|
for (const c of buffer)
|
|
interpreter.replProcessChar(c);
|
|
},
|
|
}),
|
|
);
|
|
|
|
stream = encoder.writable.getWriter();
|
|
terminal.onData((buffer) => stream.write(buffer));
|
|
|
|
interpreter.replInit();
|
|
},
|
|
});
|
|
});
|
|
}
|
|
};
|