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