Initial commit

This commit is contained in:
2026-10-29 23:40:41 +01:00
commit c8789214bd
30 changed files with 7905 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TaskForge</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #111318; }
#app { display: block; width: 100vw; height: 100vh; outline: none; }
#message { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
color: #9DA7B3; font: 15px system-ui, sans-serif; text-align: center; padding: 2em; pointer-events: none; }
</style>
</head>
<body>
<canvas id="app" tabindex="0"></canvas>
<div id="message">Loading…</div>
<input id="import" type="file" accept=".taskforge,.txt" style="display: none">
<script src="taskforge.js"></script>
</body>
</html>
+329
View File
@@ -0,0 +1,329 @@
'use strict';
// Thin browser shim for TaskForge. All app logic and rendering is in Jai (taskforge.wasm); this file only
// 1. forwards WebGL2 calls (same names as OpenGL, see src/web_gl.jai),
// 2. forwards input events to the exported web_* procedures,
// 3. provides storage (localStorage), clipboard and file import/export.
// Pointers and s64 values arrive as BigInt (WASM64), everything else as Number.
const canvas = document.getElementById('app');
const message = document.getElementById('message');
const importInput = document.getElementById('import');
const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, premultipliedAlpha: false });
let memory = null;
let X = null; // WASM exports.
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const u8 = () => new Uint8Array(memory.buffer);
const dv = () => new DataView(memory.buffer);
const str = (ptr, len) => decoder.decode(u8().subarray(Number(ptr), Number(ptr) + Number(len)));
const cstr = (ptr) => {
const m = u8();
const start = Number(ptr);
let end = start;
while (m[end]) end++;
return decoder.decode(m.subarray(start, end));
};
// WebGL objects are referenced from Jai by integer handles.
const objects = [null];
const handle = (o) => { objects.push(o); return objects.length - 1; };
function writeInfoLog(log, maxLength, lengthPtr, bufferPtr) {
const bytes = encoder.encode(log || '').subarray(0, Math.max(0, maxLength - 1));
u8().set(bytes, Number(bufferPtr));
u8()[Number(bufferPtr) + bytes.length] = 0;
if (lengthPtr) dv().setInt32(Number(lengthPtr), bytes.length, true);
}
function genObjects(n, out, create) {
for (let i = 0; i < n; i++) dv().setUint32(Number(out) + i * 4, handle(create()), true);
}
let pendingLoad = null;
let consoleLine = '';
const env = {
// Jai runtime.
wasm_write_string(count, data, toStderr) {
consoleLine += str(data, count);
const lines = consoleLine.split('\n');
consoleLine = lines.pop();
for (const line of lines) (toStderr ? console.error : console.log)(line);
},
wasm_debug_break() { debugger; },
memcmp(a, b, n) {
const m = u8();
const pa = Number(a), pb = Number(b);
for (let i = 0; i < Number(n); i++) {
const d = m[pa + i] - m[pb + i];
if (d) return d;
}
return 0;
},
// OpenGL ES 3.0 -> WebGL2.
glCreateShader: (type) => handle(gl.createShader(type)),
glShaderSource(shader, count, strings, lengths) {
const d = dv();
let source = '';
for (let i = 0; i < count; i++) {
const p = d.getBigUint64(Number(strings) + i * 8, true);
const len = lengths ? d.getInt32(Number(lengths) + i * 4, true) : -1;
source += len >= 0 ? str(p, len) : cstr(p);
}
gl.shaderSource(objects[shader], source);
},
glCompileShader: (shader) => gl.compileShader(objects[shader]),
glGetShaderiv(shader, pname, out) {
const value = pname === 0x8B84
? (gl.getShaderInfoLog(objects[shader]) || '').length + 1
: Number(gl.getShaderParameter(objects[shader], pname));
dv().setInt32(Number(out), value, true);
},
glGetShaderInfoLog: (shader, max, len, buf) => writeInfoLog(gl.getShaderInfoLog(objects[shader]), max, len, buf),
glCreateProgram: () => handle(gl.createProgram()),
glAttachShader: (program, shader) => gl.attachShader(objects[program], objects[shader]),
glBindAttribLocation: (program, index, name) => gl.bindAttribLocation(objects[program], index, cstr(name)),
glLinkProgram: (program) => gl.linkProgram(objects[program]),
glGetProgramiv(program, pname, out) {
const value = pname === 0x8B84
? (gl.getProgramInfoLog(objects[program]) || '').length + 1
: Number(gl.getProgramParameter(objects[program], pname));
dv().setInt32(Number(out), value, true);
},
glGetProgramInfoLog: (program, max, len, buf) => writeInfoLog(gl.getProgramInfoLog(objects[program]), max, len, buf),
glUseProgram: (program) => gl.useProgram(objects[program]),
glGetUniformLocation: (program, name) => handle(gl.getUniformLocation(objects[program], cstr(name))),
glUniform1i: (loc, v) => gl.uniform1i(objects[loc], v),
glUniform1f: (loc, v) => gl.uniform1f(objects[loc], v),
glUniform2f: (loc, a, b) => gl.uniform2f(objects[loc], a, b),
glGenVertexArrays: (n, out) => genObjects(n, out, () => gl.createVertexArray()),
glBindVertexArray: (vao) => gl.bindVertexArray(objects[vao]),
glGenBuffers: (n, out) => genObjects(n, out, () => gl.createBuffer()),
glBindBuffer: (target, buffer) => gl.bindBuffer(target, objects[buffer]),
glBufferData(target, size, data, usage) {
if (data) gl.bufferData(target, u8().subarray(Number(data), Number(data) + Number(size)), usage);
else gl.bufferData(target, Number(size), usage);
},
glVertexAttribPointer: (index, size, type, normalized, stride, offset) =>
gl.vertexAttribPointer(index, size, type, !!normalized, stride, Number(offset)),
glEnableVertexAttribArray: (index) => gl.enableVertexAttribArray(index),
glGenTextures: (n, out) => genObjects(n, out, () => gl.createTexture()),
glBindTexture: (target, texture) => gl.bindTexture(target, objects[texture]),
glActiveTexture: (unit) => gl.activeTexture(unit),
glTexImage2D(target, level, internalFormat, w, h, border, format, type, pixels) {
const bytesPerPixel = format === gl.RED ? 1 : 4;
const data = pixels ? u8().subarray(Number(pixels), Number(pixels) + w * h * bytesPerPixel) : null;
gl.texImage2D(target, level, internalFormat, w, h, border, format, type, data);
},
glTexParameteri: (target, pname, param) => gl.texParameteri(target, pname, param),
glPixelStorei: (pname, param) => gl.pixelStorei(pname, param),
glEnable: (cap) => gl.enable(cap),
glDisable: (cap) => gl.disable(cap),
glBlendFunc: (s, d) => gl.blendFunc(s, d),
glScissor: (x, y, w, h) => gl.scissor(x, y, w, h),
glViewport: (x, y, w, h) => gl.viewport(x, y, w, h),
glClearColor: (r, g, b, a) => gl.clearColor(r, g, b, a),
glClear: (mask) => gl.clear(mask),
glDrawArrays: (mode, first, count) => gl.drawArrays(mode, first, count),
// Platform services.
js_storage_save(key, keyCount, data, count) {
try { localStorage.setItem(str(key, keyCount), str(data, count)); }
catch (e) { console.error('Saving failed:', e); }
},
js_storage_load_size(key, keyCount) {
const text = localStorage.getItem(str(key, keyCount));
pendingLoad = text ? encoder.encode(text) : null;
return BigInt(pendingLoad ? pendingLoad.length : 0);
},
js_storage_load(dest) {
if (pendingLoad) u8().set(pendingLoad, Number(dest));
pendingLoad = null;
},
js_clipboard_write(data, count) {
navigator.clipboard?.writeText(str(data, count)).catch(() => {});
},
js_set_cursor(kind) {
canvas.style.cursor = ['default', 'pointer', 'text', 'grabbing'][kind] || 'default';
},
js_download(data, count) {
const blob = new Blob([u8().slice(Number(data), Number(data) + Number(count))], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'project.taskforge';
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
},
js_request_import() { importInput.click(); },
js_http_post(tag, path, pathCount, body, bodyCount, token, tokenCount) {
const headers = { 'Content-Type': 'text/plain; charset=utf-8' };
if (tokenCount) headers['Authorization'] = 'Bearer ' + str(token, tokenCount);
const request = fetch(str(path, pathCount), { method: 'POST', headers, body: u8().slice(Number(body), Number(body) + Number(bodyCount)) });
request.then(async (response) => [response.status, new Uint8Array(await response.arrayBuffer())])
.catch(() => [0, new Uint8Array(0)])
.then(([status, bytes]) => {
const ptr = bytes.length ? X.web_alloc(BigInt(bytes.length)) : 0n;
if (bytes.length) u8().set(bytes, Number(ptr));
X.web_http_response(tag, status, ptr, BigInt(bytes.length));
requestFrame();
});
},
};
// Report any import we forgot instead of failing to instantiate.
const imports = {
env: new Proxy(env, {
get(target, name) {
if (name in target) return target[name];
return () => { throw new Error('Missing import: ' + String(name)); };
},
}),
};
//
// Frames are only rendered when something happened (input, resize, or the app asks for more).
//
let frameScheduled = false;
function requestFrame() {
if (!frameScheduled) {
frameScheduled = true;
requestAnimationFrame(frame);
}
}
function frame(timeMs) {
frameScheduled = false;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth, h = canvas.clientHeight;
const pw = Math.round(w * dpr), ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) {
canvas.width = pw;
canvas.height = ph;
}
if (X.web_frame(w, h, dpr, timeMs / 1000)) requestFrame();
}
function sendString(text, exportName) {
const bytes = encoder.encode(text);
const ptr = X.web_alloc(BigInt(bytes.length));
u8().set(bytes, Number(ptr));
X[exportName](ptr, BigInt(bytes.length));
}
//
// Input
//
const KEYS = {
Backspace: 8, Tab: 9, Enter: 13, Escape: 27, Delete: 127,
ArrowUp: 128, ArrowDown: 129, ArrowLeft: 130, ArrowRight: 131,
PageUp: 132, PageDown: 133, Home: 134, End: 135,
};
const MOUSE_BUTTONS = [0, 2, 1]; // DOM left/middle/right -> app left/middle/right indices.
function modifiers(e) {
return (e.shiftKey ? 1 : 0) | (e.ctrlKey || e.metaKey ? 2 : 0) | (e.altKey ? 4 : 0);
}
function mouseMove(e) {
const r = canvas.getBoundingClientRect();
X.web_mouse_move(e.clientX - r.left, e.clientY - r.top);
X.web_modifiers(modifiers(e));
requestFrame();
}
function installInput() {
window.addEventListener('pointermove', mouseMove);
canvas.addEventListener('pointerdown', (e) => {
canvas.focus();
canvas.setPointerCapture(e.pointerId);
mouseMove(e);
X.web_mouse_button(MOUSE_BUTTONS[e.button] ?? 0, 1);
e.preventDefault();
});
window.addEventListener('pointerup', (e) => {
mouseMove(e);
X.web_mouse_button(MOUSE_BUTTONS[e.button] ?? 0, 0);
});
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('wheel', (e) => {
const scale = e.deltaMode === 1 ? 1 : e.deltaMode === 2 ? 10 : 1 / 40;
X.web_modifiers(modifiers(e));
X.web_wheel(e.deltaX * scale, e.deltaY * scale);
requestFrame();
e.preventDefault();
}, { passive: false });
window.addEventListener('keydown', (e) => {
const mods = modifiers(e);
const ctrl = (mods & 2) !== 0;
X.web_modifiers(mods);
if (KEYS[e.key] !== undefined) {
X.web_key(KEYS[e.key], mods);
e.preventDefault();
} else if (ctrl) {
// Ctrl+V is left to the browser so the 'paste' event delivers the clipboard text.
const k = e.key.toUpperCase();
if (k === 'C' || k === 'X' || k === 'S' || k === 'A') {
X.web_key(k.charCodeAt(0), mods);
e.preventDefault();
}
} else if ([...e.key].length === 1) {
X.web_char(e.key.codePointAt(0));
e.preventDefault();
}
requestFrame();
});
window.addEventListener('keyup', (e) => { X.web_modifiers(modifiers(e)); });
window.addEventListener('paste', (e) => {
const text = e.clipboardData?.getData('text') || '';
for (const ch of text.replace(/\r\n/g, '\n')) X.web_char(ch.codePointAt(0));
e.preventDefault();
requestFrame();
});
window.addEventListener('resize', requestFrame);
window.addEventListener('beforeunload', () => X.web_flush());
// Keep the caret blinking and autosave ticking even without input.
setInterval(requestFrame, 500);
importInput.addEventListener('change', async () => {
const file = importInput.files[0];
importInput.value = '';
if (!file) return;
sendString(await file.text(), 'web_import');
requestFrame();
});
}
async function start() {
if (!gl) throw new Error('WebGL2 is not available in this browser.');
// When served by taskforge-server we run against it (with sign-in); otherwise the project lives in this browser.
const serverMode = await fetch('api/info').then((r) => r.ok ? r.text() : '').then((t) => t.startsWith('taskforge-server')).catch(() => false);
const [wasm, fonts] = await Promise.all([
fetch('taskforge.wasm').then((r) => r.arrayBuffer()).then((bytes) => WebAssembly.instantiate(bytes, imports)),
fetch('fonts.bin').then((r) => r.arrayBuffer()),
]);
X = wasm.instance.exports;
memory = X.memory;
X.main(0, 0n); // Runs the Jai runtime init + our main(), which just stores the context.
const ptr = X.web_alloc(BigInt(fonts.byteLength));
u8().set(new Uint8Array(fonts), Number(ptr));
if (!X.web_init(ptr, BigInt(fonts.byteLength), serverMode ? 1 : 0)) throw new Error('Initialization failed (see console).');
message.remove();
installInput();
canvas.focus();
requestFrame();
}
start().catch((e) => {
console.error(e);
const memory64 = /memory64|i64|Memory64|table64/i.test(String(e));
message.textContent = memory64
? 'This browser does not support WebAssembly Memory64 yet. Try a current Chrome, Edge or Firefox.'
: 'TaskForge failed to start: ' + e.message;
});