pytml-runtime.js (38744B)
1 fetch('https://pytml.vercel.app/api/count') 2 .then(r => r.json()) 3 .then(data => console.log('Pytml loaded:', data.message, 'times')) 4 .catch(() => {}); 5 6 /* 7 * Pytml 2.5.0 8 * Python + HTML, powered by Pyodide. 9 * 10 * Pytml 2.5 includes a lightweight inline UI preprocessor for <pyN> blocks. 11 * The full document/language compiler remains planned for v3.0. 12 */ 13 (function (global) { 14 'use strict'; 15 16 const VERSION = '2.5.0'; 17 const PYODIDE_VERSION = '0.314.0.7'; 18 const PYODIDE_INDEX = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`; 19 const PYODIDE_SCRIPT = `${PYODIDE_INDEX}pyodide.js`; 20 const PY_BLOCK = /^py(?:\d+)?$/i; 21 22 // --- Speed: start the CDN connection and script download immediately, before DOMContentLoaded --- 23 let _pyodideScriptPromise = null; 24 25 function preconnect() { 26 for (const [rel, href] of [ 27 ['preconnect', 'https://cdn.jsdelivr.net'], 28 ['dns-prefetch', 'https://cdn.jsdelivr.net'], 29 ]) { 30 const link = document.createElement('link'); 31 link.rel = rel; 32 link.href = href; 33 if (rel === 'preconnect') link.crossOrigin = 'anonymous'; 34 document.head.appendChild(link); 35 } 36 // Preload the 6MB wasm so it downloads in parallel with the JS bootstrap. 37 const wasm = document.createElement('link'); 38 wasm.rel = 'preload'; 39 wasm.href = `${PYODIDE_INDEX}pyodide.asm.wasm`; 40 wasm.as = 'fetch'; 41 wasm.crossOrigin = 'anonymous'; 42 document.head.appendChild(wasm); 43 } 44 45 function preloadPyodideScript() { 46 if (_pyodideScriptPromise) return _pyodideScriptPromise; 47 _pyodideScriptPromise = new Promise((resolve, reject) => { 48 if (typeof global.loadPyodide === 'function') return resolve(); 49 const existing = document.querySelector(`script[src="${PYODIDE_SCRIPT}"]`); 50 if (existing) { 51 existing.addEventListener('load', resolve, { once: true }); 52 existing.addEventListener('error', () => reject(new Error(`Failed to load ${PYODIDE_SCRIPT}`)), { once: true }); 53 return; 54 } 55 const script = document.createElement('script'); 56 script.src = PYODIDE_SCRIPT; 57 script.async = true; 58 script.crossOrigin = 'anonymous'; 59 script.onload = resolve; 60 script.onerror = () => reject(new Error(`Failed to load ${PYODIDE_SCRIPT}`)); 61 document.head.appendChild(script); 62 }); 63 return _pyodideScriptPromise; 64 } 65 66 // --- Boot lock: interactive elements are visible but inert until Python is ready --- 67 function installBootState() { 68 document.documentElement.classList.add('pytml-booting'); 69 const style = document.createElement('style'); 70 style.id = 'pytml-boot-style'; 71 style.textContent = ` 72 /* Page paints normally, but nothing is clickable until Python is up */ 73 html.pytml-booting button, 74 html.pytml-booting input, 75 html.pytml-booting select, 76 html.pytml-booting textarea, 77 html.pytml-booting a[href], 78 html.pytml-booting [role="button"], 79 html.pytml-booting [contenteditable="true"] { 80 pointer-events: none !important; 81 opacity: 0.5; 82 cursor: not-allowed !important; 83 user-select: none; 84 transition: opacity 0.2s ease; 85 } 86 87 /* Small fixed pill at the top so users know why nothing is clickable */ 88 .pytml-boot-pill { 89 position: fixed; top: 16px; left: 50%; transform: translateX(-50%); 90 display: flex; align-items: center; gap: 8px; 91 background: rgba(10,14,39,0.95); color: #c8cdda; 92 border: 1px solid rgba(102,126,234,0.4); 93 border-radius: 999px; padding: 8px 16px; 94 font: 13px system-ui, -apple-system, sans-serif; 95 z-index: 2147483647; 96 box-shadow: 0 4px 20px rgba(0,0,0,0.35); 97 backdrop-filter: blur(6px); 98 } 99 .pytml-boot-pill.pytml-boot-error { 100 border-color: rgba(255,80,120,0.5); 101 color: #ff8aa8; 102 } 103 .pytml-boot-spinner { 104 width: 14px; height: 14px; flex: 0 0 14px; 105 border-radius: 50%; 106 border: 2px solid rgba(102,126,234,0.25); 107 border-top-color: #3b82f6; 108 animation: pytml-spin 0.8s linear infinite; 109 } 110 @keyframes pytml-spin { to { transform: rotate(360deg); } } 111 @media (prefers-reduced-motion: reduce) { .pytml-boot-spinner { animation: none; } } 112 `; 113 (document.head || document.documentElement).appendChild(style); 114 115 const mount = () => { 116 if (document.querySelector('.pytml-boot-pill')) return; 117 const pill = document.createElement('div'); 118 pill.className = 'pytml-boot-pill'; 119 pill.innerHTML = `<span class="pytml-boot-spinner"></span><span>Loading Python…</span>`; 120 document.body.appendChild(pill); 121 }; 122 if (document.body) mount(); 123 else document.addEventListener('DOMContentLoaded', mount, { once: true }); 124 } 125 126 function clearBootState() { 127 document.documentElement.classList.remove('pytml-booting'); 128 document.documentElement.classList.add('pytml-ready'); 129 document.querySelectorAll('.pytml-boot-pill').forEach(el => el.remove()); 130 } 131 132 function failBootState(message) { 133 document.documentElement.classList.remove('pytml-booting'); 134 document.querySelectorAll('.pytml-boot-pill').forEach(el => { 135 el.classList.add('pytml-boot-error'); 136 el.innerHTML = `<span>${message || 'Python failed to load.'}</span>`; 137 }); 138 } 139 140 // Fire immediately — these run before DOMContentLoaded. 141 preconnect(); 142 installBootState(); 143 preloadPyodideScript(); 144 145 const DEFAULT_STYLE = ` 146 .pytml-output{box-sizing:border-box;background:#0a0e27;color:#e7e9ee;border:1px solid rgba(102,126,234,.28);border-radius:14px;padding:16px;margin:16px 0;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;max-height:420px;overflow:auto} 147 .pytml-line{margin:4px 0;white-space:pre-wrap;overflow-wrap:anywhere} 148 .pytml-error{color:#ff8aa8;background:rgba(255,80,120,.10);padding:8px 10px;border-radius:8px} 149 .pytml-input-container{background:#11162f;border:1px solid rgba(102,126,234,.35);border-radius:12px;padding:14px;margin:14px 0} 150 .pytml-input-prompt{font:600 14px system-ui,sans-serif;color:#f2d15b;margin-bottom:8px} 151 .pytml-input-field{width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.18);background:#090c1c;color:#fff;outline:none;margin-bottom:9px} 152 .pytml-input-submit{border:0;border-radius:8px;padding:9px 14px;background:#3b82f6;color:#fff;font-weight:600;cursor:pointer} 153 .pytml-joystick{position:relative;touch-action:none;user-select:none;-webkit-user-select:none;overflow:hidden} 154 .pytml-joystick-knob{position:absolute;left:50%;top:50%;width:32%;aspect-ratio:1;border-radius:50%;transform:translate(-50%,-50%);background:currentColor;opacity:.78;pointer-events:none} 155 [data-pytml-hidden]{display:none!important} 156 `; 157 158 // This is the Python-side DOM/runtime module injected into Pyodide at startup. 159 const PYTHON_MODULE = String.raw` 160 import js 161 from pyodide.ffi import create_proxy 162 163 _callbacks = [] 164 165 class Event(dict): 166 """Dictionary-like browser event with default-control helpers.""" 167 def __init__(self, data, raw): 168 super().__init__(data) 169 self._raw = raw 170 def __getattr__(self, name): 171 try: 172 return self[name] 173 except KeyError as exc: 174 raise AttributeError(name) from exc 175 def prevent_default(self): 176 self._raw.preventDefault() 177 def stop_propagation(self): 178 self._raw.stopPropagation() 179 def stop_immediate_propagation(self): 180 self._raw.stopImmediatePropagation() 181 182 class Element: 183 def __init__(self, obj): 184 self._obj = obj 185 186 @property 187 def element(self): return self._obj 188 @property 189 def value(self): return self._obj.value if hasattr(self._obj, 'value') else '' 190 @value.setter 191 def value(self, value): self._obj.value = '' if value is None else str(value) 192 @property 193 def text(self): return self._obj.textContent or '' 194 @text.setter 195 def text(self, value): self._obj.textContent = '' if value is None else str(value) 196 @property 197 def html(self): return self._obj.innerHTML or '' 198 @html.setter 199 def html(self, value): self._obj.innerHTML = '' if value is None else str(value) 200 @property 201 def checked(self): return bool(getattr(self._obj, 'checked', False)) 202 @checked.setter 203 def checked(self, value): self._obj.checked = bool(value) 204 @property 205 def selected(self): return bool(getattr(self._obj, 'selected', False)) 206 @selected.setter 207 def selected(self, value): self._obj.selected = bool(value) 208 @property 209 def disabled(self): return bool(getattr(self._obj, 'disabled', False)) 210 @disabled.setter 211 def disabled(self, value): self._obj.disabled = bool(value) 212 @property 213 def visible(self): return bool(js.pytmlInstance.element(self._obj).visible) 214 @visible.setter 215 def visible(self, value): js.pytmlInstance.element(self._obj).visible = bool(value) 216 @property 217 def id(self): return str(self._obj.id) 218 @property 219 def tag(self): return str(self._obj.tagName).lower() 220 @property 221 def placeholder(self): return getattr(self._obj, 'placeholder', '') 222 @placeholder.setter 223 def placeholder(self, value): self._obj.placeholder = str(value) 224 @property 225 def class_name(self): return str(self._obj.className) 226 @class_name.setter 227 def class_name(self, value): self._obj.className = str(value) 228 @property 229 def x(self): return float(self._obj.getBoundingClientRect().x) 230 @property 231 def y(self): return float(self._obj.getBoundingClientRect().y) 232 @property 233 def width(self): return float(self._obj.getBoundingClientRect().width) 234 @property 235 def height(self): return float(self._obj.getBoundingClientRect().height) 236 @property 237 def scroll_top(self): return float(getattr(self._obj, 'scrollTop', 0)) 238 @scroll_top.setter 239 def scroll_top(self, value): self._obj.scrollTop = float(value) 240 @property 241 def scroll_left(self): return float(getattr(self._obj, 'scrollLeft', 0)) 242 @scroll_left.setter 243 def scroll_left(self, value): self._obj.scrollLeft = float(value) 244 @property 245 def files(self): 246 files = getattr(self._obj, 'files', None) 247 if not files: 248 return [] 249 return [{'name': f.name, 'size': int(f.size), 'type': f.type, 'last_modified': int(f.lastModified)} for f in files] 250 251 def attr(self, name): return self._obj.getAttribute(str(name)) 252 def set_attr(self, name, value): self._obj.setAttribute(str(name), str(value)) 253 def remove_attr(self, name): self._obj.removeAttribute(str(name)) 254 def add_class(self, name): self._obj.classList.add(str(name)) 255 def remove_class(self, name): self._obj.classList.remove(str(name)) 256 def toggle_class(self, name, force=None): 257 if force is None: 258 return bool(self._obj.classList.toggle(str(name))) 259 return bool(self._obj.classList.toggle(str(name), bool(force))) 260 def has_class(self, name): return bool(self._obj.classList.contains(str(name))) 261 def focus(self): self._obj.focus() 262 def blur(self): self._obj.blur() 263 def click(self): self._obj.click() 264 def remove(self): self._obj.remove() 265 def clear(self): js.pytmlInstance.clear_element(self._obj) 266 def scroll_to(self, x=0, y=0, behavior='auto'): js.pytmlInstance.scroll_element(self._obj, x, y, behavior) 267 def into_view(self, **kwargs): self._obj.scrollIntoView(kwargs) 268 def set_style(self, name, value): self._obj.style[str(name)] = str(value) 269 def get_style(self, name): return str(js.pytmlInstance.get_style(self._obj, str(name))) 270 def emit(self, name, detail=None): js.pytmlInstance.emit(self._obj, str(name), detail or {}) 271 272 def on(self, event, callback, options=None): 273 def handler(raw): 274 try: 275 normalized = js.pytmlInstance.api_event(raw).to_py() 276 return callback(Event(normalized, raw)) 277 except Exception: 278 import traceback 279 js.pytmlInstance.addError(traceback.format_exc()) 280 return None 281 proxy = create_proxy(handler) 282 self._obj.addEventListener(str(event), proxy, options) 283 _callbacks.append((self._obj, str(event), callback, proxy)) 284 285 def off(): 286 try: 287 self._obj.removeEventListener(str(event), proxy, options) 288 finally: 289 try: proxy.destroy() 290 except Exception: pass 291 try: _callbacks.remove((self._obj, str(event), callback, proxy)) 292 except ValueError: pass 293 return off 294 295 def off(self, event, callback): 296 keep = [] 297 for obj, ev, cb, proxy in _callbacks: 298 if obj is self._obj and ev == str(event) and cb is callback: 299 try: obj.removeEventListener(ev, proxy) 300 except Exception: pass 301 try: proxy.destroy() 302 except Exception: pass 303 else: 304 keep.append((obj, ev, cb, proxy)) 305 _callbacks[:] = keep 306 307 def on_click(self, callback, options=None): return self.on('click', callback, options) 308 def on_input(self, callback, options=None): return self.on('input', callback, options) 309 def on_change(self, callback, options=None): return self.on('change', callback, options) 310 def on_keydown(self, callback, options=None): return self.on('keydown', callback, options) 311 def on_keyup(self, callback, options=None): return self.on('keyup', callback, options) 312 def on_scroll(self, callback, options=None): return self.on('scroll', callback, options) 313 def play(self): return self._obj.play() 314 def pause(self): return self._obj.pause() 315 def request_fullscreen(self): return self._obj.requestFullscreen() 316 def canvas_context(self, kind='2d'): return self._obj.getContext(str(kind)) 317 318 class Joystick(Element): 319 @property 320 def joystick_x(self): return float(js.pytmlInstance.joystick_state(self._obj)['x']) 321 @property 322 def joystick_y(self): return float(js.pytmlInstance.joystick_state(self._obj)['y']) 323 @property 324 def magnitude(self): return float(js.pytmlInstance.joystick_state(self._obj)['magnitude']) 325 @property 326 def angle(self): return float(js.pytmlInstance.joystick_state(self._obj)['angle']) 327 @property 328 def active(self): return bool(js.pytmlInstance.joystick_state(self._obj)['active']) 329 330 331 def element(target): return Element(js.pytmlInstance.element(target).element) 332 def get(target): return element(target) 333 def query(selector): return element(selector) 334 def query_all(selector): return [Element(x) for x in js.document.querySelectorAll(str(selector))] 335 def joystick(target): return Joystick(js.pytmlInstance.makeJoystick(target).element) 336 async def sleep(ms): await js.pytmlInstance.sleep(int(ms)) 337 def timeout(callback, ms): return js.pytmlInstance.setTimeout(callback, int(ms)) 338 def interval(callback, ms): return js.pytmlInstance.setInterval(callback, int(ms)) 339 def clear_timeout(timer_id): return js.pytmlInstance.clearTimeout(int(timer_id)) 340 def clear_interval(timer_id): return js.pytmlInstance.clearInterval(int(timer_id)) 341 async def clipboard_copy(text): await js.pytmlInstance.clipboard_write(str(text)) 342 async def clipboard_read(): return await js.pytmlInstance.clipboard_read() 343 def alert(message): return js.pytmlInstance.alert(str(message)) 344 def confirm(message): return bool(js.pytmlInstance.confirm(str(message))) 345 def download(data, filename, mime='text/plain;charset=utf-8'): return js.pytmlInstance.download(data, filename, mime) 346 def gamepads(): return js.pytmlInstance.gamepads() 347 def storage_get(key, fallback=None): return js.pytmlInstance.storage_get(key, fallback) 348 def storage_set(key, value): return js.pytmlInstance.storage_set(key, value) 349 def storage_remove(key): return js.pytmlInstance.storage_remove(key) 350 def install(requirements, **kwargs): 351 import micropip 352 return micropip.install(requirements, **kwargs) 353 354 version = '2.5.0' 355 pyodide_version = '0.314.0.7' 356 357 def _bind_all_dom(namespace): 358 elements = {} 359 for obj in js.document.querySelectorAll('[id]'): 360 name = str(obj.id) 361 if name.isidentifier(): 362 elements[name] = Element(obj) 363 if name not in namespace: 364 namespace[name] = elements[name] 365 class _Elements: 366 def __getattr__(self, name): 367 try: 368 return elements[name] 369 except KeyError as exc: 370 raise AttributeError(name) from exc 371 def __getitem__(self, name): 372 try: 373 return elements[str(name)] 374 except KeyError as exc: 375 raise KeyError(name) from exc 376 namespace['elements'] = _Elements() 377 namespace['document'] = js.document 378 namespace['window'] = js.window 379 namespace['console'] = js.console 380 namespace['pytml'] = __import__('pytml') 381 `; 382 383 function installEarlyPythonHide() { 384 if (typeof document === 'undefined') return; 385 if (document.getElementById('pytml-early-hide')) return; 386 const style = document.createElement('style'); 387 style.id = 'pytml-early-hide'; 388 style.textContent = 'py,py0,py1,py2,py3,py4,py5,py6,py7,py8,py9,py10,py11,py12,py13,py14,py15,py16,py17,py18,py19,py20,py21,py22,py23,py24,py25,py26,py27,py28,py29,py30,py31,py32,py33,py34,py35,py36,py37,py38,py39,py40,py41,py42,py43,py44,py45,py46,py47,py48,py49,py50,py51,py52,py53,py54,py55,py56,py57,py58,py59,py60,py61,py62,py63,py64,py65,py66,py67,py68,py69,py70,py71,py72,py73,py74,py75,py76,py77,py78,py79,py80,py81,py82,py83,py84,py85,py86,py87,py88,py89,py90,py91,py92,py93,py94,py95,py96,py97,py98,py99,py100 { display:none !important; }'; 389 (document.head || document.documentElement).appendChild(style); 390 } 391 392 function normalizeEvent(event) { 393 const target = event?.currentTarget || event?.target || null; 394 const rect = target?.getBoundingClientRect ? target.getBoundingClientRect() : null; 395 const detail = event?.detail || {}; 396 return { 397 type: event?.type || '', 398 x: Number(event?.clientX ?? detail.x ?? 0), 399 y: Number(event?.clientY ?? detail.y ?? 0), 400 dx: Number(event?.movementX ?? detail.dx ?? 0), 401 dy: Number(event?.movementY ?? detail.dy ?? 0), 402 deltaX: Number(event?.deltaX ?? 0), 403 deltaY: Number(event?.deltaY ?? 0), 404 key: event?.key || '', 405 code: event?.code || '', 406 button: Number(event?.button ?? 0), 407 buttons: Number(event?.buttons ?? 0), 408 pointerId: Number(event?.pointerId ?? 0), 409 pointerType: event?.pointerType || '', 410 value: event?.target?.value ?? '', 411 checked: !!event?.target?.checked, 412 scrollTop: Number(event?.target?.scrollTop ?? 0), 413 scrollLeft: Number(event?.target?.scrollLeft ?? 0), 414 ctrlKey: !!event?.ctrlKey, 415 shiftKey: !!event?.shiftKey, 416 altKey: !!event?.altKey, 417 metaKey: !!event?.metaKey, 418 width: Number(rect?.width ?? 0), 419 height: Number(rect?.height ?? 0), 420 joystick_x: detail.x, 421 joystick_y: detail.y, 422 magnitude: detail.magnitude, 423 angle: detail.angle, 424 active: detail.active 425 }; 426 } 427 428 // Inline UI syntax supported inside <py>, <py1>, <py2>, ... blocks. 429 // These tags are converted to real browser elements before the Python is 430 // sent to Pyodide. Non-UI text nodes are preserved, including indentation. 431 const PY_INLINE_UI = /^(btn|input|txt)(\d+)$/i; 432 433 function copyInlineAttributes(source, target, kind) { 434 for (const attr of Array.from(source.attributes || [])) { 435 const name = attr.name.toLowerCase(); 436 if (name === 'id') continue; 437 if (kind === 'txt' && name === 'type') continue; 438 target.setAttribute(attr.name, attr.value); 439 } 440 } 441 442 class Pytml { 443 constructor(options = {}) { 444 this.version = VERSION; 445 this.pyodideVersion = PYODIDE_VERSION; 446 this.options = { 447 pyodideVersion: PYODIDE_VERSION, 448 pyodideIndexURL: PYODIDE_INDEX, 449 autoOutput: options.autoOutput === true, 450 loadPackagesFromImports: options.loadPackagesFromImports !== false, 451 ...options 452 }; 453 this.pyodide = null; 454 this.outputContainer = null; 455 this.statusElement = null; 456 this.ready = false; 457 this.readyPromise = null; 458 this.programs = new Map(); 459 this._callbackRegistry = new Map(); 460 this._intervals = new Set(); 461 this._timeouts = new Set(); 462 this._joystickState = new WeakMap(); 463 } 464 465 async start() { 466 if (!this.readyPromise) this.readyPromise = this._start(); 467 return this.readyPromise; 468 } 469 470 async _start() { 471 this.installStyles(); 472 await this.loadPyodideScript(); 473 this.pyodide = await global.loadPyodide({ indexURL: this.options.pyodideIndexURL }); 474 global.pyodide = this.pyodide; 475 global.pytmlInstance = this; 476 global.pytml = this; 477 await this.installPythonModule(); 478 await this.setupPythonEnvironment(); 479 await this.pyodide.loadPackage('micropip'); 480 this.ready = true; 481 await this.runAllPythonScripts(); 482 clearBootState(); 483 document.dispatchEvent(new CustomEvent('pytml:ready', { detail: this })); 484 return this; 485 } 486 487 installStyles() { 488 if (document.getElementById('pytml-runtime-style')) return; 489 const style = document.createElement('style'); 490 style.id = 'pytml-runtime-style'; 491 style.textContent = DEFAULT_STYLE; 492 document.head.appendChild(style); 493 } 494 495 createOutputContainer() { 496 let container = document.getElementById('pytml-output'); 497 if (!container) { 498 container = document.createElement('div'); 499 container.id = 'pytml-output'; 500 const first = this.findPythonBlocks()[0]?.element; 501 if (first?.parentNode) first.parentNode.insertBefore(container, first); 502 else document.body.insertBefore(container, document.body.firstChild); 503 } 504 container.classList.add('pytml-output'); 505 this.outputContainer = container; 506 } 507 508 async loadPyodideScript() { 509 await preloadPyodideScript(); 510 } 511 512 async installPythonModule() { 513 this.pyodide.globals.set('_pytml_module_source', PYTHON_MODULE); 514 await this.pyodide.runPythonAsync(` 515 import sys, types 516 _pytml_module = types.ModuleType('pytml') 517 exec(_pytml_module_source, _pytml_module.__dict__) 518 sys.modules['pytml'] = _pytml_module 519 `); 520 this.pyodide.globals.delete('_pytml_module_source'); 521 } 522 523 async setupPythonEnvironment() { 524 const bootstrap = String.raw` 525 import sys, ast, builtins, pytml 526 import js 527 528 class _OutputRedirect: 529 def __init__(self, error=False): self.error = error 530 def write(self, text): 531 if text: js.pytmlInstance.addOutput(str(text), self.error) 532 def flush(self): pass 533 534 sys.stdout = _OutputRedirect(False) 535 536 async def _pytml_input(prompt=""): 537 return await js.pytmlInstance.getUserInput(str(prompt)) 538 539 builtins.input = _pytml_input 540 541 class _InputTransformer(ast.NodeTransformer): 542 def visit_Call(self, node): 543 self.generic_visit(node) 544 if isinstance(node.func, ast.Name) and node.func.id == "input": 545 return ast.copy_location(ast.Await(value=node), node) 546 return node 547 548 def _pytml_transform_tree(source): 549 tree = ast.parse(source) 550 tree = _InputTransformer().visit(tree) 551 ast.fix_missing_locations(tree) 552 return tree 553 554 async def _pytml_run_source(source): 555 source = str(source) 556 tree = _pytml_transform_tree(source) 557 await js.pytmlInstance.prepare_python_globals() 558 code = compile(tree, "<pytml>", "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT) 559 result = eval(code, globals(), globals()) 560 if result is not None: 561 import inspect 562 if inspect.isawaitable(result): 563 await result 564 `; 565 await this.pyodide.runPythonAsync(bootstrap); 566 } 567 568 preprocessPythonBlock(el) { 569 if (el.__pytmlPreprocessed) return el.__pytmlPreprocessed; 570 571 const declarations = []; 572 let code = ''; 573 574 const visit = (node) => { 575 for (const child of Array.from(node.childNodes || [])) { 576 if (child.nodeType === Node.TEXT_NODE) { 577 code += child.nodeValue || ''; 578 continue; 579 } 580 if (child.nodeType !== Node.ELEMENT_NODE) continue; 581 582 const tag = child.tagName?.toLowerCase?.() || ''; 583 const match = tag.match(PY_INLINE_UI); 584 if (match) { 585 declarations.push({ 586 node: child, 587 kind: match[1].toLowerCase(), 588 id: `${match[1].toLowerCase()}${match[2]}`, 589 content: child.textContent || '' 590 }); 591 continue; 592 } 593 594 // Ordinary wrapper elements contribute their text to Python source. 595 visit(child); 596 } 597 }; 598 599 visit(el); 600 601 if (declarations.length) { 602 const fragment = document.createDocumentFragment(); 603 for (const declaration of declarations) { 604 const { node, kind, id, content } = declaration; 605 const existing = document.getElementById(id); 606 if (existing && existing !== el && !existing.hasAttribute('data-pytml-generated')) { 607 throw new Error(`Pytml UI id "${id}" already exists outside its <pyN> block.`); 608 } 609 610 const generated = document.createElement(kind === 'btn' ? 'button' : 'input'); 611 if (kind === 'txt' || (kind === 'input' && !node.hasAttribute('type'))) generated.type = 'text'; 612 copyInlineAttributes(node, generated, kind); 613 generated.id = id; 614 generated.setAttribute('data-pytml-generated', ''); 615 616 if (kind === 'btn') { 617 generated.type = node.getAttribute('type') || 'button'; 618 generated.textContent = content; 619 } else if (content.trim()) { 620 generated.placeholder = content.trim(); 621 } 622 623 fragment.appendChild(generated); 624 node.remove(); 625 } 626 if (el.parentNode) el.parentNode.insertBefore(fragment, el); 627 } 628 629 const processed = { 630 code, 631 declarations: declarations.map(({ kind, id }) => ({ kind, id })) 632 }; 633 el.__pytmlPreprocessed = processed; 634 return processed; 635 } 636 637 findPythonBlocks() { 638 const result = []; 639 document.querySelectorAll('*').forEach((el) => { 640 const tag = el.tagName?.toLowerCase?.() || ''; 641 if (PY_BLOCK.test(tag)) { 642 el.setAttribute('data-pytml-hidden', ''); 643 el.hidden = true; 644 const processed = this.preprocessPythonBlock(el); 645 result.push({ 646 element: el, 647 id: tag, 648 src: el.getAttribute('src'), 649 code: processed.code, 650 ui: processed.declarations 651 }); 652 } 653 }); 654 document.querySelectorAll('script[type="text/python"]').forEach((el, index) => { 655 result.push({ 656 element: el, 657 id: (el.getAttribute('data-id') || el.id || `script-${index + 1}`).toLowerCase(), 658 src: el.getAttribute('src'), 659 code: el.textContent, 660 ui: [] 661 }); 662 }); 663 return result; 664 } 665 666 async runAllPythonScripts() { 667 for (const block of this.findPythonBlocks()) { 668 if (block.src) { 669 const response = await fetch(block.src, { credentials: 'same-origin' }); 670 if (!response.ok) throw new Error(`HTTP ${response.status} while loading ${block.src}`); 671 await this.executePythonCode(await response.text(), block.id); 672 } else if (block.code.trim()) { 673 await this.executePythonCode(block.code, block.id); 674 } 675 block.element.setAttribute('data-pytml-hidden', ''); 676 } 677 } 678 679 async prepare_python_globals() { 680 await this.pyodide.runPythonAsync('from pytml import _bind_all_dom; _bind_all_dom(globals())'); 681 } 682 683 async executePythonCode(source, programId = 'py') { 684 if (!this.ready) throw new Error('Pytml is not ready yet.'); 685 if (this.options.loadPackagesFromImports) { 686 try { await this.pyodide.loadPackagesFromImports(source); } catch (_) {} 687 } 688 this.pyodide.globals.set('_pytml_source', source); 689 try { 690 await this.pyodide.runPythonAsync('await _pytml_run_source(_pytml_source)'); 691 this.programs.set(programId, (this.programs.get(programId) || 0) + 1); 692 } catch (error) { 693 const message = error?.message || String(error); 694 this.addError(`Execution error in ${programId}: ${message}`); 695 return false; 696 } finally { 697 this.pyodide.globals.delete('_pytml_source'); 698 } 699 } 700 701 element(target) { 702 let el = target; 703 if (typeof target === 'string') { 704 const looksLikeSelector = target.startsWith('#') || target.startsWith('.') || target.includes('[') || target.includes(' ') || target.includes(':'); 705 el = looksLikeSelector ? document.querySelector(target) : document.getElementById(target); 706 } 707 if (!el) throw new Error(`Pytml element not found: ${target}`); 708 return this.makeElementProxy(el); 709 } 710 711 makeElementProxy(el) { 712 return { 713 element: el, 714 get value() { return el.value ?? ''; }, 715 set value(v) { el.value = v == null ? '' : String(v); }, 716 get text() { return el.textContent ?? ''; }, 717 set text(v) { el.textContent = v == null ? '' : String(v); }, 718 get html() { return el.innerHTML ?? ''; }, 719 set html(v) { el.innerHTML = v == null ? '' : String(v); }, 720 get checked() { return !!el.checked; }, 721 set checked(v) { el.checked = !!v; }, 722 get selected() { return !!el.selected; }, 723 set selected(v) { el.selected = !!v; }, 724 get disabled() { return !!el.disabled; }, 725 set disabled(v) { el.disabled = !!v; }, 726 get visible() { return getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden'; }, 727 set visible(v) { el.style.display = v ? '' : 'none'; }, 728 get_style: (name) => getComputedStyle(el)[String(name)], 729 set_style: (name, value) => { el.style[String(name)] = String(value); }, 730 clear: () => { while (el.firstChild) el.removeChild(el.firstChild); if ('value' in el) el.value = ''; }, 731 scroll_to: (x, y, behavior='auto') => el.scrollTo({ left: Number(x)||0, top: Number(y)||0, behavior }), 732 emit: (name, detail={}) => el.dispatchEvent(new CustomEvent(String(name), { detail, bubbles:true })), 733 get files() { return [...(el.files || [])].map(f => ({name:f.name,size:f.size,type:f.type,last_modified:f.lastModified})); } 734 }; 735 } 736 737 api_event(event) { return normalizeEvent(event); } 738 clear_element(el) { while (el.firstChild) el.removeChild(el.firstChild); if ('value' in el) el.value = ''; } 739 scroll_element(el, x=0, y=0, behavior='auto') { el.scrollTo({ left:Number(x)||0, top:Number(y)||0, behavior }); } 740 get_style(el, name) { return getComputedStyle(el)[name]; } 741 emit(el, name, detail={}) { el.dispatchEvent(new CustomEvent(name, { detail, bubbles:true })); } 742 sleep(ms) { return new Promise(resolve => setTimeout(resolve, Number(ms)||0)); } 743 744 bindPythonCallback(el, event, callback, options=undefined) { 745 if (!this._callbackRegistry.has(el)) this._callbackRegistry.set(el, new Map()); 746 const eventMap = this._callbackRegistry.get(el); 747 if (!eventMap.has(event)) eventMap.set(event, []); 748 const proxy = this.pyodide.ffi.create_proxy((raw) => { 749 try { return callback(normalizeEvent(raw)); } 750 catch (error) { this.addError(`Event handler error: ${error.message}`); } 751 }); 752 el.addEventListener(event, proxy, options); 753 eventMap.get(event).push({ callback, proxy }); 754 return () => { 755 el.removeEventListener(event, proxy, options); 756 try { proxy.destroy(); } catch (_) {} 757 }; 758 } 759 760 async getUserInput(prompt) { 761 return new Promise((resolve) => { 762 if (!this.outputContainer) this.createOutputContainer(); 763 const box = document.createElement('div'); 764 box.className = 'pytml-input-container'; 765 const label = document.createElement('div'); label.className = 'pytml-input-prompt'; label.textContent = prompt || 'Enter value:'; 766 const input = document.createElement('input'); input.className = 'pytml-input-field'; input.type = 'text'; input.autocomplete = 'off'; 767 const submit = document.createElement('button'); submit.className = 'pytml-input-submit'; submit.type = 'button'; submit.textContent = 'Submit'; 768 box.append(label, input, submit); this.outputContainer.appendChild(box); 769 let done = false; 770 const finish = () => { if (done) return; done = true; const value=input.value; box.remove(); resolve(value); }; 771 submit.addEventListener('click', finish); input.addEventListener('keydown', e => { if (e.key === 'Enter') finish(); }); 772 input.focus(); box.scrollIntoView({behavior:'smooth',block:'center'}); 773 }); 774 } 775 776 addOutput(text, isError=false) { 777 if (!this.outputContainer) this.createOutputContainer(); 778 const line = document.createElement('div'); 779 line.className = `pytml-line${isError ? ' pytml-error' : ''}`; 780 line.textContent = String(text); 781 this.outputContainer.appendChild(line); 782 this.outputContainer.scrollTop = this.outputContainer.scrollHeight; 783 } 784 785 addError(text) { this.addOutput(`❌ ${text}`, true); } 786 787 showStatus(message, isError=false) { 788 if (!this.statusElement) { this.statusElement=document.createElement('div'); document.body.appendChild(this.statusElement); } 789 this.statusElement.className = `pytml-status${isError ? ' error' : ''}`; 790 this.statusElement.textContent = String(message); 791 this.statusElement.style.display = 'block'; 792 } 793 hideStatus() { if (this.statusElement) { this.statusElement.remove(); this.statusElement=null; } } 794 795 setTimeout(fn, ms) { 796 const proxy = this.pyodide.ffi.create_proxy(() => { try { fn(); } catch (e) { this.addError(`Timer error: ${e.message}`); } }); 797 const id = window.setTimeout(() => { try { proxy(); } finally { try { proxy.destroy(); } catch (_) {} } }, Number(ms)||0); 798 this._timeouts.add(id); return id; 799 } 800 setInterval(fn, ms) { 801 const proxy = this.pyodide.ffi.create_proxy(() => { try { fn(); } catch (e) { this.addError(`Timer error: ${e.message}`); } }); 802 const id = window.setInterval(proxy, Number(ms)||0); 803 this._intervals.add({ id, proxy }); return id; 804 } 805 clearTimeout(id) { window.clearTimeout(id); this._timeouts.delete(id); } 806 clearInterval(id) { window.clearInterval(id); for (const item of [...this._intervals]) if (item.id === id) { try { item.proxy.destroy(); } catch (_) {} this._intervals.delete(item); } } 807 808 clipboard_write(text) { 809 if (!navigator.clipboard?.writeText) return Promise.reject(new Error('Clipboard API unavailable')); 810 return navigator.clipboard.writeText(String(text)); 811 } 812 clipboard_read() { 813 if (!navigator.clipboard?.readText) return Promise.reject(new Error('Clipboard API unavailable')); 814 return navigator.clipboard.readText(); 815 } 816 alert(message) { return window.alert(String(message)); } 817 confirm(message) { return window.confirm(String(message)); } 818 storage_get(key, fallback=null) { return localStorage.getItem(String(key)) ?? fallback; } 819 storage_set(key, value) { localStorage.setItem(String(key), String(value)); } 820 storage_remove(key) { localStorage.removeItem(String(key)); } 821 download(data, filename, mime='text/plain;charset=utf-8') { 822 const blob = data instanceof Blob ? data : new Blob([String(data)], { type: mime }); 823 const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href=url; a.download=String(filename); a.click(); 824 setTimeout(() => URL.revokeObjectURL(url), 1000); 825 } 826 827 makeJoystick(target) { 828 const el = this.element(target).element; 829 if (el.__pytmlJoystick) return this.makeJoystickProxy(el); 830 el.classList.add('pytml-joystick'); el.style.touchAction='none'; 831 const state={x:0,y:0,magnitude:0,angle:0,active:false}; this._joystickState.set(el,state); 832 const knob=document.createElement('div'); knob.className='pytml-joystick-knob'; el.appendChild(knob); 833 const move=(ev)=>{ 834 const r=el.getBoundingClientRect(), cx=r.left+r.width/2, cy=r.top+r.height/2, max=Math.max(1,Math.min(r.width,r.height)/2); 835 const dx=ev.clientX-cx, dy=ev.clientY-cy, angle=Math.atan2(dy,dx), magnitude=Math.min(1,Math.hypot(dx,dy)/max); 836 state.x=Math.cos(angle)*magnitude; state.y=Math.sin(angle)*magnitude; state.magnitude=magnitude; state.angle=angle; state.active=true; 837 knob.style.left=`${50+state.x*35}%`; knob.style.top=`${50+state.y*35}%`; 838 el.dispatchEvent(new CustomEvent('joystickmove',{detail:{...state},bubbles:true})); 839 }; 840 const reset=(ev)=>{ state.x=state.y=state.magnitude=state.angle=0; state.active=false; knob.style.left='50%'; knob.style.top='50%'; el.dispatchEvent(new CustomEvent('joystickmove',{detail:{...state},bubbles:true})); try{el.releasePointerCapture?.(ev.pointerId)}catch(_){} }; 841 el.addEventListener('pointerdown',ev=>{el.setPointerCapture?.(ev.pointerId);move(ev)}); 842 el.addEventListener('pointermove',ev=>{if(el.hasPointerCapture?.(ev.pointerId))move(ev)}); 843 el.addEventListener('pointerup',reset); el.addEventListener('pointercancel',reset); el.__pytmlJoystick=true; 844 return this.makeJoystickProxy(el); 845 } 846 makeJoystickProxy(el) { 847 const base=this.makeElementProxy(el); 848 Object.defineProperties(base, { 849 joystick_x:{enumerable:true,get:()=>this._joystickState.get(el)?.x||0}, 850 joystick_y:{enumerable:true,get:()=>this._joystickState.get(el)?.y||0}, 851 magnitude:{enumerable:true,get:()=>this._joystickState.get(el)?.magnitude||0}, 852 angle:{enumerable:true,get:()=>this._joystickState.get(el)?.angle||0}, 853 active:{enumerable:true,get:()=>this._joystickState.get(el)?.active||false} 854 }); 855 return base; 856 } 857 joystick_state(el) { return this._joystickState.get(el) || {x:0,y:0,magnitude:0,angle:0,active:false}; } 858 gamepads() { return [...(navigator.getGamepads?.()||[])].filter(Boolean).map(g=>({id:g.id,index:g.index,connected:g.connected,buttons:[...g.buttons].map(b=>({pressed:b.pressed,value:b.value})),axes:[...g.axes]})); } 859 } 860 861 let singleton=null; 862 function start(options={}) { if(!singleton) singleton=new Pytml(options); return singleton.start(); } 863 async function boot() { 864 try { 865 await start(); 866 } catch (err) { 867 console.error('[PYTML] boot failed:', err); 868 failBootState('Python failed to load. Refresh to retry.'); 869 } 870 } 871 872 installEarlyPythonHide(); 873 874 global.Pytml=Pytml; 875 global.PytmlStart=start; 876 global.pytml=global.pytml||{start}; 877 boot(); 878 })(typeof window!=='undefined'?window:globalThis);