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