hyperbook 0.92.0 → 0.93.1

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,963 @@ 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 toPlainBoolean = (value, fallback = false) => {
385
+ if (value === null || value === undefined) return fallback;
386
+ if (typeof value === "boolean") return value;
387
+ if (typeof value === "number") return value !== 0;
388
+ if (typeof value === "bigint") return value !== 0n;
389
+ if (typeof value === "string") {
390
+ const normalized = value.trim().toLowerCase();
391
+ if (!normalized) return fallback;
392
+ if (["false", "0", "no", "off"].includes(normalized)) return false;
393
+ if (["true", "1", "yes", "on"].includes(normalized)) return true;
394
+ }
395
+ if (typeof value.toJs === "function") {
396
+ return toPlainBoolean(value.toJs({ pyproxies: [] }), fallback);
397
+ }
398
+ return Boolean(value);
399
+ };
400
+ const fontSizeToCanvasUnits = (value) => {
401
+ const numeric = toPlainNumber(value, Number.NaN);
402
+ if (!Number.isFinite(numeric) || numeric === 0) {
403
+ const fallback = DEFAULT_FONT_SIZE;
404
+ return { size: `${fallback}pt`, pxApprox: (fallback * 96) / 72 };
405
+ }
406
+ if (numeric < 0) {
407
+ return { size: `${Math.abs(numeric)}px`, pxApprox: Math.abs(numeric) };
408
+ }
409
+ return { size: `${numeric}pt`, pxApprox: (numeric * 96) / 72 };
410
+ };
411
+ const toPlainString = (value, fallback = "") => {
412
+ if (value === null || value === undefined) return fallback;
413
+ if (typeof value === "string") return value;
414
+ if (typeof value.toJs === "function") {
415
+ return toPlainString(value.toJs({ pyproxies: [] }), fallback);
416
+ }
417
+ return String(value);
418
+ };
419
+ const toSequence = (value) => {
420
+ if (value === null || value === undefined) return null;
421
+ if (Array.isArray(value)) return value;
422
+ if (typeof value === "string") return [value];
423
+ if (typeof value.toJs === "function") {
424
+ return toSequence(value.toJs({ pyproxies: [] }));
425
+ }
426
+ if (typeof value[Symbol.iterator] === "function") {
427
+ try {
428
+ return Array.from(value);
429
+ } catch {}
430
+ }
431
+ if (typeof value === "object" && "length" in value) {
432
+ try {
433
+ return Array.from(value);
434
+ } catch {}
435
+ }
436
+ return null;
437
+ };
438
+ const toPlainObject = (value) => {
439
+ if (value === null || value === undefined) return null;
440
+ if (typeof value.toJs === "function") {
441
+ try {
442
+ return toPlainObject(value.toJs({ pyproxies: [], dict_converter: Object.fromEntries }));
443
+ } catch {
444
+ return toPlainObject(value.toJs({ pyproxies: [] }));
445
+ }
446
+ }
447
+ if (typeof value !== "object" || Array.isArray(value)) return null;
448
+ return value;
449
+ };
450
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
451
+ const isWriteKwargsObject = (value) => {
452
+ const obj = toPlainObject(value);
453
+ if (!obj) return false;
454
+ return (
455
+ hasOwn(obj, "arg") ||
456
+ hasOwn(obj, "text") ||
457
+ hasOwn(obj, "move") ||
458
+ hasOwn(obj, "align") ||
459
+ hasOwn(obj, "font")
460
+ );
461
+ };
462
+ const normalizeFontStyle = (value) => {
463
+ const style = toPlainString(value, "normal").trim().toLowerCase();
464
+ if (!style || style === "normal") return "";
465
+ const parts = style.split(/\s+/).filter(Boolean);
466
+ const hasItalic = parts.includes("italic");
467
+ const hasBold = parts.includes("bold");
468
+ if (hasItalic && hasBold) return "italic bold";
469
+ if (hasBold) return "bold";
470
+ if (hasItalic) return "italic";
471
+ return style;
472
+ };
473
+ const normalizeTextAlign = (value) => {
474
+ const align = toPlainString(value, "left").trim().toLowerCase();
475
+ if (align === "center" || align === "right") return align;
476
+ return "left";
477
+ };
478
+ const buildCanvasFont = (family, size, style) => {
479
+ const { size: canvasSize } = fontSizeToCanvasUnits(size);
480
+ const stylePart = normalizeFontStyle(style);
481
+ return `${stylePart ? `${stylePart} ` : ""}${canvasSize} ${toPlainString(family, "Arial")}`;
482
+ };
483
+ const measureTurtleText = (text, family, size, style) => {
484
+ const font = buildCanvasFont(family, size, style);
485
+ const { pxApprox } = fontSizeToCanvasUnits(size);
486
+ const ctx = textMeasureContext || context;
487
+ if (!ctx) {
488
+ return {
489
+ font,
490
+ width: String(text || "").length * pxApprox * 0.6,
491
+ descent: pxApprox * 0.2,
492
+ };
493
+ }
494
+ ctx.font = font;
495
+ ctx.textAlign = "left";
496
+ const metrics = ctx.measureText(String(text || ""));
497
+ return {
498
+ font,
499
+ width: metrics.width,
500
+ left: metrics.actualBoundingBoxLeft || 0,
501
+ right: metrics.actualBoundingBoxRight || metrics.width,
502
+ ascent:
503
+ metrics.fontBoundingBoxAscent ||
504
+ metrics.actualBoundingBoxAscent ||
505
+ pxApprox * 0.8,
506
+ descent:
507
+ metrics.fontBoundingBoxDescent ||
508
+ metrics.actualBoundingBoxDescent ||
509
+ pxApprox * 0.2,
510
+ };
511
+ };
512
+ const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
513
+ const clearQueue = () => {
514
+ queueGeneration += 1;
515
+ operationQueue = [];
516
+ queueRunning = false;
517
+ };
518
+ const enqueueOperation = (fn) => {
519
+ if (delayMs <= 0) {
520
+ fn();
521
+ return;
522
+ }
523
+ const generation = queueGeneration;
524
+ operationQueue.push({ fn, generation });
525
+ if (queueRunning) return;
526
+ queueRunning = true;
527
+ void (async () => {
528
+ try {
529
+ while (operationQueue.length > 0) {
530
+ const operation = operationQueue.shift();
531
+ if (!operation || operation.generation !== queueGeneration) {
532
+ continue;
533
+ }
534
+ operation.fn();
535
+ if (delayMs > 0) {
536
+ await sleep(delayMs);
537
+ }
538
+ }
539
+ } finally {
540
+ queueRunning = false;
541
+ }
542
+ })();
543
+ };
544
+
545
+ const ensureContext = () => {
546
+ if (!canvas) {
547
+ canvas = getCanvas(id);
548
+ }
549
+ if (!canvas) return false;
550
+ if (!context) {
551
+ context = canvas.getContext("2d");
552
+ }
553
+ return !!context;
554
+ };
555
+
556
+ const setupCanvasResolution = () => {
557
+ if (!ensureContext()) return false;
558
+ dpr = window.devicePixelRatio || 1;
559
+ const wrapperRect = canvas.parentElement?.getBoundingClientRect?.();
560
+ const panelWidth =
561
+ wrapperRect?.width || canvas.parentElement?.clientWidth || canvas.clientWidth || canvas.width || 800;
562
+ const panelHeight =
563
+ wrapperRect?.height || canvas.parentElement?.clientHeight || canvas.clientHeight || canvas.height || 400;
564
+ const width = Math.max(
565
+ 1,
566
+ Math.floor(screenWidth !== null ? screenWidth : panelWidth),
567
+ );
568
+ const height = Math.max(
569
+ 1,
570
+ Math.floor(screenHeight !== null ? screenHeight : panelHeight),
571
+ );
572
+ cssWidth = width;
573
+ cssHeight = height;
574
+ canvas.style.width = `${width}px`;
575
+ canvas.style.height = `${height}px`;
576
+ canvas.width = Math.max(1, Math.floor(cssWidth * dpr));
577
+ canvas.height = Math.max(1, Math.floor(cssHeight * dpr));
578
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
579
+ return true;
580
+ };
581
+
582
+ const makePath = (overrides = {}) => ({
583
+ down: penDown,
584
+ stroke: strokeColor,
585
+ lineWidth: DEFAULT_LINE_WIDTH,
586
+ fontsize: fontSize,
587
+ fontfamily: currentFontFamily,
588
+ fontstyle: currentFontStyle,
589
+ fill: false,
590
+ fillstyle: fillColor,
591
+ points: [{ x, y }],
592
+ ...overrides,
593
+ });
594
+
595
+ const beginCurrentPath = () => {
596
+ currentPath = makePath();
597
+ paths.push(currentPath);
598
+ return currentPath;
599
+ };
600
+
601
+ const commitStyleToNewPath = () => {
602
+ currentPath = makePath({
603
+ down: penDown,
604
+ lineWidth: currentPath?.lineWidth ?? DEFAULT_LINE_WIDTH,
605
+ });
606
+ paths.push(currentPath);
607
+ return currentPath;
608
+ };
609
+
610
+ const ensurePath = () => {
611
+ if (!currentPath) {
612
+ beginCurrentPath();
613
+ }
614
+ return currentPath;
615
+ };
616
+
617
+ const drawBackground = () => {
618
+ context.save();
619
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
620
+ context.fillStyle = backgroundColor;
621
+ context.fillRect(0, 0, cssWidth, cssHeight);
622
+ if (backgroundImage?.complete && backgroundImage.naturalWidth > 0) {
623
+ context.drawImage(backgroundImage, 0, 0, cssWidth, cssHeight);
624
+ }
625
+ context.restore();
626
+ };
627
+
628
+ const drawPathSegment = (path) => {
629
+ if (!path?.points?.length) return;
630
+
631
+ if (path.fill) {
632
+ context.beginPath();
633
+ path.points.forEach((point, index) => {
634
+ const px = toCanvasX(point.x);
635
+ const py = toCanvasY(point.y);
636
+ if (index === 0) {
637
+ context.moveTo(px, py);
638
+ } else {
639
+ context.lineTo(px, py);
640
+ }
641
+ });
642
+ context.closePath();
643
+ context.fillStyle = path.fillstyle;
644
+ context.fill();
645
+ return;
646
+ }
647
+
648
+ let startedLine = false;
649
+ context.beginPath();
650
+ for (const point of path.points) {
651
+ const px = toCanvasX(point.x);
652
+ const py = toCanvasY(point.y);
653
+ if (point.text !== undefined) {
654
+ const family = point.fontfamily || path.fontfamily || "Arial";
655
+ const style = point.fontstyle || path.fontstyle || "normal";
656
+ const size = point.fontsize ?? path.fontsize ?? DEFAULT_FONT_SIZE;
657
+ const text = String(point.text);
658
+ const metrics = measureTurtleText(text, family, size, style);
659
+ const align = normalizeTextAlign(point.align);
660
+ const left = metrics.left || 0;
661
+ const right = metrics.right || metrics.width || 0;
662
+ const descent = metrics.descent || 0;
663
+ const anchorX = px + TK_TEXT_X_OFFSET;
664
+ let drawX = anchorX + left;
665
+ if (align === "center") {
666
+ drawX = anchorX + (left - right) / 2;
667
+ } else if (align === "right") {
668
+ drawX = anchorX - right;
669
+ }
670
+ const drawY = py - descent;
671
+ context.font = metrics.font;
672
+ context.fillStyle = point.color || path.stroke;
673
+ context.textAlign = "left";
674
+ context.textBaseline = "alphabetic";
675
+ context.fillText(text, drawX, drawY);
676
+ continue;
677
+ }
678
+ if (point.dotRadius) {
679
+ context.beginPath();
680
+ context.arc(px, py, point.dotRadius, 0, 2 * Math.PI);
681
+ context.fillStyle = point.color || path.fillstyle || path.stroke;
682
+ context.fill();
683
+ context.beginPath();
684
+ startedLine = false;
685
+ continue;
686
+ }
687
+ if (!startedLine || point.move || !path.down) {
688
+ context.moveTo(px, py);
689
+ startedLine = true;
690
+ continue;
691
+ }
692
+ context.lineTo(px, py);
693
+ }
694
+
695
+ if (path.down) {
696
+ context.strokeStyle = path.stroke;
697
+ context.lineWidth = path.lineWidth || DEFAULT_LINE_WIDTH;
698
+ context.stroke();
699
+ }
700
+ };
701
+
702
+ const drawTurtle = () => {
703
+ if (!renderedTurtleVisible) return;
704
+ context.save();
705
+ context.translate(toCanvasX(renderedX), toCanvasY(renderedY));
706
+ context.rotate(-toRadians(renderedHeading));
707
+ context.beginPath();
708
+ context.moveTo(TURTLE_SIZE / 2, 0);
709
+ context.lineTo(-TURTLE_SIZE / 2, TURTLE_SIZE / 3);
710
+ context.lineTo(-TURTLE_SIZE / 4, 0);
711
+ context.lineTo(-TURTLE_SIZE / 2, -TURTLE_SIZE / 3);
712
+ context.closePath();
713
+ context.fillStyle = "#2f9e44";
714
+ context.strokeStyle = "#1b5e20";
715
+ context.lineWidth = 1;
716
+ context.fill();
717
+ context.stroke();
718
+ context.restore();
719
+ };
720
+
721
+ const draw = () => {
722
+ if (!active) return;
723
+ if (!setupCanvasResolution()) return;
724
+ drawBackground();
725
+ paths.forEach(drawPathSegment);
726
+ drawTurtle();
727
+ };
728
+
729
+ const resetState = () => {
730
+ paths = [];
731
+ currentPath = null;
732
+ fillPath = null;
733
+ x = 0;
734
+ y = 0;
735
+ heading = 0;
736
+ renderedX = 0;
737
+ renderedY = 0;
738
+ renderedHeading = 0;
739
+ penDown = true;
740
+ turtleVisible = true;
741
+ renderedTurtleVisible = true;
742
+ strokeColor = "#000000";
743
+ fillColor = "#000000";
744
+ backgroundColor = "#ffffff";
745
+ backgroundImage = null;
746
+ fontSize = DEFAULT_FONT_SIZE;
747
+ currentFontFamily = "Arial";
748
+ currentFontStyle = "normal";
749
+ colorMode = 1.0;
750
+ screenWidth = null;
751
+ screenHeight = null;
752
+ filling = false;
753
+ active = true;
754
+ clearQueue();
755
+ beginCurrentPath();
756
+ draw();
757
+ };
758
+
759
+ const deactivate = () => {
760
+ active = false;
761
+ clearQueue();
762
+ paths = [];
763
+ currentPath = null;
764
+ fillPath = null;
765
+ turtleVisible = false;
766
+ renderedTurtleVisible = false;
767
+ };
768
+
769
+ const bindCanvas = (nextCanvas) => {
770
+ canvas = nextCanvas || getCanvas(id);
771
+ context = canvas?.getContext?.("2d") || null;
772
+ if (canvas) {
773
+ canvas.tabIndex = 0;
774
+ }
775
+ if (!currentPath) {
776
+ beginCurrentPath();
777
+ }
778
+ draw();
779
+ };
780
+
781
+ const toColorString = (value) => {
782
+ if (typeof value === "string") {
783
+ return value;
784
+ }
785
+ if (value && typeof value.toJs === "function") {
786
+ return toColorString(value.toJs({ pyproxies: [] }));
787
+ }
788
+ if (Array.isArray(value) || (value && typeof value === "object" && "length" in value)) {
789
+ const parts = Array.from(value).slice(0, 3).map((part) => Number(part));
790
+ if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
791
+ throw new Error(`bad color sequence: ${String(value)}`);
792
+ }
793
+ if (colorMode === 1.0) {
794
+ if (parts.some((part) => part < 0 || part > 1)) {
795
+ throw new Error(`bad color sequence: ${String(value)}`);
796
+ }
797
+ const [r, g, b] = parts.map((part) => Math.round(part * 255));
798
+ return `rgb(${r}, ${g}, ${b})`;
799
+ }
800
+ if (colorMode === 255) {
801
+ if (parts.some((part) => part < 0 || part > 255)) {
802
+ throw new Error(`bad color sequence: ${String(value)}`);
803
+ }
804
+ const [r, g, b] = parts.map((part) => Math.round(part));
805
+ return `rgb(${r}, ${g}, ${b})`;
806
+ }
807
+ throw new Error(`bad color sequence: ${String(value)}`);
808
+ }
809
+ return "#000000";
810
+ };
811
+
812
+ const getPenState = () => ({
813
+ pendown: penDown,
814
+ pencolor: strokeColor,
815
+ fillcolor: fillColor,
816
+ pensize: currentPath?.lineWidth ?? DEFAULT_LINE_WIDTH,
817
+ speed: turtleSpeed,
818
+ shown: turtleVisible,
819
+ });
820
+
821
+ const forward = (distance) => {
822
+ if (!ensureContext()) return;
823
+ const path = ensurePath();
824
+ const fill = filling ? fillPath : null;
825
+ const length = Number(distance) || 0;
826
+ const nextX = x + length * Math.cos(toRadians(heading));
827
+ const nextY = y + length * Math.sin(toRadians(heading));
828
+ x = nextX;
829
+ y = nextY;
830
+ const point = { x, y, move: !penDown };
831
+ enqueueOperation(() => {
832
+ renderedX = point.x;
833
+ renderedY = point.y;
834
+ path.points.push(point);
835
+ if (fill) {
836
+ fill.points.push({ x, y });
837
+ }
838
+ draw();
839
+ });
840
+ };
841
+
842
+ const backward = (distance) => forward(-Number(distance || 0));
843
+ const right = (angle) => {
844
+ heading = normalizeAngle(heading - Number(angle || 0));
845
+ const nextHeading = heading;
846
+ enqueueOperation(() => {
847
+ renderedHeading = nextHeading;
848
+ draw();
849
+ });
850
+ };
851
+ const left = (angle) => {
852
+ heading = normalizeAngle(heading + Number(angle || 0));
853
+ const nextHeading = heading;
854
+ enqueueOperation(() => {
855
+ renderedHeading = nextHeading;
856
+ draw();
857
+ });
858
+ };
859
+ const penup = () => {
860
+ penDown = false;
861
+ commitStyleToNewPath();
862
+ draw();
863
+ };
864
+ const pendownFn = () => {
865
+ penDown = true;
866
+ commitStyleToNewPath();
867
+ draw();
868
+ };
869
+ const goto_ = (a, b) => {
870
+ if (!ensureContext()) return;
871
+ let nextX = Number(a);
872
+ let nextY = Number(b);
873
+ if (b === undefined && (Array.isArray(a) || (a && typeof a === "object"))) {
874
+ const source =
875
+ a && typeof a.toJs === "function" ? a.toJs({ pyproxies: [] }) : a;
876
+ nextX = Number(source?.[0] ?? source?.x ?? 0);
877
+ nextY = Number(source?.[1] ?? source?.y ?? 0);
878
+ }
879
+ x = Number.isFinite(nextX) ? nextX : 0;
880
+ y = Number.isFinite(nextY) ? nextY : 0;
881
+ const path = ensurePath();
882
+ const fill = filling ? fillPath : null;
883
+ const point = { x, y, move: !penDown };
884
+ enqueueOperation(() => {
885
+ renderedX = point.x;
886
+ renderedY = point.y;
887
+ path.points.push(point);
888
+ if (fill) {
889
+ fill.points.push({ x, y });
890
+ }
891
+ draw();
892
+ });
893
+ };
894
+ const setx = (value) => goto_(Number(value), y);
895
+ const sety = (value) => goto_(x, Number(value));
896
+ const position = () => [x, y];
897
+ const xcor = () => x;
898
+ const ycor = () => y;
899
+ const heading_ = () => heading;
900
+ const setheading = (angle) => {
901
+ heading = normalizeAngle(angle);
902
+ const nextHeading = heading;
903
+ enqueueOperation(() => {
904
+ renderedHeading = nextHeading;
905
+ draw();
906
+ });
907
+ };
908
+ const home = () => {
909
+ goto_(0, 0);
910
+ setheading(0);
911
+ };
912
+ const towards = (tx, ty) => {
913
+ const dx = Number(tx) - x;
914
+ const dy = Number(ty) - y;
915
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return 0;
916
+ return normalizeAngle((Math.atan2(dy, dx) * 180) / Math.PI);
917
+ };
918
+ const speed = (value = 0) => {
919
+ const numeric = Number(value);
920
+ turtleSpeed = Number.isFinite(numeric) ? numeric : 0;
921
+ delayMs = turtleSpeed <= 0 ? 0 : Math.max(0, Math.round(300 / turtleSpeed));
922
+ return delayMs;
923
+ };
924
+ const colormode = (mode = undefined) => {
925
+ if (mode === undefined) return colorMode;
926
+ const numeric = Number(mode);
927
+ if (numeric !== 1 && numeric !== 255) {
928
+ throw new Error("colormode must be 1.0 or 255");
929
+ }
930
+ colorMode = numeric;
931
+ return colorMode;
932
+ };
933
+ const screensize = (canvwidth = undefined, canvheight = undefined, bg = undefined) => {
934
+ if (canvwidth === undefined && canvheight === undefined && bg === undefined) {
935
+ return [screenWidth || cssWidth || 0, screenHeight || cssHeight || 0];
936
+ }
937
+ if (canvwidth !== undefined && canvwidth !== null) {
938
+ const width = Math.floor(Number(canvwidth));
939
+ if (Number.isFinite(width) && width > 0) {
940
+ screenWidth = width;
941
+ }
942
+ }
943
+ if (canvheight !== undefined && canvheight !== null) {
944
+ const height = Math.floor(Number(canvheight));
945
+ if (Number.isFinite(height) && height > 0) {
946
+ screenHeight = height;
947
+ }
948
+ }
949
+ if (bg !== undefined && bg !== null) {
950
+ backgroundColor = toColorString(bg);
951
+ }
952
+ draw();
953
+ if (canvas?.parentElement) {
954
+ const wrapper = canvas.parentElement;
955
+ wrapper.scrollLeft = Math.max(0, (cssWidth - wrapper.clientWidth) / 2);
956
+ wrapper.scrollTop = Math.max(0, (cssHeight - wrapper.clientHeight) / 2);
957
+ }
958
+ return [screenWidth || cssWidth || 0, screenHeight || cssHeight || 0];
959
+ };
960
+ const color = (...args) => {
961
+ const stroke = toColorString(args[0]);
962
+ const fill = args.length > 1 ? toColorString(args[1]) : stroke;
963
+ strokeColor = stroke;
964
+ fillColor = fill;
965
+ commitStyleToNewPath();
966
+ if (filling && fillPath) {
967
+ fillPath.fillstyle = fillColor;
968
+ }
969
+ draw();
970
+ };
971
+ const pencolor = (value) => {
972
+ strokeColor = toColorString(value);
973
+ commitStyleToNewPath();
974
+ draw();
975
+ };
976
+ const fillcolor = (value) => {
977
+ fillColor = toColorString(value);
978
+ if (filling && fillPath) {
979
+ fillPath.fillstyle = fillColor;
980
+ }
981
+ commitStyleToNewPath();
982
+ draw();
983
+ };
984
+ const pensize = (value) => {
985
+ ensurePath().lineWidth = Math.max(1, Number(value) || DEFAULT_LINE_WIDTH);
986
+ commitStyleToNewPath();
987
+ draw();
988
+ };
989
+ const width = (value) => pensize(value);
990
+ const begin_fill = () => {
991
+ if (filling) return;
992
+ filling = true;
993
+ fillPath = makePath({
994
+ down: false,
995
+ fill: true,
996
+ stroke: "transparent",
997
+ lineWidth: 1,
998
+ fillstyle: fillColor,
999
+ });
1000
+ paths.push(fillPath);
1001
+ draw();
1002
+ };
1003
+ const end_fill = () => {
1004
+ filling = false;
1005
+ fillPath = null;
1006
+ commitStyleToNewPath();
1007
+ draw();
1008
+ };
1009
+ const dot = (size = 5, colorValue = null) => {
1010
+ const segment = makePath({
1011
+ down: false,
1012
+ fill: false,
1013
+ stroke: colorValue ? toColorString(colorValue) : strokeColor,
1014
+ fillstyle: colorValue ? toColorString(colorValue) : strokeColor,
1015
+ points: [
1016
+ {
1017
+ x,
1018
+ y,
1019
+ dotRadius: Math.max(0.5, Number(size) || 5) / 2,
1020
+ color: colorValue ? toColorString(colorValue) : strokeColor,
1021
+ },
1022
+ ],
1023
+ });
1024
+ enqueueOperation(() => {
1025
+ paths.push(segment);
1026
+ draw();
1027
+ });
1028
+ };
1029
+ const circle = (radius, steps = 120) => {
1030
+ const numericRadius = Number(radius) || 0;
1031
+ const stepCount = Math.max(8, Number(steps) | 0);
1032
+ const circumference = 2 * Math.PI * Math.abs(numericRadius);
1033
+ const stepLength = circumference / stepCount;
1034
+ const stepTurn = (360 / stepCount) * (numericRadius >= 0 ? 1 : -1);
1035
+ for (let index = 0; index < stepCount; index += 1) {
1036
+ forward(stepLength);
1037
+ left(stepTurn);
1038
+ }
1039
+ };
1040
+ const write = (text, move = false, align = "left", font = null) => {
1041
+ let writeText = text;
1042
+ let writeMove = move;
1043
+ let writeAlign = align;
1044
+ let writeFont = font;
1045
+ const applyWriteKwargs = (candidate) => {
1046
+ if (!isWriteKwargsObject(candidate)) return;
1047
+ const kwargs = toPlainObject(candidate);
1048
+ if (!kwargs) return;
1049
+ if (hasOwn(kwargs, "arg")) writeText = kwargs.arg;
1050
+ else if (hasOwn(kwargs, "text")) writeText = kwargs.text;
1051
+ if (hasOwn(kwargs, "move")) writeMove = kwargs.move;
1052
+ if (hasOwn(kwargs, "align")) writeAlign = kwargs.align;
1053
+ if (hasOwn(kwargs, "font")) writeFont = kwargs.font;
1054
+ };
1055
+ applyWriteKwargs(writeText);
1056
+ applyWriteKwargs(writeMove);
1057
+ applyWriteKwargs(writeAlign);
1058
+ applyWriteKwargs(writeFont);
1059
+ if (isWriteKwargsObject(writeFont)) writeFont = null;
1060
+
1061
+ let family = currentFontFamily;
1062
+ let size = fontSize;
1063
+ let style = currentFontStyle;
1064
+ try {
1065
+ const source = toSequence(writeFont);
1066
+ if (source?.length) {
1067
+ if (source[0]) family = toPlainString(source[0], family);
1068
+ if (source[1] !== undefined && source[1] !== null) {
1069
+ size = toPlainNumber(source[1], size);
1070
+ }
1071
+ if (source[2]) style = toPlainString(source[2], style);
1072
+ }
1073
+ } catch {}
1074
+ const normalizedAlign = normalizeTextAlign(writeAlign);
1075
+ const segment = makePath({
1076
+ down: false,
1077
+ fill: false,
1078
+ stroke: strokeColor,
1079
+ points: [
1080
+ {
1081
+ x,
1082
+ y,
1083
+ text: String(writeText),
1084
+ align: normalizedAlign,
1085
+ fontsize: size,
1086
+ fontfamily: family,
1087
+ fontstyle: style,
1088
+ color: strokeColor,
1089
+ },
1090
+ ],
1091
+ });
1092
+ if (toPlainBoolean(writeMove, false)) {
1093
+ const metrics = measureTurtleText(String(writeText), family, size, style);
1094
+ const left = metrics.left || 0;
1095
+ const right = metrics.right || metrics.width || 0;
1096
+ const align = normalizeTextAlign(normalizedAlign);
1097
+ const textWidth = left + right;
1098
+ if (align === "left") {
1099
+ x += TK_TEXT_X_OFFSET + textWidth;
1100
+ } else if (align === "center") {
1101
+ x += TK_TEXT_X_OFFSET + right;
1102
+ } else {
1103
+ x += TK_TEXT_X_OFFSET;
1104
+ }
1105
+ commitStyleToNewPath();
1106
+ }
1107
+ enqueueOperation(() => {
1108
+ paths.push(segment);
1109
+ draw();
1110
+ });
1111
+ };
1112
+ const setfontsize = (value) => {
1113
+ const nextSize = toPlainNumber(value, DEFAULT_FONT_SIZE);
1114
+ fontSize = Math.max(1, Number.isFinite(nextSize) ? nextSize : DEFAULT_FONT_SIZE);
1115
+ commitStyleToNewPath();
1116
+ draw();
1117
+ };
1118
+ const bgcolor = (value) => {
1119
+ backgroundColor = toColorString(value);
1120
+ draw();
1121
+ };
1122
+ const bgpic = (filename = "") => {
1123
+ const name = String(filename || "").trim();
1124
+ if (!name) {
1125
+ backgroundImage = null;
1126
+ draw();
1127
+ return;
1128
+ }
1129
+ const fs = pyodide?.FS;
1130
+ if (!fs) return;
1131
+ const candidates = [
1132
+ name,
1133
+ `/home/pyodide/${name}`,
1134
+ `/home/pyodide/files/${name}`,
1135
+ `/home/pyodide/uploads/${name}`,
1136
+ ];
1137
+ for (const candidate of candidates) {
1138
+ try {
1139
+ const data = fs.readFile(candidate);
1140
+ const blob = new Blob([data], { type: "image/*" });
1141
+ const url = URL.createObjectURL(blob);
1142
+ const image = new Image();
1143
+ image.onload = () => {
1144
+ backgroundImage = image;
1145
+ draw();
1146
+ URL.revokeObjectURL(url);
1147
+ };
1148
+ image.onerror = () => URL.revokeObjectURL(url);
1149
+ image.src = url;
1150
+ return;
1151
+ } catch {}
1152
+ }
1153
+ };
1154
+ const clear = () => {
1155
+ clearQueue();
1156
+ paths = [];
1157
+ currentPath = null;
1158
+ fillPath = null;
1159
+ beginCurrentPath();
1160
+ draw();
1161
+ };
1162
+ const reset = () => {
1163
+ resetState();
1164
+ };
1165
+ const showturtle = () => {
1166
+ turtleVisible = true;
1167
+ enqueueOperation(() => {
1168
+ renderedTurtleVisible = true;
1169
+ draw();
1170
+ });
1171
+ };
1172
+ const hideturtle = () => {
1173
+ turtleVisible = false;
1174
+ enqueueOperation(() => {
1175
+ renderedTurtleVisible = false;
1176
+ draw();
1177
+ });
1178
+ };
1179
+ const isvisible = () => turtleVisible;
1180
+ const pen = (options = undefined) => {
1181
+ if (options === undefined) {
1182
+ return getPenState();
1183
+ }
1184
+ const source =
1185
+ options && typeof options.toJs === "function"
1186
+ ? options.toJs({ pyproxies: [], dict_converter: Object.fromEntries })
1187
+ : options;
1188
+ if (!source || typeof source !== "object") return getPenState();
1189
+ if ("pendown" in source) {
1190
+ penDown = !!source.pendown;
1191
+ }
1192
+ if ("pencolor" in source) {
1193
+ strokeColor = toColorString(source.pencolor);
1194
+ }
1195
+ if ("fillcolor" in source) {
1196
+ fillColor = toColorString(source.fillcolor);
1197
+ }
1198
+ if ("pensize" in source) {
1199
+ ensurePath().lineWidth = Math.max(1, Number(source.pensize) || DEFAULT_LINE_WIDTH);
1200
+ }
1201
+ if ("shown" in source) {
1202
+ turtleVisible = !!source.shown;
1203
+ renderedTurtleVisible = turtleVisible;
1204
+ }
1205
+ commitStyleToNewPath();
1206
+ draw();
1207
+ return getPenState();
1208
+ };
1209
+
1210
+ const api = {
1211
+ __bindCanvas: bindCanvas,
1212
+ __deactivate: deactivate,
1213
+ __redraw: draw,
1214
+ __resetState: resetState,
1215
+ __setPyodide: (runtime) => {
1216
+ pyodide = runtime;
1217
+ },
1218
+ forward,
1219
+ fd: forward,
1220
+ backward,
1221
+ bk: backward,
1222
+ back: backward,
1223
+ right,
1224
+ rt: right,
1225
+ left,
1226
+ lt: left,
1227
+ goto: (...args) => goto_(...args),
1228
+ setpos: (...args) => goto_(...args),
1229
+ setposition: (...args) => goto_(...args),
1230
+ setx,
1231
+ sety,
1232
+ setheading,
1233
+ seth: setheading,
1234
+ home,
1235
+ circle,
1236
+ dot,
1237
+ position,
1238
+ pos: position,
1239
+ xcor,
1240
+ ycor,
1241
+ heading: heading_,
1242
+ towards,
1243
+ pendown: pendownFn,
1244
+ pd: pendownFn,
1245
+ down: pendownFn,
1246
+ penup,
1247
+ pu: penup,
1248
+ up: penup,
1249
+ pensize,
1250
+ width,
1251
+ pen,
1252
+ write,
1253
+ setfontsize,
1254
+ color,
1255
+ pencolor,
1256
+ fillcolor,
1257
+ begin_fill,
1258
+ end_fill,
1259
+ bgcolor,
1260
+ bgpic,
1261
+ reset,
1262
+ clear,
1263
+ speed,
1264
+ colormode,
1265
+ screensize,
1266
+ showturtle,
1267
+ st: showturtle,
1268
+ hideturtle,
1269
+ ht: hideturtle,
1270
+ isvisible,
1271
+ };
1272
+
1273
+ return api;
1274
+ };
1275
+
271
1276
  const createPytamaroJsFFI = () => {
272
1277
  const floatBuffer = new ArrayBuffer(4);
273
1278
  const floatView = new DataView(floatBuffer);
@@ -596,6 +1601,142 @@ hyperbook.python = (function () {
596
1601
  };
597
1602
  };
598
1603
 
1604
+ // Browser/Pyodide needs periodic yielding for top-level pygame loops.
1605
+ const hasExplicitMain = (code) => {
1606
+ const text = String(code || "");
1607
+ return (
1608
+ /^(\s*)(?:async\s+def|def)\s+main\s*\(/m.test(text) ||
1609
+ /__name__\s*==\s*["']__main__["']/.test(text)
1610
+ );
1611
+ };
1612
+
1613
+ const looksLikeTopLevelGameLoop = (code) => {
1614
+ const text = String(code || "");
1615
+ const hasPygame =
1616
+ /(?:^|\W)(?:import\s+pygame\b|from\s+pygame\b\s+import\b)/m.test(text);
1617
+ const hasSasPygame =
1618
+ /(?:^|\W)(?:import\s+sas_pygame\b|from\s+sas_pygame\b\s+import\b)/m.test(text);
1619
+ const hasAnyWhile = /^\s*while\s+.+:\s*$/m.test(text);
1620
+ if (!hasAnyWhile) return false;
1621
+ return (hasPygame || hasSasPygame) && hasAnyWhile;
1622
+ };
1623
+
1624
+ const indentBlock = (source, spaces) => {
1625
+ const pad = " ".repeat(spaces);
1626
+ return String(source || "")
1627
+ .split(/\r\n|\r|\n/)
1628
+ .map((line) => (line.length ? pad + line : line))
1629
+ .join("\n");
1630
+ };
1631
+
1632
+ const wrapTopLevelIntoAsyncMain = (userCode) => {
1633
+ const code = String(userCode || "").replace(/\r\n/g, "\n");
1634
+
1635
+ if (hasExplicitMain(code)) return code;
1636
+
1637
+ const lines = code.split("\n");
1638
+ const keep = [];
1639
+ const body = [];
1640
+
1641
+ let index = 0;
1642
+ while (index < lines.length) {
1643
+ const line = lines[index];
1644
+ if (/^\s*$/.test(line)) {
1645
+ keep.push(line);
1646
+ index += 1;
1647
+ continue;
1648
+ }
1649
+ if (/^\s*#/.test(line)) {
1650
+ keep.push(line);
1651
+ index += 1;
1652
+ continue;
1653
+ }
1654
+ if (/^\s*(?:from\s+\S+\s+import\b|import\s+\S+)/.test(line)) {
1655
+ keep.push(line);
1656
+ index += 1;
1657
+ continue;
1658
+ }
1659
+
1660
+ if (/^\s*(?:def|async\s+def|class)\s+/.test(line)) {
1661
+ keep.push(line);
1662
+ index += 1;
1663
+ while (index < lines.length) {
1664
+ const nextLine = lines[index];
1665
+ if (/^\s*$/.test(nextLine)) {
1666
+ keep.push(nextLine);
1667
+ index += 1;
1668
+ continue;
1669
+ }
1670
+ if (
1671
+ /^\S/.test(nextLine) &&
1672
+ !/^\s*#/.test(nextLine) &&
1673
+ !/^\s*(?:from\s+\S+\s+import\b|import\s+\S+)/.test(nextLine) &&
1674
+ !/^\s*(?:def|async\s+def|class)\s+/.test(nextLine)
1675
+ ) {
1676
+ break;
1677
+ }
1678
+ keep.push(nextLine);
1679
+ index += 1;
1680
+ }
1681
+ continue;
1682
+ }
1683
+ break;
1684
+ }
1685
+
1686
+ for (; index < lines.length; index += 1) {
1687
+ body.push(lines[index]);
1688
+ }
1689
+
1690
+ let bodyText = body.join("\n");
1691
+ bodyText = bodyText.replace(/^([ \t]*\n)+/, "");
1692
+ bodyText = bodyText.replace(/(\n[ \t]*)+$/, "");
1693
+ bodyText = bodyText.replace(/\t/g, " ");
1694
+ if (!bodyText.replace(/[\s\n]+/g, "").length) return code;
1695
+
1696
+ const injected = bodyText
1697
+ .replace(
1698
+ /^(\s*)(\w+\s*\.\s*tick\s*\([^\)]*\)\s*)$/gm,
1699
+ "$1$2\n$1await asyncio.sleep(0)",
1700
+ )
1701
+ .replace(
1702
+ /^(\s*)(pygame\s*\.\s*display\s*\.\s*flip\s*\(\s*\)\s*)$/gm,
1703
+ "$1$2\n$1await asyncio.sleep(0)",
1704
+ )
1705
+ .replace(
1706
+ /^([\t ]*)([A-Za-z_][\w]*(?:\s*\.\s*[A-Za-z_][\w]*)*\s*\.\s*step\s*\([^\)]*\)\s*)(#.*)?$/gm,
1707
+ "$1$2$3\n$1await asyncio.sleep(0)",
1708
+ );
1709
+
1710
+ const prelude = [
1711
+ "# --- auto-wrapped by IDE for browser pygame compatibility ---",
1712
+ "import asyncio",
1713
+ "import pygame",
1714
+ "",
1715
+ "async def main():",
1716
+ indentBlock(injected, 4),
1717
+ "",
1718
+ "await main()",
1719
+ "# --- end auto-wrapped ---",
1720
+ "",
1721
+ ].join("\n");
1722
+
1723
+ let keepText = keep.join("\n");
1724
+ if (keepText && !keepText.endsWith("\n")) keepText += "\n";
1725
+ if (keepText && !/\n\s*\n$/.test(keepText)) keepText += "\n";
1726
+
1727
+ return keepText + prelude;
1728
+ };
1729
+
1730
+ const maybeAutoWrapPygame = (code) => {
1731
+ try {
1732
+ const text = String(code || "");
1733
+ if (!looksLikeTopLevelGameLoop(text)) return text;
1734
+ return wrapTopLevelIntoAsyncMain(text);
1735
+ } catch {
1736
+ return String(code || "");
1737
+ }
1738
+ };
1739
+
599
1740
  const ensureMicropipPackages = async (id, pyodide, packages = []) => {
600
1741
  if (packages.length === 0) return;
601
1742
  if (!installedMicropipPackages.has(id)) {
@@ -623,10 +1764,20 @@ hyperbook.python = (function () {
623
1764
  const pyodide = await getRuntime(id);
624
1765
  const { canvas, ...globalsContext } = context;
625
1766
  const decoder = new TextDecoder("utf-8");
1767
+ const executableScript = maybeAutoWrapPygame(script);
626
1768
 
627
1769
  if (canvas) {
628
1770
  try {
629
1771
  resetCanvas(canvas);
1772
+ const usesTurtle = scriptLooksLikeTurtle(executableScript);
1773
+ if (usesTurtle) {
1774
+ turtleModules.get(id)?.__bindCanvas(canvas);
1775
+ turtleModules.get(id)?.__resetState();
1776
+ } else {
1777
+ turtleModules.get(id)?.__deactivate?.();
1778
+ canvas.style.width = "";
1779
+ canvas.style.height = "";
1780
+ }
630
1781
  pyodide.canvas.setCanvas2D(canvas);
631
1782
  } catch (error) {
632
1783
  appendOutputErrorLine(id, `Canvas setup failed: ${error.message}`);
@@ -658,14 +1809,14 @@ hyperbook.python = (function () {
658
1809
  });
659
1810
 
660
1811
  await ensureMicropipPackages(id, pyodide, packages);
661
- await pyodide.loadPackagesFromImports(script);
1812
+ await pyodide.loadPackagesFromImports(executableScript);
662
1813
  const dict = pyodide.globals.get("dict");
663
1814
  const globals = dict();
664
1815
  try {
665
1816
  for (const [key, value] of Object.entries(globalsContext)) {
666
1817
  globals.set(key, value);
667
1818
  }
668
- const results = await pyodide.runPythonAsync(script, {
1819
+ const results = await pyodide.runPythonAsync(executableScript, {
669
1820
  globals,
670
1821
  locals: globals,
671
1822
  filename,
@@ -1122,6 +2273,7 @@ if _pg:
1122
2273
  output.classList.add("hidden");
1123
2274
  if (canvasWrapper) canvasWrapper.classList.remove("hidden");
1124
2275
  canvasOutputSplitter?.classList.add("hidden");
2276
+ turtleModules.get(id)?.__redraw?.();
1125
2277
  };
1126
2278
 
1127
2279
  const applyStoredCanvasOutputSplit = setupCanvasOutputSplitter(
@@ -1152,6 +2304,7 @@ if _pg:
1152
2304
  canvasBtn.disabled = true;
1153
2305
  }
1154
2306
  applyStoredCanvasOutputSplit?.();
2307
+ turtleModules.get(id)?.__redraw?.();
1155
2308
  return;
1156
2309
  }
1157
2310
 
@@ -1238,7 +2391,10 @@ if _pg:
1238
2391
  void restoreEditorState();
1239
2392
  }
1240
2393
 
1241
- window.addEventListener("resize", applyCanvasOutputLayout);
2394
+ window.addEventListener("resize", () => {
2395
+ applyCanvasOutputLayout();
2396
+ turtleModules.get(id)?.__redraw?.();
2397
+ });
1242
2398
  applyCanvasOutputLayout();
1243
2399
 
1244
2400
  editor.addEventListener("input", () => {
@@ -1303,8 +2459,9 @@ if _pg:
1303
2459
 
1304
2460
  run?.addEventListener("click", async () => {
1305
2461
  const script = getEditorValue();
1306
- const useOutputForPytamaro = hasPytamaroPackage || scriptLooksLikePytamaro(script);
1307
- if (hasCanvas && !useOutputForPytamaro) {
2462
+ const usesPytamaro = hasPytamaroPackage || scriptLooksLikePytamaro(script);
2463
+ const renderPytamaroToCanvas = hasCanvas && canvas && usesPytamaro;
2464
+ if (hasCanvas) {
1308
2465
  showCanvas();
1309
2466
  } else {
1310
2467
  showOutput();
@@ -1322,15 +2479,16 @@ if _pg:
1322
2479
  output.innerHTML = "";
1323
2480
  clearPytamaroStdoutCarry(id);
1324
2481
  try {
2482
+ setPytamaroCanvasTarget(id, renderPytamaroToCanvas);
1325
2483
  const { results, error } = await executeScript(id, script, {
1326
- ...(hasCanvas && canvas && !useOutputForPytamaro ? { canvas } : {}),
2484
+ ...(hasCanvas && canvas ? { canvas } : {}),
1327
2485
  }, additionalPackages);
1328
2486
  if (!state.stopRequested) {
1329
2487
  if (results) {
1330
- appendOutput(output, results);
2488
+ appendOutput(output, results, false, id);
1331
2489
  } else if (error) {
1332
2490
  showOutput();
1333
- appendOutput(output, error, true);
2491
+ appendOutput(output, error, true, id);
1334
2492
  }
1335
2493
  } else {
1336
2494
  appendOutputLine(id, "Execution stopped.");
@@ -1338,7 +2496,7 @@ if _pg:
1338
2496
  } catch (e) {
1339
2497
  showOutput();
1340
2498
  output.innerHTML = "";
1341
- appendOutput(output, `Error: ${e}`, true);
2499
+ appendOutput(output, `Error: ${e}`, true, id);
1342
2500
  console.log(e);
1343
2501
  } finally {
1344
2502
  clearPytamaroStdoutCarry(id);
@@ -55,7 +55,6 @@
55
55
  display: block;
56
56
  width: auto;
57
57
  height: auto;
58
- min-height: 300px;
59
58
  }
60
59
 
61
60
  .directive-pyide .canvas-header,
package/dist/index.js CHANGED
@@ -202149,7 +202149,7 @@ module.exports = /*#__PURE__*/JSON.parse('{"application/1d-interleaved-parityfec
202149
202149
  /***/ ((module) => {
202150
202150
 
202151
202151
  "use strict";
202152
- module.exports = /*#__PURE__*/JSON.parse('{"name":"hyperbook","version":"0.92.0","author":"Mike Barkmin","homepage":"https://github.com/openpatch/hyperbook#readme","license":"MIT","bin":{"hyperbook":"./dist/index.js"},"files":["dist"],"publishConfig":{"access":"public"},"repository":{"type":"git","url":"git+https://github.com/openpatch/hyperbook.git","directory":"packages/hyperbook"},"bugs":{"url":"https://github.com/openpatch/hyperbook/issues"},"engines":{"node":">=18"},"scripts":{"version":"pnpm build","lint":"tsc --noEmit","dev":"ncc build ./index.ts -w -o dist/","build":"rimraf dist && ncc build ./index.ts -o ./dist/ --no-cache --no-source-map-register --external favicons --external sharp && node postbuild.mjs"},"dependencies":{"favicons":"^7.2.0"},"devDependencies":{"create-hyperbook":"workspace:*","@hyperbook/fs":"workspace:*","@hyperbook/markdown":"workspace:*","@hyperbook/types":"workspace:*","@pnpm/exportable-manifest":"1000.0.6","@types/archiver":"6.0.3","@types/async-retry":"1.4.9","@types/cross-spawn":"6.0.6","@types/lunr":"^2.3.7","@types/prompts":"2.4.9","@types/tar":"6.1.13","@types/ws":"^8.5.14","@vercel/ncc":"0.38.3","archiver":"7.0.1","async-retry":"1.3.3","chalk":"5.4.1","chokidar":"4.0.3","commander":"12.1.0","cpy":"11.1.0","cross-spawn":"7.0.6","domutils":"^3.2.2","extract-zip":"^2.0.1","got":"12.6.0","htmlparser2":"^10.0.0","lunr":"^2.3.9","lunr-languages":"^1.14.0","mime":"^4.0.6","prompts":"2.4.2","rimraf":"6.0.1","tar":"7.4.3","update-check":"1.5.4","ws":"^8.18.0"}}');
202152
+ module.exports = /*#__PURE__*/JSON.parse('{"name":"hyperbook","version":"0.93.1","author":"Mike Barkmin","homepage":"https://github.com/openpatch/hyperbook#readme","license":"MIT","bin":{"hyperbook":"./dist/index.js"},"files":["dist"],"publishConfig":{"access":"public"},"repository":{"type":"git","url":"git+https://github.com/openpatch/hyperbook.git","directory":"packages/hyperbook"},"bugs":{"url":"https://github.com/openpatch/hyperbook/issues"},"engines":{"node":">=18"},"scripts":{"version":"pnpm build","lint":"tsc --noEmit","dev":"ncc build ./index.ts -w -o dist/","build":"rimraf dist && ncc build ./index.ts -o ./dist/ --no-cache --no-source-map-register --external favicons --external sharp && node postbuild.mjs"},"dependencies":{"favicons":"^7.2.0"},"devDependencies":{"create-hyperbook":"workspace:*","@hyperbook/fs":"workspace:*","@hyperbook/markdown":"workspace:*","@hyperbook/types":"workspace:*","@pnpm/exportable-manifest":"1000.0.6","@types/archiver":"6.0.3","@types/async-retry":"1.4.9","@types/cross-spawn":"6.0.6","@types/lunr":"^2.3.7","@types/prompts":"2.4.9","@types/tar":"6.1.13","@types/ws":"^8.5.14","@vercel/ncc":"0.38.3","archiver":"7.0.1","async-retry":"1.3.3","chalk":"5.4.1","chokidar":"4.0.3","commander":"12.1.0","cpy":"11.1.0","cross-spawn":"7.0.6","domutils":"^3.2.2","extract-zip":"^2.0.1","got":"12.6.0","htmlparser2":"^10.0.0","lunr":"^2.3.9","lunr-languages":"^1.14.0","mime":"^4.0.6","prompts":"2.4.2","rimraf":"6.0.1","tar":"7.4.3","update-check":"1.5.4","ws":"^8.18.0"}}');
202153
202153
 
202154
202154
  /***/ })
202155
202155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperbook",
3
- "version": "0.92.0",
3
+ "version": "0.93.1",
4
4
  "author": "Mike Barkmin",
5
5
  "homepage": "https://github.com/openpatch/hyperbook#readme",
6
6
  "license": "MIT",
@@ -59,7 +59,7 @@
59
59
  "create-hyperbook": "0.3.6",
60
60
  "@hyperbook/fs": "0.25.0",
61
61
  "@hyperbook/types": "0.23.0",
62
- "@hyperbook/markdown": "0.63.0"
62
+ "@hyperbook/markdown": "0.64.1"
63
63
  },
64
64
  "scripts": {
65
65
  "version": "pnpm build",