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