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>
325 lines
10 KiB
JavaScript
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;
|
|
}
|