app.mjs raw

   1  // Moxie jsruntime - Gio App Bridge
   2  // Canvas creation, WebGL context setup, DOM event handling for Gio windows.
   3  
   4  import * as webgl from './webgl.mjs';
   5  
   6  let _canvas = null;
   7  let _textarea = null;
   8  let _composing = false;
   9  let _wasm = null;     // set by loader after WASM instantiation
  10  let _wasmReady = false; // set after _start() returns; prevents event dispatch during init
  11  let _dpr = 1;
  12  
  13  // Special key codes (>= 256) returned by keyCode().
  14  const KEY_ARROW_UP    = 256;
  15  const KEY_ARROW_DOWN  = 257;
  16  const KEY_ARROW_LEFT  = 258;
  17  const KEY_ARROW_RIGHT = 259;
  18  const KEY_ESCAPE      = 260;
  19  const KEY_ENTER       = 261;
  20  const KEY_BACKSPACE   = 262;
  21  const KEY_DELETE      = 263;
  22  const KEY_HOME        = 264;
  23  const KEY_END         = 265;
  24  const KEY_PAGE_UP     = 266;
  25  const KEY_PAGE_DOWN   = 267;
  26  const KEY_TAB         = 268;
  27  const KEY_SPACE       = 269;
  28  const KEY_F1          = 270;
  29  const KEY_F2          = 271;
  30  const KEY_F3          = 272;
  31  const KEY_F4          = 273;
  32  const KEY_F5          = 274;
  33  const KEY_F6          = 275;
  34  const KEY_F7          = 276;
  35  const KEY_F8          = 277;
  36  const KEY_F9          = 278;
  37  const KEY_F10         = 279;
  38  const KEY_F11         = 280;
  39  const KEY_F12         = 281;
  40  const KEY_CTRL        = 282;
  41  const KEY_SHIFT       = 283;
  42  const KEY_ALT         = 284;
  43  const KEY_SUPER       = 285;
  44  const KEY_COMMAND     = 286;
  45  
  46  function keyCode(k) {
  47    switch (k) {
  48      case 'ArrowUp':    return KEY_ARROW_UP;
  49      case 'ArrowDown':  return KEY_ARROW_DOWN;
  50      case 'ArrowLeft':  return KEY_ARROW_LEFT;
  51      case 'ArrowRight': return KEY_ARROW_RIGHT;
  52      case 'Escape':     return KEY_ESCAPE;
  53      case 'Enter':      return KEY_ENTER;
  54      case 'Backspace':  return KEY_BACKSPACE;
  55      case 'Delete':     return KEY_DELETE;
  56      case 'Home':       return KEY_HOME;
  57      case 'End':        return KEY_END;
  58      case 'PageUp':     return KEY_PAGE_UP;
  59      case 'PageDown':   return KEY_PAGE_DOWN;
  60      case 'Tab':        return KEY_TAB;
  61      case ' ':          return KEY_SPACE;
  62      case 'F1':  return KEY_F1;  case 'F2':  return KEY_F2;
  63      case 'F3':  return KEY_F3;  case 'F4':  return KEY_F4;
  64      case 'F5':  return KEY_F5;  case 'F6':  return KEY_F6;
  65      case 'F7':  return KEY_F7;  case 'F8':  return KEY_F8;
  66      case 'F9':  return KEY_F9;  case 'F10': return KEY_F10;
  67      case 'F11': return KEY_F11; case 'F12': return KEY_F12;
  68      case 'Control': return KEY_CTRL;
  69      case 'Shift':   return KEY_SHIFT;
  70      case 'Alt':     return KEY_ALT;
  71      case 'Meta':    return KEY_COMMAND;
  72      case 'OS':      return KEY_SUPER;
  73    }
  74    // Single printable character: return uppercase code point.
  75    if (k.length === 1) {
  76      const cp = k.toUpperCase().codePointAt(0);
  77      if (cp >= 0x20 && cp <= 0x7E) return cp;
  78    }
  79    return -1;
  80  }
  81  
  82  function modsFrom(e) {
  83    let m = 0;
  84    if (e.altKey)     m |= 1;
  85    if (e.ctrlKey)    m |= 2;
  86    if (e.shiftKey)   m |= 4;
  87    if (e.metaKey)    m |= 8;
  88    return m;
  89  }
  90  
  91  function mem32() {
  92    return new Int32Array(_wasm.exports.memory.buffer);
  93  }
  94  
  95  export function SetWASM(instance) {
  96    _wasm = instance;
  97  }
  98  
  99  export function SetWASMReady() {
 100    _wasmReady = true;
 101  }
 102  
 103  // --- Bridge functions called from Moxie ---
 104  
 105  export function CreateFullscreenCanvas() {
 106    const canvas = document.createElement('canvas');
 107    canvas.style.cssText = 'position:fixed;left:0;top:0;width:100%;height:100%;display:block;';
 108    document.body.style.cssText = 'margin:0;padding:0;overflow:hidden;';
 109    document.body.appendChild(canvas);
 110    _canvas = canvas;
 111  
 112    // Hidden textarea: keyboard focus target for text input and IME composition.
 113    const tarea = document.createElement('textarea');
 114    const ts = tarea.style;
 115    ts.position = 'absolute';
 116    ts.left = '-9999px';
 117    ts.top = '0';
 118    ts.width = '1px';
 119    ts.height = '1px';
 120    ts.opacity = '0';
 121    ts.border = '0';
 122    ts.padding = '0';
 123    ts.overflow = 'hidden';
 124    ts.resize = 'none';
 125    tarea.setAttribute('autocomplete', 'off');
 126    tarea.setAttribute('autocorrect', 'off');
 127    tarea.setAttribute('autocapitalize', 'off');
 128    tarea.spellcheck = false;
 129    tarea.rows = 1;
 130    document.body.appendChild(tarea);
 131    _textarea = tarea;
 132  }
 133  
 134  export function CreateWebGLContext() {
 135    if (!_canvas) return 0;
 136    const gl = _canvas.getContext('webgl2', {
 137      alpha: true,
 138      depth: true,
 139      stencil: true,
 140      antialias: false,
 141      premultipliedAlpha: true,
 142      preserveDrawingBuffer: false,
 143    });
 144    if (!gl) return 0;
 145    // Enable float FBO rendering for stencil-and-cover path rendering.
 146    // Without this, negative coverage values (for left-moving bezier segments)
 147    // are clamped to 0, causing D-shaped rendering artifacts on circles/rrects.
 148    gl.getExtension('EXT_color_buffer_float');
 149    gl.getExtension('EXT_color_buffer_half_float');
 150    return webgl.RegisterContext(gl);
 151  }
 152  
 153  export function GetDevicePixelRatio() {
 154    _dpr = window.devicePixelRatio || 1;
 155    return Math.fround(_dpr);
 156  }
 157  
 158  export function GetCanvasCSSSize(wPtr, hPtr) {
 159    if (!_canvas || !_wasm) return;
 160    const rect = _canvas.getBoundingClientRect();
 161    // Fall back to window size if layout hasn't completed yet.
 162    const w = rect.width > 1 ? rect.width : window.innerWidth;
 163    const h = rect.height > 1 ? rect.height : window.innerHeight;
 164    const m = mem32();
 165    m[(wPtr >>> 0) >> 2] = Math.round(w);
 166    m[(hPtr >>> 0) >> 2] = Math.round(h);
 167  }
 168  
 169  export function SetCanvasBacking(w, h) {
 170    if (!_canvas) return;
 171    _canvas.width = w;
 172    _canvas.height = h;
 173  }
 174  
 175  export function RequestRAF() {
 176    requestAnimationFrame(() => {
 177      if (_wasm && _wasm.exports.__gio_raf) {
 178        _wasm.exports.__gio_raf();
 179      }
 180    });
 181  }
 182  
 183  export function SetCursor(ptr, len) {
 184    if (!_canvas || !_wasm) return;
 185    const bytes = new Uint8Array(_wasm.exports.memory.buffer, ptr >>> 0, len);
 186    _canvas.style.cursor = new TextDecoder().decode(bytes);
 187  }
 188  
 189  export function SetTitle(ptr, len) {
 190    if (!_wasm) return;
 191    const bytes = new Uint8Array(_wasm.exports.memory.buffer, ptr >>> 0, len);
 192    document.title = new TextDecoder().decode(bytes);
 193  }
 194  
 195  export function SetFullscreen(full) {
 196    if (full) {
 197      if (document.documentElement.requestFullscreen) {
 198        document.documentElement.requestFullscreen();
 199      }
 200    } else {
 201      if (document.exitFullscreen) {
 202        document.exitFullscreen();
 203      }
 204    }
 205  }
 206  
 207  export function RegisterPointerEvents() {
 208    if (!_canvas) return;
 209    // pointer kind constants: 0=Move, 1=Press, 2=Release, 3=Scroll, 4=Cancel
 210    _canvas.addEventListener('mousemove', e => {
 211      e.preventDefault();
 212      dispatchPointer(0, e, 0, 0);
 213    });
 214    _canvas.addEventListener('mousedown', e => {
 215      e.preventDefault();
 216      dispatchPointer(1, e, 0, 0);
 217      // Focus textarea on click so keyboard events are captured.
 218      if (_textarea) _textarea.focus({ preventScroll: true });
 219    });
 220    _canvas.addEventListener('mouseup', e => {
 221      e.preventDefault();
 222      dispatchPointer(2, e, 0, 0);
 223    });
 224    _canvas.addEventListener('wheel', e => {
 225      e.preventDefault();
 226      let dx = e.deltaX, dy = e.deltaY;
 227      if (e.shiftKey) { const tmp = dx; dx = dy; dy = tmp; }
 228      if (e.deltaMode === 1) { dx *= 10; dy *= 10; }
 229      if (e.deltaMode === 2) { dx *= 120; dy *= 120; }
 230      const dpr = window.devicePixelRatio || 1;
 231      dispatchPointer(3, e, dx * dpr, dy * dpr);
 232    }, { passive: false });
 233    // Prevent right-click context menu over canvas.
 234    _canvas.addEventListener('contextmenu', e => e.preventDefault());
 235    // touch events
 236    _canvas.addEventListener('touchstart', e => {
 237      e.preventDefault();
 238      const rect = _canvas.getBoundingClientRect();
 239      const dpr = window.devicePixelRatio || 1;
 240      for (let i = 0; i < e.changedTouches.length; i++) {
 241        const t = e.changedTouches[i];
 242        dispatchTouchPoint(1, t, rect, dpr, modsFrom(e));
 243      }
 244      if (_textarea) _textarea.focus({ preventScroll: true });
 245    }, { passive: false });
 246    _canvas.addEventListener('touchend', e => {
 247      e.preventDefault();
 248      const rect = _canvas.getBoundingClientRect();
 249      const dpr = window.devicePixelRatio || 1;
 250      for (let i = 0; i < e.changedTouches.length; i++) {
 251        const t = e.changedTouches[i];
 252        dispatchTouchPoint(2, t, rect, dpr, modsFrom(e));
 253      }
 254    }, { passive: false });
 255    _canvas.addEventListener('touchmove', e => {
 256      e.preventDefault();
 257      const rect = _canvas.getBoundingClientRect();
 258      const dpr = window.devicePixelRatio || 1;
 259      for (let i = 0; i < e.changedTouches.length; i++) {
 260        const t = e.changedTouches[i];
 261        dispatchTouchPoint(0, t, rect, dpr, modsFrom(e));
 262      }
 263    }, { passive: false });
 264    _canvas.addEventListener('touchcancel', e => {
 265      if (_wasm && _wasm.exports.__gio_pointer) {
 266        _wasm.exports.__gio_pointer(4, 0, 0, 0, 0, 0, 0); // Cancel
 267      }
 268    });
 269  }
 270  
 271  function dispatchPointer(kind, e, dx, dy) {
 272    if (!_wasm || !_wasmReady || !_wasm.exports.__gio_pointer) return;
 273    const rect = _canvas.getBoundingClientRect();
 274    const dpr = window.devicePixelRatio || 1;
 275    const x = (e.clientX - rect.left) * dpr;
 276    const y = (e.clientY - rect.top) * dpr;
 277    const btns = e.buttons;
 278    const mods = modsFrom(e);
 279    _wasm.exports.__gio_pointer(kind, Math.fround(x), Math.fround(y), Math.fround(dx), Math.fround(dy), btns, mods);
 280  }
 281  
 282  function dispatchTouchPoint(kind, touch, rect, dpr, mods) {
 283    if (!_wasm || !_wasm.exports.__gio_pointer) return;
 284    const x = (touch.clientX - rect.left) * dpr;
 285    const y = (touch.clientY - rect.top) * dpr;
 286    const id = touch.identifier & 0xff; // pack touch id in high bits of buttons
 287    _wasm.exports.__gio_pointer(kind, Math.fround(x), Math.fround(y), 0, 0, id << 8, mods);
 288  }
 289  
 290  export function RegisterKeyEvents() {
 291    document.addEventListener('keydown', e => {
 292      // Ctrl+V / Cmd+V: intercept for clipboard paste.
 293      if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
 294        e.preventDefault();
 295        if (navigator.clipboard && navigator.clipboard.readText) {
 296          navigator.clipboard.readText().then(text => {
 297            dispatchPaste(text);
 298          }).catch(() => {});
 299        }
 300        // Still dispatch the key event so Moxie knows a paste was requested.
 301      }
 302      dispatchKey(e, 1);
 303    });
 304    document.addEventListener('keyup', e => dispatchKey(e, 0));
 305  }
 306  
 307  function dispatchKey(e, press) {
 308    if (!_wasm || !_wasmReady || !_wasm.exports.__gio_key) return;
 309    const code = keyCode(e.key);
 310    if (code < 0) return;
 311    const mods = modsFrom(e);
 312    // Prevent browser default for function/navigation keys on press.
 313    if (press && code >= KEY_ARROW_UP) {
 314      e.preventDefault();
 315    }
 316    _wasm.exports.__gio_key(code, mods, press);
 317  }
 318  
 319  export function RegisterResizeEvents() {
 320    const vp = window.visualViewport || window;
 321    vp.addEventListener('resize', () => {
 322      if (_wasm && _wasm.exports.__gio_resize) {
 323        _wasm.exports.__gio_resize();
 324      }
 325    });
 326  }
 327  
 328  // --- Text input (Step 1) ---
 329  
 330  export function RegisterTextEvents() {
 331    if (!_textarea) return;
 332    _textarea.addEventListener('compositionstart', () => {
 333      _composing = true;
 334    });
 335    _textarea.addEventListener('compositionend', e => {
 336      _composing = false;
 337      if (e.data) dispatchText(e.data);
 338      _textarea.value = '';
 339    });
 340    _textarea.addEventListener('input', e => {
 341      if (_composing) return; // skip intermediate IME state
 342      const data = e.data;
 343      if (data) {
 344        dispatchText(data);
 345        _textarea.value = '';
 346      }
 347    });
 348    _textarea.addEventListener('focus', () => {
 349      if (_wasm && _wasmReady && _wasm.exports.__gio_focus) {
 350        _wasm.exports.__gio_focus(1);
 351      }
 352    });
 353    _textarea.addEventListener('blur', () => {
 354      if (_wasm && _wasmReady && _wasm.exports.__gio_focus) {
 355        _wasm.exports.__gio_focus(0);
 356      }
 357    });
 358  }
 359  
 360  function dispatchText(str) {
 361    if (!_wasm || !_wasmReady || !_wasm.exports.__gio_text) return;
 362    for (const ch of str) {
 363      const cp = ch.codePointAt(0);
 364      if (cp !== undefined) _wasm.exports.__gio_text(cp);
 365    }
 366  }
 367  
 368  function dispatchPaste(str) {
 369    if (!_wasm || !_wasmReady || !_wasm.exports.__gio_paste) return;
 370    for (const ch of str) {
 371      const cp = ch.codePointAt(0);
 372      if (cp !== undefined) _wasm.exports.__gio_paste(cp);
 373    }
 374  }
 375  
 376  export function FocusTextArea() {
 377    if (_textarea) _textarea.focus({ preventScroll: true });
 378  }
 379  
 380  export function BlurTextArea() {
 381    if (_textarea) _textarea.blur();
 382  }
 383  
 384  // --- Clipboard (Step 3) ---
 385  
 386  export function WriteClipboard(ptr, len) {
 387    if (!_wasm || !navigator.clipboard) return;
 388    const bytes = new Uint8Array(_wasm.exports.memory.buffer, ptr >>> 0, len);
 389    const text = new TextDecoder().decode(bytes);
 390    navigator.clipboard.writeText(text).catch(() => {});
 391  }
 392  
 393  export function ReadClipboard() {
 394    if (!navigator.clipboard) return;
 395    navigator.clipboard.readText().then(text => {
 396      dispatchPaste(text);
 397    }).catch(() => {});
 398  }
 399  
 400  // --- WebGL context loss (Step 4) ---
 401  
 402  export function RegisterContextLossEvents() {
 403    if (!_canvas) return;
 404    _canvas.addEventListener('webglcontextlost', e => {
 405      e.preventDefault();
 406      if (_wasm && _wasmReady && _wasm.exports.__gio_context_loss) {
 407        _wasm.exports.__gio_context_loss(1);
 408      }
 409    });
 410    _canvas.addEventListener('webglcontextrestored', () => {
 411      if (_wasm && _wasmReady && _wasm.exports.__gio_context_loss) {
 412        _wasm.exports.__gio_context_loss(0);
 413      }
 414    });
 415  }
 416