zcatgui/web/zcatgui.js
reugenio 6889474327 feat: zcatgui v0.15.0 - Mobile & Web Backends
WASM Backend:
- src/backend/wasm.zig: Browser backend using extern JS functions
- web/zcatgui.js: Canvas 2D rendering bridge (~200 LOC)
- web/index.html: Demo page with event handling
- examples/wasm_demo.zig: Widget showcase for browser
- Output: 18KB WASM binary

Android Backend:
- src/backend/android.zig: ANativeActivity + ANativeWindow
- examples/android_demo.zig: Touch-enabled demo
- Touch-to-mouse event mapping
- Logging via __android_log_print
- Targets: ARM64 (device), x86_64 (emulator)

iOS Backend:
- src/backend/ios.zig: UIKit bridge via extern C functions
- ios/ZcatguiBridge.h: Objective-C header
- ios/ZcatguiBridge.m: UIKit implementation (~320 LOC)
- CADisplayLink render loop
- Touch event queue with @synchronized
- Targets: ARM64 (device), ARM64 simulator

Build System:
- WASM: zig build wasm
- Android: zig build android / android-x86
- iOS: zig build ios / ios-sim
- Conditional compilation for platform detection

Documentation:
- docs/MOBILE_WEB_BACKENDS.md: Comprehensive guide (~400 lines)
- Updated DEVELOPMENT_PLAN.md with FASE 10
- Updated CLAUDE.md with new commands

Stats: 3 backends, ~1500 new LOC, cross-platform ready

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 18:20:13 +01:00

325 lines
10 KiB
JavaScript

/**
* zcatgui WASM Glue Code
*
* Provides the JavaScript side of the WASM backend:
* - Canvas management
* - Event handling (keyboard, mouse)
* - Framebuffer presentation
*/
class ZcatguiRuntime {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
if (!this.canvas) {
throw new Error(`Canvas with id '${canvasId}' not found`);
}
this.ctx = this.canvas.getContext('2d');
this.imageData = null;
this.wasm = null;
this.memory = null;
// Event queue
this.eventQueue = [];
this.maxEvents = 256;
// Setup event listeners
this.setupEventListeners();
}
/**
* Load and initialize the WASM module
*/
async load(wasmPath) {
const importObject = {
env: {
js_canvas_init: (width, height) => this.canvasInit(width, height),
js_canvas_present: (pixelsPtr, width, height) => this.canvasPresent(pixelsPtr, width, height),
js_get_canvas_width: () => this.canvas.width,
js_get_canvas_height: () => this.canvas.height,
js_console_log: (ptr, len) => this.consoleLog(ptr, len),
js_get_time_ms: () => BigInt(performance.now() | 0),
js_poll_event: (bufferPtr) => this.pollEvent(bufferPtr),
},
};
const response = await fetch(wasmPath);
const bytes = await response.arrayBuffer();
const result = await WebAssembly.instantiate(bytes, importObject);
this.wasm = result.instance;
this.memory = this.wasm.exports.memory;
return this.wasm;
}
/**
* Initialize canvas
*/
canvasInit(width, height) {
this.canvas.width = width;
this.canvas.height = height;
this.imageData = this.ctx.createImageData(width, height);
console.log(`zcatgui: Canvas initialized ${width}x${height}`);
}
/**
* Present framebuffer to canvas
*/
canvasPresent(pixelsPtr, width, height) {
if (!this.imageData || this.imageData.width !== width || this.imageData.height !== height) {
this.imageData = this.ctx.createImageData(width, height);
}
// Copy RGBA pixels from WASM memory to ImageData
// Pixels are stored as u32 (RGBA packed), need to read as Uint32Array then convert
const pixels32 = new Uint32Array(this.memory.buffer, pixelsPtr, width * height);
const pixels8 = this.imageData.data;
// Convert from RGBA u32 to separate R, G, B, A bytes
for (let i = 0; i < pixels32.length; i++) {
const pixel = pixels32[i];
const offset = i * 4;
// RGBA format (assuming little-endian)
pixels8[offset + 0] = pixel & 0xFF; // R
pixels8[offset + 1] = (pixel >> 8) & 0xFF; // G
pixels8[offset + 2] = (pixel >> 16) & 0xFF; // B
pixels8[offset + 3] = (pixel >> 24) & 0xFF; // A
}
this.ctx.putImageData(this.imageData, 0, 0);
}
/**
* Log to console from WASM
*/
consoleLog(ptr, len) {
const bytes = new Uint8Array(this.memory.buffer, ptr, len);
const text = new TextDecoder().decode(bytes);
console.log(`[zcatgui] ${text}`);
}
/**
* Poll event from queue
* Returns event type and writes data to buffer
*/
pollEvent(bufferPtr) {
if (this.eventQueue.length === 0) {
return 0; // No event
}
const event = this.eventQueue.shift();
const buffer = new Uint8Array(this.memory.buffer, bufferPtr, 64);
switch (event.type) {
case 'keydown':
buffer[0] = event.keyCode;
buffer[1] = this.getModifiers(event);
return 1;
case 'keyup':
buffer[0] = event.keyCode;
buffer[1] = this.getModifiers(event);
return 2;
case 'mousemove':
this.writeI32(buffer, 0, event.x);
this.writeI32(buffer, 4, event.y);
return 3;
case 'mousedown':
this.writeI32(buffer, 0, event.x);
this.writeI32(buffer, 4, event.y);
buffer[8] = event.button;
return 4;
case 'mouseup':
this.writeI32(buffer, 0, event.x);
this.writeI32(buffer, 4, event.y);
buffer[8] = event.button;
return 5;
case 'wheel':
this.writeI32(buffer, 0, event.x);
this.writeI32(buffer, 4, event.y);
this.writeI32(buffer, 8, event.deltaX);
this.writeI32(buffer, 12, event.deltaY);
return 6;
case 'resize':
this.writeU32(buffer, 0, event.width);
this.writeU32(buffer, 4, event.height);
return 7;
case 'quit':
return 8;
case 'textinput':
const encoded = new TextEncoder().encode(event.text);
buffer[0] = Math.min(encoded.length, 31);
buffer.set(encoded.slice(0, 31), 1);
return 9;
default:
return 0;
}
}
/**
* Setup DOM event listeners
*/
setupEventListeners() {
// Keyboard events
document.addEventListener('keydown', (e) => {
if (this.shouldCaptureKey(e)) {
e.preventDefault();
this.queueEvent({
type: 'keydown',
keyCode: e.keyCode,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
});
// Also queue text input for printable characters
if (e.key.length === 1 && !e.ctrlKey && !e.altKey) {
this.queueEvent({
type: 'textinput',
text: e.key,
});
}
}
});
document.addEventListener('keyup', (e) => {
if (this.shouldCaptureKey(e)) {
e.preventDefault();
this.queueEvent({
type: 'keyup',
keyCode: e.keyCode,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
});
}
});
// Mouse events
this.canvas.addEventListener('mousemove', (e) => {
const rect = this.canvas.getBoundingClientRect();
this.queueEvent({
type: 'mousemove',
x: Math.floor(e.clientX - rect.left),
y: Math.floor(e.clientY - rect.top),
});
});
this.canvas.addEventListener('mousedown', (e) => {
const rect = this.canvas.getBoundingClientRect();
this.queueEvent({
type: 'mousedown',
x: Math.floor(e.clientX - rect.left),
y: Math.floor(e.clientY - rect.top),
button: e.button,
});
});
this.canvas.addEventListener('mouseup', (e) => {
const rect = this.canvas.getBoundingClientRect();
this.queueEvent({
type: 'mouseup',
x: Math.floor(e.clientX - rect.left),
y: Math.floor(e.clientY - rect.top),
button: e.button,
});
});
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
this.queueEvent({
type: 'wheel',
x: Math.floor(e.clientX - rect.left),
y: Math.floor(e.clientY - rect.top),
deltaX: Math.sign(e.deltaX) * 3,
deltaY: Math.sign(e.deltaY) * 3,
});
}, { passive: false });
// Resize observer
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === this.canvas) {
this.queueEvent({
type: 'resize',
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
}
});
resizeObserver.observe(this.canvas);
// Prevent context menu
this.canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// Focus canvas for keyboard input
this.canvas.tabIndex = 0;
this.canvas.focus();
}
/**
* Check if we should capture this key event
*/
shouldCaptureKey(e) {
// Always capture when canvas is focused
if (document.activeElement === this.canvas) {
return true;
}
// Capture arrow keys, space, etc. even if not focused
const captureKeys = [37, 38, 39, 40, 32, 9, 27]; // arrows, space, tab, escape
return captureKeys.includes(e.keyCode);
}
/**
* Get modifier flags
*/
getModifiers(e) {
let mods = 0;
if (e.ctrlKey) mods |= 1;
if (e.shiftKey) mods |= 2;
if (e.altKey) mods |= 4;
return mods;
}
/**
* Queue an event
*/
queueEvent(event) {
if (this.eventQueue.length < this.maxEvents) {
this.eventQueue.push(event);
}
}
/**
* Write i32 to buffer (little-endian)
*/
writeI32(buffer, offset, value) {
const view = new DataView(buffer.buffer, buffer.byteOffset + offset, 4);
view.setInt32(0, value, true);
}
/**
* Write u32 to buffer (little-endian)
*/
writeU32(buffer, offset, value) {
const view = new DataView(buffer.buffer, buffer.byteOffset + offset, 4);
view.setUint32(0, value, true);
}
}
// Export for use
if (typeof module !== 'undefined' && module.exports) {
module.exports = { ZcatguiRuntime };
} else {
window.ZcatguiRuntime = ZcatguiRuntime;
}