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