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 / src / pytml-runtime.js

pytml-runtime.js (38744B)


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