Pytml

Pytml is a 15KB lightweight PyScript alternative — run Python in HTML with one script tag.

git clone https://github.com/notamitgamer/Pytml.git
· rebuilt hourlyOpen on GitHub
root / dist / pytml.min.js

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);