hyperbook 0.91.1 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,6 +45,10 @@ hyperbook.python = (function () {
45
45
  * @type {Map<string, Set<string>>}
46
46
  */
47
47
  const installedMicropipPackages = new Map();
48
+ /**
49
+ * @type {Map<string, any>}
50
+ */
51
+ const turtleModules = new Map();
48
52
 
49
53
  /**
50
54
  * @type {Map<string, { running: boolean, stopping: boolean, stopRequested: boolean, type: "run" | "test" | null }>}
@@ -74,7 +78,12 @@ hyperbook.python = (function () {
74
78
  const loadPyodide = await pyodideReadyPromise;
75
79
  const pyodide = await loadPyodide();
76
80
  if (typeof pyodide.registerJsModule === "function") {
81
+ const turtleModule = createTurtleJsFFI(id);
82
+ turtleModule.__setPyodide(pyodide);
83
+ pyodide.registerJsModule("turtle", turtleModule);
84
+ pyodide.registerJsModule("jturtle", turtleModule);
77
85
  pyodide.registerJsModule("pytamaro_js_ffi", createPytamaroJsFFI());
86
+ turtleModules.set(id, turtleModule);
78
87
  }
79
88
  if (
80
89
  typeof SharedArrayBuffer !== "undefined" &&
@@ -95,16 +104,58 @@ hyperbook.python = (function () {
95
104
  * @type {Map<string, string>}
96
105
  */
97
106
  const pytamaroStdoutCarry = new Map();
107
+ const pytamaroCanvasTargets = new Set();
98
108
 
99
109
  const getOutput = (id) => {
100
110
  return document.getElementById(id)?.getElementsByClassName("output")[0];
101
111
  };
102
112
 
113
+ const getCanvas = (id) => {
114
+ return document.getElementById(id)?.getElementsByClassName("canvas")[0];
115
+ };
116
+
117
+ const setPytamaroCanvasTarget = (id, enabled) => {
118
+ if (enabled) {
119
+ pytamaroCanvasTargets.add(id);
120
+ } else {
121
+ pytamaroCanvasTargets.delete(id);
122
+ }
123
+ };
124
+
125
+ const renderPytamaroDataUri = (id, container, dataUri) => {
126
+ if (id && pytamaroCanvasTargets.has(id)) {
127
+ const canvas = getCanvas(id);
128
+ const context = canvas?.getContext?.("2d");
129
+ if (canvas && context) {
130
+ const img = new Image();
131
+ img.onload = () => {
132
+ const width = Math.max(1, img.naturalWidth || img.width);
133
+ const height = Math.max(1, img.naturalHeight || img.height);
134
+ canvas.width = width;
135
+ canvas.height = height;
136
+ context.clearRect(0, 0, width, height);
137
+ context.drawImage(img, 0, 0, width, height);
138
+ };
139
+ img.onerror = () => {
140
+ appendOutputErrorLine(id, "Failed to render pytamaro graphic.");
141
+ };
142
+ img.src = dataUri;
143
+ return;
144
+ }
145
+ }
146
+
147
+ const img = document.createElement("img");
148
+ img.src = dataUri;
149
+ img.style.maxWidth = "100%";
150
+ img.style.display = "block";
151
+ container.appendChild(img);
152
+ };
153
+
103
154
  /**
104
155
  * Renders a message that may contain pytamaro data URI image markers into
105
156
  * the given container, creating <img> elements for each embedded image.
106
157
  */
107
- const renderOutputSegments = (container, message) => {
158
+ const renderOutputSegments = (container, message, id = null) => {
108
159
  let remaining = String(message);
109
160
  while (remaining.length > 0) {
110
161
  const beginIdx = remaining.indexOf(PYTAMARO_URI_BEGIN);
@@ -122,15 +173,15 @@ hyperbook.python = (function () {
122
173
  break;
123
174
  }
124
175
  const dataUri = afterBegin.slice(0, endIdx);
125
- const img = document.createElement("img");
126
- img.src = dataUri;
127
- img.style.maxWidth = "100%";
128
- img.style.display = "block";
129
- container.appendChild(img);
176
+ renderPytamaroDataUri(id, container, dataUri);
130
177
  remaining = afterBegin.slice(endIdx + PYTAMARO_URI_END.length);
131
178
  }
132
179
  };
133
180
 
181
+ const scriptLooksLikeTurtle = (script) => {
182
+ return /\bfrom\s+turtle\s+import\b|\bimport\s+turtle\b/.test(String(script || ""));
183
+ };
184
+
134
185
  const getTrailingPrefixLength = (text, marker) => {
135
186
  const max = Math.min(text.length, marker.length - 1);
136
187
  for (let len = max; len > 0; len -= 1) {
@@ -189,11 +240,7 @@ hyperbook.python = (function () {
189
240
  }
190
241
 
191
242
  const dataUri = afterBegin.slice(0, endIdx);
192
- const img = document.createElement("img");
193
- img.src = dataUri;
194
- img.style.maxWidth = "100%";
195
- img.style.display = "block";
196
- output.appendChild(img);
243
+ renderPytamaroDataUri(id, output, dataUri);
197
244
  combined = afterBegin.slice(endIdx + PYTAMARO_URI_END.length);
198
245
  }
199
246
  };
@@ -207,7 +254,7 @@ hyperbook.python = (function () {
207
254
  output.appendChild(line);
208
255
  };
209
256
 
210
- const appendOutput = (output, message, isError = false) => {
257
+ const appendOutput = (output, message, isError = false, id = null) => {
211
258
  if (!output || message === undefined || message === null) return;
212
259
  if (isError) {
213
260
  const line = document.createElement("span");
@@ -218,7 +265,7 @@ hyperbook.python = (function () {
218
265
  }
219
266
  const msg = String(message);
220
267
  if (msg.includes(PYTAMARO_URI_BEGIN)) {
221
- renderOutputSegments(output, msg);
268
+ renderOutputSegments(output, msg, id);
222
269
  return;
223
270
  }
224
271
  output.appendChild(document.createTextNode(msg));
@@ -226,6 +273,7 @@ hyperbook.python = (function () {
226
273
 
227
274
  const clearPytamaroStdoutCarry = (id) => {
228
275
  pytamaroStdoutCarry.delete(id);
276
+ pytamaroCanvasTargets.delete(id);
229
277
  };
230
278
 
231
279
  const updateFullscreenButtonState = (elem, button) => {
@@ -268,6 +316,954 @@ hyperbook.python = (function () {
268
316
  context?.clearRect(0, 0, canvas.width, canvas.height);
269
317
  };
270
318
 
319
+ const createTurtleJsFFI = (id) => {
320
+ const DEFAULT_LINE_WIDTH = 1;
321
+ const DEFAULT_FONT_SIZE = 8;
322
+ const TK_TEXT_X_OFFSET = -1;
323
+ const DEFAULT_TURTLE_SCREEN_WIDTH = 400;
324
+ const TURTLE_SIZE = 20;
325
+
326
+ let pyodide = null;
327
+ let canvas = null;
328
+ let context = null;
329
+ let cssWidth = 0;
330
+ let cssHeight = 0;
331
+ let dpr = 1;
332
+ let active = false;
333
+
334
+ let x = 0;
335
+ let y = 0;
336
+ let heading = 0;
337
+ let renderedX = 0;
338
+ let renderedY = 0;
339
+ let renderedHeading = 0;
340
+ let penDown = true;
341
+ let turtleVisible = true;
342
+ let renderedTurtleVisible = true;
343
+ let strokeColor = "#000000";
344
+ let fillColor = "#000000";
345
+ let backgroundColor = "#ffffff";
346
+ let backgroundImage = null;
347
+ let fontSize = DEFAULT_FONT_SIZE;
348
+ let currentFontFamily = "Arial";
349
+ let currentFontStyle = "normal";
350
+ let colorMode = 1.0;
351
+ let delayMs = 80;
352
+ let turtleSpeed = 3;
353
+ let screenWidth = null;
354
+ let screenHeight = null;
355
+ let filling = false;
356
+ let fillPath = null;
357
+ let currentPath = null;
358
+ /** @type {Array<any>} */
359
+ let paths = [];
360
+ let operationQueue = [];
361
+ let queueGeneration = 0;
362
+ let queueRunning = false;
363
+ const textMeasureCanvas = document.createElement("canvas");
364
+ const textMeasureContext = textMeasureCanvas.getContext("2d");
365
+
366
+ const normalizeAngle = (angle) => {
367
+ const value = Number(angle) || 0;
368
+ return ((value % 360) + 360) % 360;
369
+ };
370
+
371
+ const toRadians = (angle) => (Number(angle) * Math.PI) / 180;
372
+ const toCanvasX = (value) => cssWidth / 2 + value;
373
+ const toCanvasY = (value) => cssHeight / 2 - value;
374
+ const toPlainNumber = (value, fallback = Number.NaN) => {
375
+ if (value === null || value === undefined) return fallback;
376
+ if (typeof value === "number") return Number.isFinite(value) ? value : fallback;
377
+ if (typeof value === "bigint") return Number(value);
378
+ if (typeof value.toJs === "function") {
379
+ return toPlainNumber(value.toJs({ pyproxies: [] }), fallback);
380
+ }
381
+ const numeric = Number(value);
382
+ return Number.isFinite(numeric) ? numeric : fallback;
383
+ };
384
+ const fontSizeToCanvasUnits = (value) => {
385
+ const numeric = toPlainNumber(value, Number.NaN);
386
+ if (!Number.isFinite(numeric) || numeric === 0) {
387
+ const fallback = DEFAULT_FONT_SIZE;
388
+ return { size: `${fallback}pt`, pxApprox: (fallback * 96) / 72 };
389
+ }
390
+ if (numeric < 0) {
391
+ return { size: `${Math.abs(numeric)}px`, pxApprox: Math.abs(numeric) };
392
+ }
393
+ return { size: `${numeric}pt`, pxApprox: (numeric * 96) / 72 };
394
+ };
395
+ const toPlainString = (value, fallback = "") => {
396
+ if (value === null || value === undefined) return fallback;
397
+ if (typeof value === "string") return value;
398
+ if (typeof value.toJs === "function") {
399
+ return toPlainString(value.toJs({ pyproxies: [] }), fallback);
400
+ }
401
+ return String(value);
402
+ };
403
+ const toSequence = (value) => {
404
+ if (value === null || value === undefined) return null;
405
+ if (Array.isArray(value)) return value;
406
+ if (typeof value === "string") return [value];
407
+ if (typeof value.toJs === "function") {
408
+ return toSequence(value.toJs({ pyproxies: [] }));
409
+ }
410
+ if (typeof value[Symbol.iterator] === "function") {
411
+ try {
412
+ return Array.from(value);
413
+ } catch {}
414
+ }
415
+ if (typeof value === "object" && "length" in value) {
416
+ try {
417
+ return Array.from(value);
418
+ } catch {}
419
+ }
420
+ return null;
421
+ };
422
+ const toPlainObject = (value) => {
423
+ if (value === null || value === undefined) return null;
424
+ if (typeof value.toJs === "function") {
425
+ try {
426
+ return toPlainObject(value.toJs({ pyproxies: [], dict_converter: Object.fromEntries }));
427
+ } catch {
428
+ return toPlainObject(value.toJs({ pyproxies: [] }));
429
+ }
430
+ }
431
+ if (typeof value !== "object" || Array.isArray(value)) return null;
432
+ return value;
433
+ };
434
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
435
+ const isWriteKwargsObject = (value) => {
436
+ const obj = toPlainObject(value);
437
+ if (!obj) return false;
438
+ return (
439
+ hasOwn(obj, "arg") ||
440
+ hasOwn(obj, "text") ||
441
+ hasOwn(obj, "move") ||
442
+ hasOwn(obj, "align") ||
443
+ hasOwn(obj, "font")
444
+ );
445
+ };
446
+ const normalizeFontStyle = (value) => {
447
+ const style = toPlainString(value, "normal").trim().toLowerCase();
448
+ if (!style || style === "normal") return "";
449
+ const parts = style.split(/\s+/).filter(Boolean);
450
+ const hasItalic = parts.includes("italic");
451
+ const hasBold = parts.includes("bold");
452
+ if (hasItalic && hasBold) return "italic bold";
453
+ if (hasBold) return "bold";
454
+ if (hasItalic) return "italic";
455
+ return style;
456
+ };
457
+ const normalizeTextAlign = (value) => {
458
+ const align = toPlainString(value, "left").trim().toLowerCase();
459
+ if (align === "center" || align === "right") return align;
460
+ return "left";
461
+ };
462
+ const buildCanvasFont = (family, size, style) => {
463
+ const { size: canvasSize } = fontSizeToCanvasUnits(size);
464
+ const stylePart = normalizeFontStyle(style);
465
+ return `${stylePart ? `${stylePart} ` : ""}${canvasSize} ${toPlainString(family, "Arial")}`;
466
+ };
467
+ const measureTurtleText = (text, family, size, style) => {
468
+ const font = buildCanvasFont(family, size, style);
469
+ const { pxApprox } = fontSizeToCanvasUnits(size);
470
+ const ctx = textMeasureContext || context;
471
+ if (!ctx) {
472
+ return {
473
+ font,
474
+ width: String(text || "").length * pxApprox * 0.6,
475
+ descent: pxApprox * 0.2,
476
+ };
477
+ }
478
+ ctx.font = font;
479
+ ctx.textAlign = "left";
480
+ const metrics = ctx.measureText(String(text || ""));
481
+ return {
482
+ font,
483
+ width: metrics.width,
484
+ left: metrics.actualBoundingBoxLeft || 0,
485
+ right: metrics.actualBoundingBoxRight || metrics.width,
486
+ ascent:
487
+ metrics.fontBoundingBoxAscent ||
488
+ metrics.actualBoundingBoxAscent ||
489
+ pxApprox * 0.8,
490
+ descent:
491
+ metrics.fontBoundingBoxDescent ||
492
+ metrics.actualBoundingBoxDescent ||
493
+ pxApprox * 0.2,
494
+ };
495
+ };
496
+ const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
497
+ const clearQueue = () => {
498
+ queueGeneration += 1;
499
+ operationQueue = [];
500
+ queueRunning = false;
501
+ };
502
+ const enqueueOperation = (fn) => {
503
+ if (delayMs <= 0) {
504
+ fn();
505
+ return;
506
+ }
507
+ const generation = queueGeneration;
508
+ operationQueue.push({ fn, generation });
509
+ if (queueRunning) return;
510
+ queueRunning = true;
511
+ void (async () => {
512
+ try {
513
+ while (operationQueue.length > 0) {
514
+ const operation = operationQueue.shift();
515
+ if (!operation || operation.generation !== queueGeneration) {
516
+ continue;
517
+ }
518
+ operation.fn();
519
+ if (delayMs > 0) {
520
+ await sleep(delayMs);
521
+ }
522
+ }
523
+ } finally {
524
+ queueRunning = false;
525
+ }
526
+ })();
527
+ };
528
+
529
+ const ensureContext = () => {
530
+ if (!canvas) {
531
+ canvas = getCanvas(id);
532
+ }
533
+ if (!canvas) return false;
534
+ if (!context) {
535
+ context = canvas.getContext("2d");
536
+ }
537
+ return !!context;
538
+ };
539
+
540
+ const setupCanvasResolution = () => {
541
+ if (!ensureContext()) return false;
542
+ dpr = window.devicePixelRatio || 1;
543
+ const wrapperRect = canvas.parentElement?.getBoundingClientRect?.();
544
+ const panelWidth =
545
+ wrapperRect?.width || canvas.parentElement?.clientWidth || canvas.clientWidth || canvas.width || 800;
546
+ const panelHeight =
547
+ wrapperRect?.height || canvas.parentElement?.clientHeight || canvas.clientHeight || canvas.height || 400;
548
+ const width = Math.max(
549
+ 1,
550
+ Math.floor(screenWidth !== null ? screenWidth : panelWidth),
551
+ );
552
+ const height = Math.max(
553
+ 1,
554
+ Math.floor(screenHeight !== null ? screenHeight : panelHeight),
555
+ );
556
+ cssWidth = width;
557
+ cssHeight = height;
558
+ canvas.style.width = `${width}px`;
559
+ canvas.style.height = `${height}px`;
560
+ canvas.width = Math.max(1, Math.floor(cssWidth * dpr));
561
+ canvas.height = Math.max(1, Math.floor(cssHeight * dpr));
562
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
563
+ return true;
564
+ };
565
+
566
+ const makePath = (overrides = {}) => ({
567
+ down: penDown,
568
+ stroke: strokeColor,
569
+ lineWidth: DEFAULT_LINE_WIDTH,
570
+ fontsize: fontSize,
571
+ fontfamily: currentFontFamily,
572
+ fontstyle: currentFontStyle,
573
+ fill: false,
574
+ fillstyle: fillColor,
575
+ points: [{ x, y }],
576
+ ...overrides,
577
+ });
578
+
579
+ const beginCurrentPath = () => {
580
+ currentPath = makePath();
581
+ paths.push(currentPath);
582
+ return currentPath;
583
+ };
584
+
585
+ const commitStyleToNewPath = () => {
586
+ currentPath = makePath({
587
+ down: penDown,
588
+ lineWidth: currentPath?.lineWidth ?? DEFAULT_LINE_WIDTH,
589
+ });
590
+ paths.push(currentPath);
591
+ return currentPath;
592
+ };
593
+
594
+ const ensurePath = () => {
595
+ if (!currentPath) {
596
+ beginCurrentPath();
597
+ }
598
+ return currentPath;
599
+ };
600
+
601
+ const drawBackground = () => {
602
+ context.save();
603
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
604
+ context.fillStyle = backgroundColor;
605
+ context.fillRect(0, 0, cssWidth, cssHeight);
606
+ if (backgroundImage?.complete && backgroundImage.naturalWidth > 0) {
607
+ context.drawImage(backgroundImage, 0, 0, cssWidth, cssHeight);
608
+ }
609
+ context.restore();
610
+ };
611
+
612
+ const drawPathSegment = (path) => {
613
+ if (!path?.points?.length) return;
614
+
615
+ if (path.fill) {
616
+ context.beginPath();
617
+ path.points.forEach((point, index) => {
618
+ const px = toCanvasX(point.x);
619
+ const py = toCanvasY(point.y);
620
+ if (index === 0) {
621
+ context.moveTo(px, py);
622
+ } else {
623
+ context.lineTo(px, py);
624
+ }
625
+ });
626
+ context.closePath();
627
+ context.fillStyle = path.fillstyle;
628
+ context.fill();
629
+ return;
630
+ }
631
+
632
+ let startedLine = false;
633
+ context.beginPath();
634
+ for (const point of path.points) {
635
+ const px = toCanvasX(point.x);
636
+ const py = toCanvasY(point.y);
637
+ if (point.text !== undefined) {
638
+ const family = point.fontfamily || path.fontfamily || "Arial";
639
+ const style = point.fontstyle || path.fontstyle || "normal";
640
+ const size = point.fontsize ?? path.fontsize ?? DEFAULT_FONT_SIZE;
641
+ const text = String(point.text);
642
+ const metrics = measureTurtleText(text, family, size, style);
643
+ const align = normalizeTextAlign(point.align);
644
+ const left = metrics.left || 0;
645
+ const right = metrics.right || metrics.width || 0;
646
+ const descent = metrics.descent || 0;
647
+ const anchorX = px + TK_TEXT_X_OFFSET;
648
+ let drawX = anchorX + left;
649
+ if (align === "center") {
650
+ drawX = anchorX + (left - right) / 2;
651
+ } else if (align === "right") {
652
+ drawX = anchorX - right;
653
+ }
654
+ const drawY = py - descent;
655
+ context.font = metrics.font;
656
+ context.fillStyle = point.color || path.stroke;
657
+ context.textAlign = "left";
658
+ context.textBaseline = "alphabetic";
659
+ context.fillText(text, drawX, drawY);
660
+ continue;
661
+ }
662
+ if (point.dotRadius) {
663
+ context.beginPath();
664
+ context.arc(px, py, point.dotRadius, 0, 2 * Math.PI);
665
+ context.fillStyle = point.color || path.fillstyle || path.stroke;
666
+ context.fill();
667
+ context.beginPath();
668
+ startedLine = false;
669
+ continue;
670
+ }
671
+ if (!startedLine || point.move || !path.down) {
672
+ context.moveTo(px, py);
673
+ startedLine = true;
674
+ continue;
675
+ }
676
+ context.lineTo(px, py);
677
+ }
678
+
679
+ if (path.down) {
680
+ context.strokeStyle = path.stroke;
681
+ context.lineWidth = path.lineWidth || DEFAULT_LINE_WIDTH;
682
+ context.stroke();
683
+ }
684
+ };
685
+
686
+ const drawTurtle = () => {
687
+ if (!renderedTurtleVisible) return;
688
+ context.save();
689
+ context.translate(toCanvasX(renderedX), toCanvasY(renderedY));
690
+ context.rotate(-toRadians(renderedHeading));
691
+ context.beginPath();
692
+ context.moveTo(TURTLE_SIZE / 2, 0);
693
+ context.lineTo(-TURTLE_SIZE / 2, TURTLE_SIZE / 3);
694
+ context.lineTo(-TURTLE_SIZE / 4, 0);
695
+ context.lineTo(-TURTLE_SIZE / 2, -TURTLE_SIZE / 3);
696
+ context.closePath();
697
+ context.fillStyle = "#2f9e44";
698
+ context.strokeStyle = "#1b5e20";
699
+ context.lineWidth = 1;
700
+ context.fill();
701
+ context.stroke();
702
+ context.restore();
703
+ };
704
+
705
+ const draw = () => {
706
+ if (!active) return;
707
+ if (!setupCanvasResolution()) return;
708
+ drawBackground();
709
+ paths.forEach(drawPathSegment);
710
+ drawTurtle();
711
+ };
712
+
713
+ const resetState = () => {
714
+ paths = [];
715
+ currentPath = null;
716
+ fillPath = null;
717
+ x = 0;
718
+ y = 0;
719
+ heading = 0;
720
+ renderedX = 0;
721
+ renderedY = 0;
722
+ renderedHeading = 0;
723
+ penDown = true;
724
+ turtleVisible = true;
725
+ renderedTurtleVisible = true;
726
+ strokeColor = "#000000";
727
+ fillColor = "#000000";
728
+ backgroundColor = "#ffffff";
729
+ backgroundImage = null;
730
+ fontSize = DEFAULT_FONT_SIZE;
731
+ currentFontFamily = "Arial";
732
+ currentFontStyle = "normal";
733
+ colorMode = 1.0;
734
+ screenWidth = null;
735
+ screenHeight = null;
736
+ filling = false;
737
+ active = true;
738
+ clearQueue();
739
+ beginCurrentPath();
740
+ draw();
741
+ };
742
+
743
+ const deactivate = () => {
744
+ active = false;
745
+ clearQueue();
746
+ paths = [];
747
+ currentPath = null;
748
+ fillPath = null;
749
+ turtleVisible = false;
750
+ renderedTurtleVisible = false;
751
+ };
752
+
753
+ const bindCanvas = (nextCanvas) => {
754
+ canvas = nextCanvas || getCanvas(id);
755
+ context = canvas?.getContext?.("2d") || null;
756
+ if (canvas) {
757
+ canvas.tabIndex = 0;
758
+ }
759
+ if (!currentPath) {
760
+ beginCurrentPath();
761
+ }
762
+ draw();
763
+ };
764
+
765
+ const toColorString = (value) => {
766
+ if (typeof value === "string") {
767
+ return value;
768
+ }
769
+ if (value && typeof value.toJs === "function") {
770
+ return toColorString(value.toJs({ pyproxies: [] }));
771
+ }
772
+ if (Array.isArray(value) || (value && typeof value === "object" && "length" in value)) {
773
+ const parts = Array.from(value).slice(0, 3).map((part) => Number(part));
774
+ if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
775
+ throw new Error(`bad color sequence: ${String(value)}`);
776
+ }
777
+ if (colorMode === 1.0) {
778
+ if (parts.some((part) => part < 0 || part > 1)) {
779
+ throw new Error(`bad color sequence: ${String(value)}`);
780
+ }
781
+ const [r, g, b] = parts.map((part) => Math.round(part * 255));
782
+ return `rgb(${r}, ${g}, ${b})`;
783
+ }
784
+ if (colorMode === 255) {
785
+ if (parts.some((part) => part < 0 || part > 255)) {
786
+ throw new Error(`bad color sequence: ${String(value)}`);
787
+ }
788
+ const [r, g, b] = parts.map((part) => Math.round(part));
789
+ return `rgb(${r}, ${g}, ${b})`;
790
+ }
791
+ throw new Error(`bad color sequence: ${String(value)}`);
792
+ }
793
+ return "#000000";
794
+ };
795
+
796
+ const getPenState = () => ({
797
+ pendown: penDown,
798
+ pencolor: strokeColor,
799
+ fillcolor: fillColor,
800
+ pensize: currentPath?.lineWidth ?? DEFAULT_LINE_WIDTH,
801
+ speed: turtleSpeed,
802
+ shown: turtleVisible,
803
+ });
804
+
805
+ const forward = (distance) => {
806
+ if (!ensureContext()) return;
807
+ const path = ensurePath();
808
+ const fill = filling ? fillPath : null;
809
+ const length = Number(distance) || 0;
810
+ const nextX = x + length * Math.cos(toRadians(heading));
811
+ const nextY = y + length * Math.sin(toRadians(heading));
812
+ x = nextX;
813
+ y = nextY;
814
+ const point = { x, y, move: !penDown };
815
+ enqueueOperation(() => {
816
+ renderedX = point.x;
817
+ renderedY = point.y;
818
+ path.points.push(point);
819
+ if (fill) {
820
+ fill.points.push({ x, y });
821
+ }
822
+ draw();
823
+ });
824
+ };
825
+
826
+ const backward = (distance) => forward(-Number(distance || 0));
827
+ const right = (angle) => {
828
+ heading = normalizeAngle(heading - Number(angle || 0));
829
+ const nextHeading = heading;
830
+ enqueueOperation(() => {
831
+ renderedHeading = nextHeading;
832
+ draw();
833
+ });
834
+ };
835
+ const left = (angle) => {
836
+ heading = normalizeAngle(heading + Number(angle || 0));
837
+ const nextHeading = heading;
838
+ enqueueOperation(() => {
839
+ renderedHeading = nextHeading;
840
+ draw();
841
+ });
842
+ };
843
+ const penup = () => {
844
+ penDown = false;
845
+ commitStyleToNewPath();
846
+ draw();
847
+ };
848
+ const pendownFn = () => {
849
+ penDown = true;
850
+ commitStyleToNewPath();
851
+ draw();
852
+ };
853
+ const goto_ = (a, b) => {
854
+ if (!ensureContext()) return;
855
+ let nextX = Number(a);
856
+ let nextY = Number(b);
857
+ if (b === undefined && (Array.isArray(a) || (a && typeof a === "object"))) {
858
+ const source =
859
+ a && typeof a.toJs === "function" ? a.toJs({ pyproxies: [] }) : a;
860
+ nextX = Number(source?.[0] ?? source?.x ?? 0);
861
+ nextY = Number(source?.[1] ?? source?.y ?? 0);
862
+ }
863
+ x = Number.isFinite(nextX) ? nextX : 0;
864
+ y = Number.isFinite(nextY) ? nextY : 0;
865
+ const path = ensurePath();
866
+ const fill = filling ? fillPath : null;
867
+ const point = { x, y, move: !penDown };
868
+ enqueueOperation(() => {
869
+ renderedX = point.x;
870
+ renderedY = point.y;
871
+ path.points.push(point);
872
+ if (fill) {
873
+ fill.points.push({ x, y });
874
+ }
875
+ draw();
876
+ });
877
+ };
878
+ const setx = (value) => goto_(Number(value), y);
879
+ const sety = (value) => goto_(x, Number(value));
880
+ const position = () => [x, y];
881
+ const xcor = () => x;
882
+ const ycor = () => y;
883
+ const heading_ = () => heading;
884
+ const setheading = (angle) => {
885
+ heading = normalizeAngle(angle);
886
+ const nextHeading = heading;
887
+ enqueueOperation(() => {
888
+ renderedHeading = nextHeading;
889
+ draw();
890
+ });
891
+ };
892
+ const home = () => {
893
+ goto_(0, 0);
894
+ setheading(0);
895
+ };
896
+ const towards = (tx, ty) => {
897
+ const dx = Number(tx) - x;
898
+ const dy = Number(ty) - y;
899
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return 0;
900
+ return normalizeAngle((Math.atan2(dy, dx) * 180) / Math.PI);
901
+ };
902
+ const speed = (value = 0) => {
903
+ const numeric = Number(value);
904
+ turtleSpeed = Number.isFinite(numeric) ? numeric : 0;
905
+ delayMs = turtleSpeed <= 0 ? 0 : Math.max(0, Math.round(300 / turtleSpeed));
906
+ return delayMs;
907
+ };
908
+ const colormode = (mode = undefined) => {
909
+ if (mode === undefined) return colorMode;
910
+ const numeric = Number(mode);
911
+ if (numeric !== 1 && numeric !== 255) {
912
+ throw new Error("colormode must be 1.0 or 255");
913
+ }
914
+ colorMode = numeric;
915
+ return colorMode;
916
+ };
917
+ const screensize = (canvwidth = undefined, canvheight = undefined, bg = undefined) => {
918
+ if (canvwidth === undefined && canvheight === undefined && bg === undefined) {
919
+ return [screenWidth || cssWidth || 0, screenHeight || cssHeight || 0];
920
+ }
921
+ if (canvwidth !== undefined && canvwidth !== null) {
922
+ const width = Math.floor(Number(canvwidth));
923
+ if (Number.isFinite(width) && width > 0) {
924
+ screenWidth = width;
925
+ }
926
+ }
927
+ if (canvheight !== undefined && canvheight !== null) {
928
+ const height = Math.floor(Number(canvheight));
929
+ if (Number.isFinite(height) && height > 0) {
930
+ screenHeight = height;
931
+ }
932
+ }
933
+ if (bg !== undefined && bg !== null) {
934
+ backgroundColor = toColorString(bg);
935
+ }
936
+ draw();
937
+ if (canvas?.parentElement) {
938
+ const wrapper = canvas.parentElement;
939
+ wrapper.scrollLeft = Math.max(0, (cssWidth - wrapper.clientWidth) / 2);
940
+ wrapper.scrollTop = Math.max(0, (cssHeight - wrapper.clientHeight) / 2);
941
+ }
942
+ return [screenWidth || cssWidth || 0, screenHeight || cssHeight || 0];
943
+ };
944
+ const color = (...args) => {
945
+ const stroke = toColorString(args[0]);
946
+ const fill = args.length > 1 ? toColorString(args[1]) : stroke;
947
+ strokeColor = stroke;
948
+ fillColor = fill;
949
+ commitStyleToNewPath();
950
+ if (filling && fillPath) {
951
+ fillPath.fillstyle = fillColor;
952
+ }
953
+ draw();
954
+ };
955
+ const pencolor = (value) => {
956
+ strokeColor = toColorString(value);
957
+ commitStyleToNewPath();
958
+ draw();
959
+ };
960
+ const fillcolor = (value) => {
961
+ fillColor = toColorString(value);
962
+ if (filling && fillPath) {
963
+ fillPath.fillstyle = fillColor;
964
+ }
965
+ commitStyleToNewPath();
966
+ draw();
967
+ };
968
+ const pensize = (value) => {
969
+ ensurePath().lineWidth = Math.max(1, Number(value) || DEFAULT_LINE_WIDTH);
970
+ commitStyleToNewPath();
971
+ draw();
972
+ };
973
+ const width = (value) => pensize(value);
974
+ const begin_fill = () => {
975
+ if (filling) return;
976
+ filling = true;
977
+ fillPath = makePath({
978
+ down: false,
979
+ fill: true,
980
+ stroke: "transparent",
981
+ lineWidth: 1,
982
+ fillstyle: fillColor,
983
+ });
984
+ paths.push(fillPath);
985
+ draw();
986
+ };
987
+ const end_fill = () => {
988
+ filling = false;
989
+ fillPath = null;
990
+ commitStyleToNewPath();
991
+ draw();
992
+ };
993
+ const dot = (size = 5, colorValue = null) => {
994
+ const segment = makePath({
995
+ down: false,
996
+ fill: false,
997
+ stroke: colorValue ? toColorString(colorValue) : strokeColor,
998
+ fillstyle: colorValue ? toColorString(colorValue) : strokeColor,
999
+ points: [
1000
+ {
1001
+ x,
1002
+ y,
1003
+ dotRadius: Math.max(0.5, Number(size) || 5) / 2,
1004
+ color: colorValue ? toColorString(colorValue) : strokeColor,
1005
+ },
1006
+ ],
1007
+ });
1008
+ enqueueOperation(() => {
1009
+ paths.push(segment);
1010
+ draw();
1011
+ });
1012
+ };
1013
+ const circle = (radius, steps = 120) => {
1014
+ const numericRadius = Number(radius) || 0;
1015
+ const stepCount = Math.max(8, Number(steps) | 0);
1016
+ const circumference = 2 * Math.PI * Math.abs(numericRadius);
1017
+ const stepLength = circumference / stepCount;
1018
+ const stepTurn = (360 / stepCount) * (numericRadius >= 0 ? 1 : -1);
1019
+ for (let index = 0; index < stepCount; index += 1) {
1020
+ forward(stepLength);
1021
+ left(stepTurn);
1022
+ }
1023
+ };
1024
+ const write = (text, move = false, align = "left", font = null) => {
1025
+ let writeText = text;
1026
+ let writeMove = move;
1027
+ let writeAlign = align;
1028
+ let writeFont = font;
1029
+
1030
+ if (isWriteKwargsObject(writeAlign)) {
1031
+ const kwargs = toPlainObject(writeAlign);
1032
+ if (kwargs) {
1033
+ if (hasOwn(kwargs, "arg")) writeText = kwargs.arg;
1034
+ else if (hasOwn(kwargs, "text")) writeText = kwargs.text;
1035
+ if (hasOwn(kwargs, "move")) writeMove = kwargs.move;
1036
+ if (hasOwn(kwargs, "align")) writeAlign = kwargs.align;
1037
+ if (hasOwn(kwargs, "font")) writeFont = kwargs.font;
1038
+ }
1039
+ }
1040
+ if (isWriteKwargsObject(writeFont)) {
1041
+ const kwargs = toPlainObject(writeFont);
1042
+ if (kwargs) {
1043
+ if (hasOwn(kwargs, "arg")) writeText = kwargs.arg;
1044
+ else if (hasOwn(kwargs, "text")) writeText = kwargs.text;
1045
+ if (hasOwn(kwargs, "move")) writeMove = kwargs.move;
1046
+ if (hasOwn(kwargs, "align")) writeAlign = kwargs.align;
1047
+ if (hasOwn(kwargs, "font")) writeFont = kwargs.font;
1048
+ }
1049
+ writeFont = null;
1050
+ }
1051
+
1052
+ let family = currentFontFamily;
1053
+ let size = fontSize;
1054
+ let style = currentFontStyle;
1055
+ try {
1056
+ const source = toSequence(writeFont);
1057
+ if (source?.length) {
1058
+ if (source[0]) family = toPlainString(source[0], family);
1059
+ if (source[1] !== undefined && source[1] !== null) {
1060
+ size = toPlainNumber(source[1], size);
1061
+ }
1062
+ if (source[2]) style = toPlainString(source[2], style);
1063
+ }
1064
+ } catch {}
1065
+ const normalizedAlign = normalizeTextAlign(writeAlign);
1066
+ const segment = makePath({
1067
+ down: false,
1068
+ fill: false,
1069
+ stroke: strokeColor,
1070
+ points: [
1071
+ {
1072
+ x,
1073
+ y,
1074
+ text: String(writeText),
1075
+ align: normalizedAlign,
1076
+ fontsize: size,
1077
+ fontfamily: family,
1078
+ fontstyle: style,
1079
+ color: strokeColor,
1080
+ },
1081
+ ],
1082
+ });
1083
+ if (Boolean(writeMove)) {
1084
+ const metrics = measureTurtleText(String(writeText), family, size, style);
1085
+ const left = metrics.left || 0;
1086
+ const right = metrics.right || metrics.width || 0;
1087
+ const align = normalizeTextAlign(normalizedAlign);
1088
+ const textWidth = left + right;
1089
+ if (align === "left") {
1090
+ x += TK_TEXT_X_OFFSET + textWidth;
1091
+ } else if (align === "center") {
1092
+ x += TK_TEXT_X_OFFSET + right;
1093
+ } else {
1094
+ x += TK_TEXT_X_OFFSET;
1095
+ }
1096
+ commitStyleToNewPath();
1097
+ }
1098
+ enqueueOperation(() => {
1099
+ paths.push(segment);
1100
+ draw();
1101
+ });
1102
+ };
1103
+ const setfontsize = (value) => {
1104
+ const nextSize = toPlainNumber(value, DEFAULT_FONT_SIZE);
1105
+ fontSize = Math.max(1, Number.isFinite(nextSize) ? nextSize : DEFAULT_FONT_SIZE);
1106
+ commitStyleToNewPath();
1107
+ draw();
1108
+ };
1109
+ const bgcolor = (value) => {
1110
+ backgroundColor = toColorString(value);
1111
+ draw();
1112
+ };
1113
+ const bgpic = (filename = "") => {
1114
+ const name = String(filename || "").trim();
1115
+ if (!name) {
1116
+ backgroundImage = null;
1117
+ draw();
1118
+ return;
1119
+ }
1120
+ const fs = pyodide?.FS;
1121
+ if (!fs) return;
1122
+ const candidates = [
1123
+ name,
1124
+ `/home/pyodide/${name}`,
1125
+ `/home/pyodide/files/${name}`,
1126
+ `/home/pyodide/uploads/${name}`,
1127
+ ];
1128
+ for (const candidate of candidates) {
1129
+ try {
1130
+ const data = fs.readFile(candidate);
1131
+ const blob = new Blob([data], { type: "image/*" });
1132
+ const url = URL.createObjectURL(blob);
1133
+ const image = new Image();
1134
+ image.onload = () => {
1135
+ backgroundImage = image;
1136
+ draw();
1137
+ URL.revokeObjectURL(url);
1138
+ };
1139
+ image.onerror = () => URL.revokeObjectURL(url);
1140
+ image.src = url;
1141
+ return;
1142
+ } catch {}
1143
+ }
1144
+ };
1145
+ const clear = () => {
1146
+ clearQueue();
1147
+ paths = [];
1148
+ currentPath = null;
1149
+ fillPath = null;
1150
+ beginCurrentPath();
1151
+ draw();
1152
+ };
1153
+ const reset = () => {
1154
+ resetState();
1155
+ };
1156
+ const showturtle = () => {
1157
+ turtleVisible = true;
1158
+ enqueueOperation(() => {
1159
+ renderedTurtleVisible = true;
1160
+ draw();
1161
+ });
1162
+ };
1163
+ const hideturtle = () => {
1164
+ turtleVisible = false;
1165
+ enqueueOperation(() => {
1166
+ renderedTurtleVisible = false;
1167
+ draw();
1168
+ });
1169
+ };
1170
+ const isvisible = () => turtleVisible;
1171
+ const pen = (options = undefined) => {
1172
+ if (options === undefined) {
1173
+ return getPenState();
1174
+ }
1175
+ const source =
1176
+ options && typeof options.toJs === "function"
1177
+ ? options.toJs({ pyproxies: [], dict_converter: Object.fromEntries })
1178
+ : options;
1179
+ if (!source || typeof source !== "object") return getPenState();
1180
+ if ("pendown" in source) {
1181
+ penDown = !!source.pendown;
1182
+ }
1183
+ if ("pencolor" in source) {
1184
+ strokeColor = toColorString(source.pencolor);
1185
+ }
1186
+ if ("fillcolor" in source) {
1187
+ fillColor = toColorString(source.fillcolor);
1188
+ }
1189
+ if ("pensize" in source) {
1190
+ ensurePath().lineWidth = Math.max(1, Number(source.pensize) || DEFAULT_LINE_WIDTH);
1191
+ }
1192
+ if ("shown" in source) {
1193
+ turtleVisible = !!source.shown;
1194
+ renderedTurtleVisible = turtleVisible;
1195
+ }
1196
+ commitStyleToNewPath();
1197
+ draw();
1198
+ return getPenState();
1199
+ };
1200
+
1201
+ const api = {
1202
+ __bindCanvas: bindCanvas,
1203
+ __deactivate: deactivate,
1204
+ __redraw: draw,
1205
+ __resetState: resetState,
1206
+ __setPyodide: (runtime) => {
1207
+ pyodide = runtime;
1208
+ },
1209
+ forward,
1210
+ fd: forward,
1211
+ backward,
1212
+ bk: backward,
1213
+ back: backward,
1214
+ right,
1215
+ rt: right,
1216
+ left,
1217
+ lt: left,
1218
+ goto: (...args) => goto_(...args),
1219
+ setpos: (...args) => goto_(...args),
1220
+ setposition: (...args) => goto_(...args),
1221
+ setx,
1222
+ sety,
1223
+ setheading,
1224
+ seth: setheading,
1225
+ home,
1226
+ circle,
1227
+ dot,
1228
+ position,
1229
+ pos: position,
1230
+ xcor,
1231
+ ycor,
1232
+ heading: heading_,
1233
+ towards,
1234
+ pendown: pendownFn,
1235
+ pd: pendownFn,
1236
+ down: pendownFn,
1237
+ penup,
1238
+ pu: penup,
1239
+ up: penup,
1240
+ pensize,
1241
+ width,
1242
+ pen,
1243
+ write,
1244
+ setfontsize,
1245
+ color,
1246
+ pencolor,
1247
+ fillcolor,
1248
+ begin_fill,
1249
+ end_fill,
1250
+ bgcolor,
1251
+ bgpic,
1252
+ reset,
1253
+ clear,
1254
+ speed,
1255
+ colormode,
1256
+ screensize,
1257
+ showturtle,
1258
+ st: showturtle,
1259
+ hideturtle,
1260
+ ht: hideturtle,
1261
+ isvisible,
1262
+ };
1263
+
1264
+ return api;
1265
+ };
1266
+
271
1267
  const createPytamaroJsFFI = () => {
272
1268
  const floatBuffer = new ArrayBuffer(4);
273
1269
  const floatView = new DataView(floatBuffer);
@@ -596,6 +1592,142 @@ hyperbook.python = (function () {
596
1592
  };
597
1593
  };
598
1594
 
1595
+ // Browser/Pyodide needs periodic yielding for top-level pygame loops.
1596
+ const hasExplicitMain = (code) => {
1597
+ const text = String(code || "");
1598
+ return (
1599
+ /^(\s*)(?:async\s+def|def)\s+main\s*\(/m.test(text) ||
1600
+ /__name__\s*==\s*["']__main__["']/.test(text)
1601
+ );
1602
+ };
1603
+
1604
+ const looksLikeTopLevelGameLoop = (code) => {
1605
+ const text = String(code || "");
1606
+ const hasPygame =
1607
+ /(?:^|\W)(?:import\s+pygame\b|from\s+pygame\b\s+import\b)/m.test(text);
1608
+ const hasSasPygame =
1609
+ /(?:^|\W)(?:import\s+sas_pygame\b|from\s+sas_pygame\b\s+import\b)/m.test(text);
1610
+ const hasAnyWhile = /^\s*while\s+.+:\s*$/m.test(text);
1611
+ if (!hasAnyWhile) return false;
1612
+ return (hasPygame || hasSasPygame) && hasAnyWhile;
1613
+ };
1614
+
1615
+ const indentBlock = (source, spaces) => {
1616
+ const pad = " ".repeat(spaces);
1617
+ return String(source || "")
1618
+ .split(/\r\n|\r|\n/)
1619
+ .map((line) => (line.length ? pad + line : line))
1620
+ .join("\n");
1621
+ };
1622
+
1623
+ const wrapTopLevelIntoAsyncMain = (userCode) => {
1624
+ const code = String(userCode || "").replace(/\r\n/g, "\n");
1625
+
1626
+ if (hasExplicitMain(code)) return code;
1627
+
1628
+ const lines = code.split("\n");
1629
+ const keep = [];
1630
+ const body = [];
1631
+
1632
+ let index = 0;
1633
+ while (index < lines.length) {
1634
+ const line = lines[index];
1635
+ if (/^\s*$/.test(line)) {
1636
+ keep.push(line);
1637
+ index += 1;
1638
+ continue;
1639
+ }
1640
+ if (/^\s*#/.test(line)) {
1641
+ keep.push(line);
1642
+ index += 1;
1643
+ continue;
1644
+ }
1645
+ if (/^\s*(?:from\s+\S+\s+import\b|import\s+\S+)/.test(line)) {
1646
+ keep.push(line);
1647
+ index += 1;
1648
+ continue;
1649
+ }
1650
+
1651
+ if (/^\s*(?:def|async\s+def|class)\s+/.test(line)) {
1652
+ keep.push(line);
1653
+ index += 1;
1654
+ while (index < lines.length) {
1655
+ const nextLine = lines[index];
1656
+ if (/^\s*$/.test(nextLine)) {
1657
+ keep.push(nextLine);
1658
+ index += 1;
1659
+ continue;
1660
+ }
1661
+ if (
1662
+ /^\S/.test(nextLine) &&
1663
+ !/^\s*#/.test(nextLine) &&
1664
+ !/^\s*(?:from\s+\S+\s+import\b|import\s+\S+)/.test(nextLine) &&
1665
+ !/^\s*(?:def|async\s+def|class)\s+/.test(nextLine)
1666
+ ) {
1667
+ break;
1668
+ }
1669
+ keep.push(nextLine);
1670
+ index += 1;
1671
+ }
1672
+ continue;
1673
+ }
1674
+ break;
1675
+ }
1676
+
1677
+ for (; index < lines.length; index += 1) {
1678
+ body.push(lines[index]);
1679
+ }
1680
+
1681
+ let bodyText = body.join("\n");
1682
+ bodyText = bodyText.replace(/^([ \t]*\n)+/, "");
1683
+ bodyText = bodyText.replace(/(\n[ \t]*)+$/, "");
1684
+ bodyText = bodyText.replace(/\t/g, " ");
1685
+ if (!bodyText.replace(/[\s\n]+/g, "").length) return code;
1686
+
1687
+ const injected = bodyText
1688
+ .replace(
1689
+ /^(\s*)(\w+\s*\.\s*tick\s*\([^\)]*\)\s*)$/gm,
1690
+ "$1$2\n$1await asyncio.sleep(0)",
1691
+ )
1692
+ .replace(
1693
+ /^(\s*)(pygame\s*\.\s*display\s*\.\s*flip\s*\(\s*\)\s*)$/gm,
1694
+ "$1$2\n$1await asyncio.sleep(0)",
1695
+ )
1696
+ .replace(
1697
+ /^([\t ]*)([A-Za-z_][\w]*(?:\s*\.\s*[A-Za-z_][\w]*)*\s*\.\s*step\s*\([^\)]*\)\s*)(#.*)?$/gm,
1698
+ "$1$2$3\n$1await asyncio.sleep(0)",
1699
+ );
1700
+
1701
+ const prelude = [
1702
+ "# --- auto-wrapped by IDE for browser pygame compatibility ---",
1703
+ "import asyncio",
1704
+ "import pygame",
1705
+ "",
1706
+ "async def main():",
1707
+ indentBlock(injected, 4),
1708
+ "",
1709
+ "await main()",
1710
+ "# --- end auto-wrapped ---",
1711
+ "",
1712
+ ].join("\n");
1713
+
1714
+ let keepText = keep.join("\n");
1715
+ if (keepText && !keepText.endsWith("\n")) keepText += "\n";
1716
+ if (keepText && !/\n\s*\n$/.test(keepText)) keepText += "\n";
1717
+
1718
+ return keepText + prelude;
1719
+ };
1720
+
1721
+ const maybeAutoWrapPygame = (code) => {
1722
+ try {
1723
+ const text = String(code || "");
1724
+ if (!looksLikeTopLevelGameLoop(text)) return text;
1725
+ return wrapTopLevelIntoAsyncMain(text);
1726
+ } catch {
1727
+ return String(code || "");
1728
+ }
1729
+ };
1730
+
599
1731
  const ensureMicropipPackages = async (id, pyodide, packages = []) => {
600
1732
  if (packages.length === 0) return;
601
1733
  if (!installedMicropipPackages.has(id)) {
@@ -623,10 +1755,20 @@ hyperbook.python = (function () {
623
1755
  const pyodide = await getRuntime(id);
624
1756
  const { canvas, ...globalsContext } = context;
625
1757
  const decoder = new TextDecoder("utf-8");
1758
+ const executableScript = maybeAutoWrapPygame(script);
626
1759
 
627
1760
  if (canvas) {
628
1761
  try {
629
1762
  resetCanvas(canvas);
1763
+ const usesTurtle = scriptLooksLikeTurtle(executableScript);
1764
+ if (usesTurtle) {
1765
+ turtleModules.get(id)?.__bindCanvas(canvas);
1766
+ turtleModules.get(id)?.__resetState();
1767
+ } else {
1768
+ turtleModules.get(id)?.__deactivate?.();
1769
+ canvas.style.width = "";
1770
+ canvas.style.height = "";
1771
+ }
630
1772
  pyodide.canvas.setCanvas2D(canvas);
631
1773
  } catch (error) {
632
1774
  appendOutputErrorLine(id, `Canvas setup failed: ${error.message}`);
@@ -658,14 +1800,14 @@ hyperbook.python = (function () {
658
1800
  });
659
1801
 
660
1802
  await ensureMicropipPackages(id, pyodide, packages);
661
- await pyodide.loadPackagesFromImports(script);
1803
+ await pyodide.loadPackagesFromImports(executableScript);
662
1804
  const dict = pyodide.globals.get("dict");
663
1805
  const globals = dict();
664
1806
  try {
665
1807
  for (const [key, value] of Object.entries(globalsContext)) {
666
1808
  globals.set(key, value);
667
1809
  }
668
- const results = await pyodide.runPythonAsync(script, {
1810
+ const results = await pyodide.runPythonAsync(executableScript, {
669
1811
  globals,
670
1812
  locals: globals,
671
1813
  filename,
@@ -1122,6 +2264,7 @@ if _pg:
1122
2264
  output.classList.add("hidden");
1123
2265
  if (canvasWrapper) canvasWrapper.classList.remove("hidden");
1124
2266
  canvasOutputSplitter?.classList.add("hidden");
2267
+ turtleModules.get(id)?.__redraw?.();
1125
2268
  };
1126
2269
 
1127
2270
  const applyStoredCanvasOutputSplit = setupCanvasOutputSplitter(
@@ -1152,6 +2295,7 @@ if _pg:
1152
2295
  canvasBtn.disabled = true;
1153
2296
  }
1154
2297
  applyStoredCanvasOutputSplit?.();
2298
+ turtleModules.get(id)?.__redraw?.();
1155
2299
  return;
1156
2300
  }
1157
2301
 
@@ -1238,7 +2382,10 @@ if _pg:
1238
2382
  void restoreEditorState();
1239
2383
  }
1240
2384
 
1241
- window.addEventListener("resize", applyCanvasOutputLayout);
2385
+ window.addEventListener("resize", () => {
2386
+ applyCanvasOutputLayout();
2387
+ turtleModules.get(id)?.__redraw?.();
2388
+ });
1242
2389
  applyCanvasOutputLayout();
1243
2390
 
1244
2391
  editor.addEventListener("input", () => {
@@ -1303,8 +2450,9 @@ if _pg:
1303
2450
 
1304
2451
  run?.addEventListener("click", async () => {
1305
2452
  const script = getEditorValue();
1306
- const useOutputForPytamaro = hasPytamaroPackage || scriptLooksLikePytamaro(script);
1307
- if (hasCanvas && !useOutputForPytamaro) {
2453
+ const usesPytamaro = hasPytamaroPackage || scriptLooksLikePytamaro(script);
2454
+ const renderPytamaroToCanvas = hasCanvas && canvas && usesPytamaro;
2455
+ if (hasCanvas) {
1308
2456
  showCanvas();
1309
2457
  } else {
1310
2458
  showOutput();
@@ -1322,15 +2470,16 @@ if _pg:
1322
2470
  output.innerHTML = "";
1323
2471
  clearPytamaroStdoutCarry(id);
1324
2472
  try {
2473
+ setPytamaroCanvasTarget(id, renderPytamaroToCanvas);
1325
2474
  const { results, error } = await executeScript(id, script, {
1326
- ...(hasCanvas && canvas && !useOutputForPytamaro ? { canvas } : {}),
2475
+ ...(hasCanvas && canvas ? { canvas } : {}),
1327
2476
  }, additionalPackages);
1328
2477
  if (!state.stopRequested) {
1329
2478
  if (results) {
1330
- appendOutput(output, results);
2479
+ appendOutput(output, results, false, id);
1331
2480
  } else if (error) {
1332
2481
  showOutput();
1333
- appendOutput(output, error, true);
2482
+ appendOutput(output, error, true, id);
1334
2483
  }
1335
2484
  } else {
1336
2485
  appendOutputLine(id, "Execution stopped.");
@@ -1338,7 +2487,7 @@ if _pg:
1338
2487
  } catch (e) {
1339
2488
  showOutput();
1340
2489
  output.innerHTML = "";
1341
- appendOutput(output, `Error: ${e}`, true);
2490
+ appendOutput(output, `Error: ${e}`, true, id);
1342
2491
  console.log(e);
1343
2492
  } finally {
1344
2493
  clearPytamaroStdoutCarry(id);