Skip to content

Commit b0a1d3e

Browse files
authored
Pyodide (#925)
* add input and tests to pyide
1 parent 666e315 commit b0a1d3e

15 files changed

Lines changed: 740 additions & 94 deletions

File tree

.changeset/blue-ways-teach.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"hyperbook": minor
3+
"@hyperbook/markdown": minor
4+
---
5+
6+
Add pyide

.changeset/funny-radios-jog.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"hyperbook": minor
3+
"@hyperbook/markdown": minor
4+
---
5+
6+
Add input and tests to pyide

packages/markdown/assets/code.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,21 @@ code[data-line-numbers-max-digits="4"]>[data-line]::before {
182182

183183
code-input:not(.code-input_registered)::before {
184184
content: "..."!important;
185+
}
186+
187+
/**
188+
* Allows code-input elements to be used with the Prism.js line-numbers plugin, as long as the code-input element
189+
* or a parent element of it has the CSS class `line-numbers`.
190+
* https://prismjs.com/plugins/line-numbers/
191+
* Files: prism-line-numbers.css
192+
*/
193+
/* Update padding to match line-numbers plugin */
194+
code-input.line-numbers textarea, code-input.line-numbers.code-input_pre-element-styled pre,
195+
.line-numbers code-input textarea, .line-numbers code-input.code-input_pre-element-styled pre {
196+
padding-left: max(3.8em, var(--padding, 16px))!important;
197+
}
198+
199+
/* Ensure pre code/textarea just wide enough to give 100% width with line numbers */
200+
code-input.line-numbers, .line-numbers code-input {
201+
grid-template-columns: calc(100% - max(0em, calc(3.8em - var(--padding, 16px))));
185202
}

packages/markdown/assets/directive-pyide/client.js

Lines changed: 159 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,78 +11,206 @@ hyperbook.python = (function () {
1111
`${HYPERBOOK_ASSETS}directive-pyide/webworker.js`
1212
);
1313

14-
const callbacks = {};
15-
let isRunning = false;
14+
let callback = null;
15+
/**
16+
* @type Uint8Array
17+
*/
18+
let interruptBuffer;
19+
/**
20+
* @type Int32Array
21+
*/
22+
let stdinBuffer;
23+
if (window.crossOriginIsolated) {
24+
interruptBuffer = new Uint8Array(new SharedArrayBuffer(1));
25+
stdinBuffer = new Int32Array(new SharedArrayBuffer(1024));
26+
pyodideWorker.postMessage({
27+
type: "setStdinBuffer",
28+
payload: { stdinBuffer },
29+
});
30+
pyodideWorker.postMessage({
31+
type: "setInterruptBuffer",
32+
payload: { interruptBuffer },
33+
});
34+
} else {
35+
interruptBuffer = new ArrayBuffer(1);
36+
pyodideWorker.postMessage({
37+
type: "setInterruptBuffer",
38+
payload: { interruptBuffer },
39+
});
40+
}
1641

17-
const asyncRun = (id) => {
18-
if (isRunning) return;
42+
const asyncRun = (id, type) => {
43+
if (callback) return;
1944

20-
isRunning = true;
21-
updateRunning();
45+
interruptBuffer[0] = 0;
2246
return (script, context) => {
23-
// the id could be generated more carefully
2447
return new Promise((onSuccess) => {
25-
callbacks[id] = onSuccess;
48+
callback = onSuccess;
49+
updateRunning(id, type);
2650
pyodideWorker.postMessage({
27-
...context,
28-
python: script,
51+
type: "run",
52+
payload: {
53+
...context,
54+
python: script,
55+
},
2956
id,
3057
});
3158
});
3259
};
3360
};
3461

35-
const updateRunning = () => {
62+
function interruptExecution() {
63+
// 2 stands for SIGINT.
64+
interruptBuffer[0] = 2;
65+
}
66+
67+
const updateRunning = (id, type) => {
3668
for (let elem of elems) {
3769
const run = elem.getElementsByClassName("run")[0];
38-
if (isRunning) {
39-
run.classList.add("running");
40-
run.textContent = "Running ...";
70+
const test = elem.getElementsByClassName("test")[0];
71+
if (callback) {
72+
if (elem.id === id && type === "run") {
73+
run.textContent = "Running (Click to stop) ...";
74+
run.addEventListener("click", interruptExecution);
75+
} else if (test && elem.id === id && type === "test") {
76+
test.textContent = "Testing (Click to stop) ...";
77+
test.addEventListener("click", interruptExecution);
78+
} else {
79+
run.classList.add("running");
80+
run.disabled = true;
81+
if (test) {
82+
test.classList.add("running");
83+
test.disabled = true;
84+
}
85+
}
4186
} else {
4287
run.classList.remove("running");
4388
run.textContent = "Run";
89+
run.disabled = false;
90+
run.removeEventListener("click", interruptExecution);
91+
if (test) {
92+
test.classList.remove("running");
93+
test.textContent = "Test";
94+
test.disabled = false;
95+
test.removeEventListener("click", interruptExecution);
96+
}
4497
}
45-
run.disabled = isRunning;
4698
}
4799
};
48100

49101
pyodideWorker.onmessage = (event) => {
50-
const { id, ...data } = event.data;
51-
if (data.type === "stdout") {
52-
const output = document
53-
.getElementById(id)
54-
.getElementsByClassName("output")[0];
55-
output.appendChild(document.createTextNode(data.message + "\n"));
56-
return;
102+
const { id, type, payload } = event.data;
103+
switch (type) {
104+
case "stdout": {
105+
const output = document
106+
.getElementById(id)
107+
.getElementsByClassName("output")[0];
108+
output.appendChild(document.createTextNode(payload + "\n"));
109+
break;
110+
}
111+
case "error": {
112+
const onSuccess = callback;
113+
onSuccess({ error: payload });
114+
break;
115+
}
116+
case "success": {
117+
const onSuccess = callback;
118+
onSuccess({ results: payload });
119+
break;
120+
}
57121
}
58-
const onSuccess = callbacks[id];
59-
delete callbacks[id];
60-
isRunning = false;
61-
updateRunning();
62-
onSuccess(data);
63122
};
64123

65124
const elems = document.getElementsByClassName("directive-pyide");
66125

67126
for (let elem of elems) {
68127
const editor = elem.getElementsByClassName("editor")[0];
69128
const run = elem.getElementsByClassName("run")[0];
129+
const test = elem.getElementsByClassName("test")[0];
70130
const output = elem.getElementsByClassName("output")[0];
131+
const input = elem.getElementsByClassName("input")[0];
132+
const outputBtn = elem.getElementsByClassName("output-btn")[0];
133+
const inputBtn = elem.getElementsByClassName("input-btn")[0];
71134
const id = elem.id;
135+
let tests = [];
136+
try {
137+
tests = JSON.parse(atob(elem.getAttribute("data-tests")));
138+
} catch (e) {}
139+
140+
function showInput() {
141+
outputBtn.classList.remove("active");
142+
inputBtn.classList.add("active");
143+
output.classList.add("hidden");
144+
input.classList.remove("hidden");
145+
}
146+
function showOutput() {
147+
outputBtn.classList.add("active");
148+
inputBtn.classList.remove("active");
149+
output.classList.remove("hidden");
150+
input.classList.add("hidden");
151+
}
152+
153+
outputBtn?.addEventListener("click", showOutput);
154+
inputBtn?.addEventListener("click", showInput);
155+
156+
test?.addEventListener("click", async () => {
157+
showOutput();
158+
if (callback) return;
159+
160+
output.innerHTML = "";
161+
162+
const script = editor.value;
163+
for (let test of tests) {
164+
const testCode = test.code.replace("#SCRIPT#", script);
165+
166+
const heading = document.createElement("div");
167+
console.log(test);
168+
heading.innerHTML = `== Test ${test.name} ==`;
169+
heading.classList.add("test-heading");
170+
output.appendChild(heading);
171+
172+
await asyncRun(id, "test")(testCode, {})
173+
.then(({ results, error }) => {
174+
if (results) {
175+
output.textContent += results;
176+
} else if (error) {
177+
output.textContent += error;
178+
}
179+
callback = null;
180+
updateRunning(id, "test");
181+
})
182+
.catch((e) => {
183+
output.textContent = `Error: ${e}`;
184+
console.log(e);
185+
callback = null;
186+
updateRunning(id, "test");
187+
});
188+
}
189+
});
190+
191+
run?.addEventListener("click", async () => {
192+
showOutput();
193+
if (callback) return;
72194

73-
run?.addEventListener("click", () => {
74195
const script = editor.value;
75196
output.innerHTML = "";
76-
asyncRun(id)(script, {})
197+
asyncRun(id, "run")(script, {
198+
inputs: input.value.split("\n"),
199+
})
77200
.then(({ results, error }) => {
78201
if (results) {
79-
output.textContent = results;
202+
output.textContent += results;
80203
} else if (error) {
81-
output.textContent = error;
204+
output.textContent += error;
82205
}
206+
callback = null;
207+
updateRunning(id, "run");
83208
})
84209
.catch((e) => {
85210
output.textContent = `Error: ${e}`;
211+
console.log(e);
212+
callback = null;
213+
updateRunning(id, "run");
86214
});
87215
});
88216
}

packages/markdown/assets/directive-pyide/style.css

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,30 @@
1414

1515
.directive-pyide .container {
1616
width: 100%;
17+
overflow: hidden;
18+
height: 200px;
19+
display: flex;
20+
flex-direction: column;
21+
}
22+
23+
.directive-pyide .output,
24+
.directive-pyide .input {
25+
white-space: pre;
26+
height: 100%;
1727
border: 1px solid var(--color-spacer);
1828
border-radius: 8px;
19-
overflow: hidden;
20-
padding: 10px;
29+
border-top-left-radius: 0;
30+
border-top-right-radius: 0;
2131
}
2232

2333
.directive-pyide .output {
24-
height: 200px;
25-
white-space: pre;
34+
padding: 16px;
35+
margin-bottom: 0px;
36+
font-family: hyperbook-monospace, monospace;
37+
}
38+
39+
.directive-pyide .hidden {
40+
display: none;
2641
}
2742

2843
.directive-pyide .editor-container {
@@ -41,26 +56,52 @@
4156
flex: 1;
4257
}
4358

44-
.directive-pyide button {
45-
padding: 8px 16px;
59+
.directive-pyide .buttons {
60+
display: flex;
4661
border: 1px solid var(--color-spacer);
4762
border-radius: 8px;
4863
border-bottom: none;
4964
border-bottom-left-radius: 0;
5065
border-bottom-right-radius: 0;
66+
}
67+
68+
.directive-pyide .test-heading {
69+
font-size: 1.1em;
70+
font-weight: bold;
71+
text-decoration: underline;
72+
margin-top: 8px;
73+
}
74+
75+
.directive-pyide .test-heading:first-of-type {
76+
margin-top: 0;
77+
}
78+
79+
.directive-pyide button {
80+
flex: 1;
81+
padding: 8px 16px;
82+
border: none;
83+
border-right: 1px solid var(--color-spacer);
5184
background-color: var(--color--background);
5285
color: var(--color-text);
5386
cursor: pointer;
5487
}
5588

89+
.directive-pyide .buttons:last-child {
90+
border-right: none;
91+
}
92+
93+
.directive-pyide button.active {
94+
background-color: var(--color-spacer);
95+
}
96+
5697
.directive-pyide button:hover {
5798
background-color: var(--color-spacer);
5899
}
59100

60101
.directive-pyide button.running {
61-
pointer-events: none;
62-
cursor: not-allowed;
63-
opacity: 0.5;
102+
pointer-events: none;
103+
cursor: not-allowed;
104+
opacity: 0.5;
64105
}
65106

66107
@media screen and (min-width: 1024px) {
@@ -69,7 +110,7 @@
69110
height: calc(100dvh - 128px);
70111

71112
.output {
72-
height: 100%;
113+
height: 100%;
73114
}
74115

75116
.container {

0 commit comments

Comments
 (0)