Merge branch 'main' into poc_ui_blocks

This commit is contained in:
Fabio Pliger
2024-04-03 17:56:13 -05:00
11 changed files with 371 additions and 242 deletions

View File

@@ -26,6 +26,9 @@ import { ErrorCode } from "./exceptions.js";
import { robustFetch as fetch, getText } from "./fetch.js";
import { hooks, main, worker, codeFor, createFunction } from "./hooks.js";
import stdlib from "./stdlib.js";
export { stdlib };
// generic helper to disambiguate between custom element and script
const isScript = ({ tagName }) => tagName === "SCRIPT";

View File

@@ -1,6 +1,6 @@
// PyScript py-editor plugin
import { Hook, XWorker, dedent } from "polyscript/exports";
import { TYPES } from "../core.js";
import { TYPES, stdlib } from "../core.js";
const RUN_BUTTON = `<svg style="height:20px;width:20px;vertical-align:-.125em;transform-origin:center;overflow:visible;color:green" viewBox="0 0 384 512" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg"><g transform="translate(192 256)" transform-origin="96 0"><g transform="translate(0,0) scale(1,1)"><path d="M361 215C375.3 223.8 384 239.3 384 256C384 272.7 375.3 288.2 361 296.1L73.03 472.1C58.21 482 39.66 482.4 24.52 473.9C9.377 465.4 0 449.4 0 432V80C0 62.64 9.377 46.63 24.52 38.13C39.66 29.64 58.21 29.99 73.03 39.04L361 215z" fill="currentColor" transform="translate(-192 -256)"></path></g></g></svg>`;
@@ -8,9 +8,11 @@ let id = 0;
const getID = (type) => `${type}-editor-${id++}`;
const envs = new Map();
const configs = new Map();
const hooks = {
worker: {
codeBeforeRun: () => stdlib,
// works on both Pyodide and MicroPython
onReady: ({ runAsync, io }, { sync }) => {
io.stdout = (line) => sync.write(line);
@@ -32,9 +34,17 @@ async function execute({ currentTarget }) {
if (!envs.has(env)) {
const srcLink = URL.createObjectURL(new Blob([""]));
const xworker = XWorker.call(new Hook(null, hooks), srcLink, {
type: this.interpreter,
});
const details = { type: this.interpreter };
const { config } = this;
if (config) {
details.configURL = config;
const { parse } = config.endsWith(".toml")
? await import(/* webpackIgnore: true */ "../3rd-party/toml.js")
: JSON;
details.config = parse(await fetch(config).then((r) => r.text()));
}
const xworker = XWorker.call(new Hook(null, hooks), srcLink, details);
const { sync } = xworker;
const { promise, resolve } = Promise.withResolvers();
@@ -138,13 +148,28 @@ const init = async (script, type, interpreter) => {
]);
const isSetup = script.hasAttribute("setup");
const hasConfig = script.hasAttribute("config");
const env = `${interpreter}-${script.getAttribute("env") || getID(type)}`;
if (hasConfig && configs.has(env)) {
throw new SyntaxError(
configs.get(env)
? `duplicated config for env: ${env}`
: `unable to add a config to the env: ${env}`,
);
}
configs.set(env, hasConfig);
const source = script.src
? await fetch(script.src).then((b) => b.text())
: script.textContent;
const context = {
interpreter,
env,
config:
hasConfig &&
new URL(script.getAttribute("config"), location.href).href,
get pySrc() {
return isSetup ? source : editor.state.doc.toString();
},
@@ -225,7 +250,7 @@ const resetTimeout = () => {
};
// triggered both ASAP on the living DOM and via MutationObserver later
const pyEditor = async () => {
const pyEditor = () => {
if (timeout) return;
timeout = setTimeout(resetTimeout, 250);
for (const [type, interpreter] of TYPES) {

View File

@@ -1,11 +1,10 @@
// PyScript py-terminal plugin
import { TYPES, hooks } from "../core.js";
import { notify } from "./error.js";
import { defineProperty } from "polyscript/exports";
import { customObserver, defineProperty } from "polyscript/exports";
const SELECTOR = [...TYPES.keys()]
.map((type) => `script[type="${type}"][terminal],${type}-script[terminal]`)
.join(",");
// will contain all valid selectors
const SELECTORS = [];
// show the error on main and
// stops the module from keep executing
@@ -14,8 +13,6 @@ const notifyAndThrow = (message) => {
throw new Error(message);
};
const notParsedYet = (script) => !bootstrapped.has(script);
const onceOnMain = ({ attributes: { worker } }) => !worker;
const bootstrapped = new WeakSet();
@@ -25,11 +22,68 @@ let addStyle = true;
// 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 }, { sync }) => {
const workerReady = ({ interpreter, io, run, type }, { sync }) => {
if (!sync.is_pyterminal()) return;
// in workers it's always safe to grab the polyscript currentScript
run("from polyscript.currentScript import terminal as __terminal__");
// the ugly `_` dance is due MicroPython not able to import via:
// `from polyscript.currentScript import terminal as __terminal__`
run(
"from polyscript import currentScript as _; __terminal__ = _.terminal; del _",
);
// This part is shared among both Pyodide and MicroPython
io.stderr = (error) => {
sync.pyterminal_write(`${error.message || error}\n`);
};
const isMicroPython = type === "mpy";
// MicroPython has no code or code.interact()
// This part patches it in a way that simulate
// the code.interact() module in Pyodide.
if (isMicroPython) {
const encoder = new TextEncoder();
const processData = () => {
if (data.length) {
for (
let i = 0, b = encoder.encode(`${data}\r`);
i < b.length;
i++
) {
const code = interpreter.replProcessChar(b[i]);
if (code) {
throw new Error(
`replProcessChar failed with code ${code}`,
);
}
}
}
data = ">>> ";
data = io.stdin();
processData();
};
interpreter.setStderr = Object; // as no-op
interpreter.setStdout = ({ write }) => {
io.stdout = (str) => {
// avoid duplicated outcome due i/o + readline
const ignore = str.startsWith(`>>> ${data}`);
return ignore ? 0 : write(`${str}\n`);
};
};
interpreter.setStdin = ({ stdin }) => {
io.stdin = stdin;
};
// tiny shim of the code module with only interact
// to bootstrap a REPL like environment
interpreter.registerJsModule("code", {
interact() {
interpreter.replInit();
data = "";
processData();
},
});
}
// This part is inevitably duplicated as external scope
// can't be reached by workers out of the box.
@@ -39,7 +93,7 @@ const workerReady = ({ interpreter, io, run }, { sync }) => {
const generic = {
isatty: true,
write(buffer) {
data = decoder.decode(buffer);
data = isMicroPython ? buffer : decoder.decode(buffer);
sync.pyterminal_write(data);
return buffer.length;
},
@@ -50,38 +104,9 @@ const workerReady = ({ interpreter, io, run }, { sync }) => {
isatty: true,
stdin: () => sync.pyterminal_read(data),
});
io.stderr = (error) => {
sync.pyterminal_write(`${error.message || error}\n`);
};
};
const pyTerminal = async () => {
const terminals = document.querySelectorAll(SELECTOR);
const unknown = [].filter.call(terminals, notParsedYet);
// no results will look further for runtime nodes
if (!unknown.length) return;
// early flag elements as known to avoid concurrent
// MutationObserver invokes of this async handler
else unknown.forEach(bootstrapped.add, bootstrapped);
// we currently support only one terminal as in "classic"
if ([].filter.call(terminals, onceOnMain).length > 1)
notifyAndThrow("You can use at most 1 main terminal");
// import styles lazily
if (addStyle) {
addStyle = false;
document.head.append(
Object.assign(document.createElement("link"), {
rel: "stylesheet",
href: new URL("./xterm.css", import.meta.url),
}),
);
}
const pyTerminal = async (element) => {
// lazy load these only when a valid terminal is found
const [{ Terminal }, { Readline }, { FitAddon }] = await Promise.all([
import(/* webpackIgnore: true */ "../3rd-party/xterm.js"),
@@ -89,118 +114,140 @@ const pyTerminal = async () => {
import(/* webpackIgnore: true */ "../3rd-party/xterm_addon-fit.js"),
]);
for (const element of unknown) {
// hopefully to be removed in the near future!
if (element.matches('script[type="mpy"],mpy-script'))
notifyAndThrow("Unsupported terminal.");
const readline = new Readline();
const readline = new Readline();
// common main thread initialization for both worker
// or main case, bootstrapping the terminal on its target
const init = (options) => {
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",
},
...options,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(readline);
terminal.open(target);
fitAddon.fit();
terminal.focus();
defineProperty(element, "terminal", { value: terminal });
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);
init({
disableStdin: false,
cursorBlink: true,
cursorStyle: "block",
});
xworker.sync.is_pyterminal = () => true;
xworker.sync.pyterminal_read = readline.read.bind(readline);
xworker.sync.pyterminal_write = readline.write.bind(readline);
});
// setup remote thread JS/Python code for whenever the
// worker is ready to become a terminal
hooks.worker.onReady.add(workerReady);
// common main thread initialization for both worker
// or main case, bootstrapping the terminal on its target
const init = (options) => {
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 {
// 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 }) {
console.warn("py-terminal is read only on main thread");
hooks.main.onReady.delete(main);
// on main, it's easy to trash and clean the current terminal
globalThis.__py_terminal__ = init({
disableStdin: true,
cursorBlink: false,
cursorStyle: "underline",
});
run("from js import __py_terminal__ as __terminal__");
delete globalThis.__py_terminal__;
// This part is inevitably duplicated as external scope
// can't be reached by workers out of the box.
// The detail is that here we use readline here, not sync.
const decoder = new TextDecoder();
let data = "";
const generic = {
isatty: true,
write(buffer) {
data = decoder.decode(buffer);
readline.write(data);
return buffer.length;
},
};
interpreter.setStdout(generic);
interpreter.setStderr(generic);
interpreter.setStdin({
isatty: true,
stdin: () => readline.read(data),
});
io.stderr = (error) => {
readline.write(`${error.message || error}\n`);
};
});
target = document.createElement("py-terminal");
target.style.display = "block";
element.after(target);
}
const terminal = new Terminal({
theme: {
background: "#191A19",
foreground: "#F5F2E7",
},
...options,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(readline);
terminal.open(target);
fitAddon.fit();
terminal.focus();
defineProperty(element, "terminal", { value: terminal });
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);
init({
disableStdin: false,
cursorBlink: true,
cursorStyle: "block",
});
xworker.sync.is_pyterminal = () => true;
xworker.sync.pyterminal_read = readline.read.bind(readline);
xworker.sync.pyterminal_write = readline.write.bind(readline);
});
// setup remote thread JS/Python code for whenever the
// worker is ready to become a terminal
hooks.worker.onReady.add(workerReady);
} else {
// 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 }) {
console.warn("py-terminal is read only on main thread");
hooks.main.onReady.delete(main);
// on main, it's easy to trash and clean the current terminal
globalThis.__py_terminal__ = init({
disableStdin: true,
cursorBlink: false,
cursorStyle: "underline",
});
run("from js import __py_terminal__ as __terminal__");
delete globalThis.__py_terminal__;
io.stderr = (error) => {
readline.write(`${error.message || error}\n`);
};
const isMicroPython = type === "mpy";
if (isMicroPython) {
interpreter.setStderr = Object; // as no-op
interpreter.setStdin = Object; // as no-op
interpreter.setStdout = ({ write }) => {
io.stdout = (str) => write(`${str}\n`);
};
}
// This part is inevitably duplicated as external scope
// can't be reached by workers out of the box.
// The detail is that here we use readline here, not sync.
const decoder = new TextDecoder();
let data = "";
const generic = {
isatty: true,
write(buffer) {
data = isMicroPython ? buffer : decoder.decode(buffer);
readline.write(data);
return buffer.length;
},
};
interpreter.setStdout(generic);
interpreter.setStderr(generic);
interpreter.setStdin({
isatty: true,
stdin: () => readline.read(data),
});
});
}
};
const mo = new MutationObserver(pyTerminal);
mo.observe(document, { childList: true, subtree: true });
for (const key of TYPES.keys()) {
const selector = `script[type="${key}"][terminal],${key}-script[terminal]`;
SELECTORS.push(selector);
customObserver.set(selector, async (element) => {
// we currently support only one terminal on main as in "classic"
const terminals = document.querySelectorAll(SELECTORS.join(","));
if ([].filter.call(terminals, onceOnMain).length > 1)
notifyAndThrow("You can use at most 1 main terminal");
// try to check the current document ASAP
export default pyTerminal();
// import styles lazily
if (addStyle) {
addStyle = false;
document.head.append(
Object.assign(document.createElement("link"), {
rel: "stylesheet",
href: new URL("./xterm.css", import.meta.url),
}),
);
}
await pyTerminal(element);
});
}