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

pytml.min.js (39330B)


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