Files
Halcyon/web/static/view-code.js
T
2026-09-20 14:16:41 +02:00

250 lines
11 KiB
JavaScript

// Code editor with sixteen saved slots. Slots 1-3 are read-only examples; 4-16
// are the player's own. Launching a ship (Fleet tab) picks which slot it flies.
import { api, b64ToBytes } from './api.js';
import { h, clear, toast, confirmDialog, customDialog, download, pad } from './dom.js';
import { createEditor } from './editor.js';
const DEFAULT_SLOT = 2; // the telemetry example, a working program to start from
const readJSON = (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } };
const writeJSON = (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch { /* ignore */ } };
export function codeView(ctx) {
// The unsaved working copy survives reloads, per account and per browser.
const draftKey = `halcyon.editor.${ctx.state.me?.name ?? ''}`;
let slots = []; // [{ slot, name, builtin, empty }] from the server
let current = { slot: DEFAULT_SLOT, name: '', builtin: true, saved: '' }; // what the editor is bound to
let ready = false;
let timer = 0;
const info = (n) => slots.find((s) => s.slot === n) ?? { slot: n, name: '', builtin: false, empty: true };
const slotLabel = (s) => `${pad(s.slot, 2)} ${s.builtin ? `${s.name} (EXAMPLE)` : s.empty ? '(EMPTY)' : s.name}`;
const cursor = h('span', { class: 'dim nowrap' }, 'LN 1 COL 1');
const editor = createEditor({
value: '',
onInput: (v) => {
clearTimeout(timer);
timer = setTimeout(() => writeJSON(draftKey, { slot: current.slot, text: v }), 150);
renderStatus();
},
onCursor: ({ line, col }) => { cursor.textContent = `LN ${line} COL ${col}`; },
});
// ---- console -----------------------------------------------------------
const output = h('div', { class: 'console', role: 'log', 'aria-live': 'polite' },
h('p', { class: 'dim' }, 'READY. ASSEMBLE TO CHECK YOUR PROGRAM.'));
const say = (...nodes) => clear(output).append(...nodes);
let assembled = null;
async function assemble() {
try {
const r = await api.assemble(editor.value);
editor.setError(null);
assembled = { bytes: b64ToBytes(r.program), ...r };
const pct = Math.min(100, Math.round((r.size / r.limit) * 100));
say(
h('p', { class: r.fits ? 'ok' : 'bad' }, r.fits ? 'ASSEMBLED OK' : (r.size === 0 ? 'NOTHING TO ASSEMBLE' : 'PROGRAM TOO LARGE FOR THIS BELT')),
h('p', null, `${r.size} BYTES / ${r.instructions} INSTRUCTIONS / LIMIT ${r.limit} BYTES`),
h('div', { class: `meter ${r.fits ? '' : 'over'}`, role: 'meter', 'aria-valuemin': '0', 'aria-valuemax': String(r.limit), 'aria-valuenow': String(r.size), 'aria-label': 'Program size' },
h('div', { class: 'fill' })),
);
output.querySelector('.fill')?.style.setProperty('width', `${pct}%`);
return assembled;
} catch (e) {
assembled = null;
const m = /^line (\d+):/.exec(e.message);
if (m) editor.setError(Number(m[1]));
say(h('p', { class: 'bad' }, `ERROR: ${e.message}`));
return null;
}
}
const bytesOrNull = async () => {
const r = await assemble();
if (r && r.size === 0) { toast('NOTHING TO ASSEMBLE', 'err'); return null; }
return r;
};
// ---- slots -------------------------------------------------------------
const btn = (label, onclick, opts = {}) =>
h('button', { class: `btn ${opts.cls ?? ''}`, type: 'button', onclick, disabled: opts.disabled, title: opts.title }, label);
const slotSel = h('select', { id: 'slot', class: 'span2', 'aria-label': 'Code slot', disabled: true });
const status = h('p', { class: 'hint slot-status', 'aria-live': 'polite' }, 'LOADING YOUR SLOTS...');
const saveBtn = btn('SAVE', () => save(), { disabled: true });
const saveAsBtn = btn('SAVE AS...', () => saveAs(), { disabled: true });
const clearBtn = btn('CLEAR SLOT', () => clearSlot(), { cls: 'span2', disabled: true });
const dirty = () => editor.value !== current.saved;
function renderSelect() {
slotSel.replaceChildren(...slots.map((s) => h('option', { value: s.slot, selected: s.slot === current.slot }, slotLabel(s))));
slotSel.value = String(current.slot);
}
function renderStatus() {
if (!ready) return;
const s = info(current.slot);
status.textContent = `SLOT ${current.slot}: ${s.builtin ? `${s.name} (EXAMPLE, READ-ONLY)` : s.empty ? 'EMPTY' : s.name}${dirty() ? ' // UNSAVED CHANGES' : ''}`;
status.classList.toggle('warn', dirty());
saveBtn.disabled = current.builtin;
saveBtn.title = current.builtin ? 'The examples are read-only: use SAVE AS to keep a copy in your own slot' : `Save in slot ${current.slot}`;
saveAsBtn.disabled = false;
clearBtn.disabled = current.builtin || s.empty;
}
async function openSlot(n, { keepText = null } = {}) {
const s = await api.slot(n);
current = { slot: n, name: s.name, builtin: s.builtin, saved: s.source };
editor.value = keepText ?? s.source;
writeJSON(draftKey, { slot: n, text: editor.value });
renderSelect();
renderStatus();
}
slotSel.addEventListener('change', async () => {
const n = Number(slotSel.value);
if (dirty() && !(await confirmDialog({
title: 'DISCARD UNSAVED CHANGES?',
body: `YOU HAVE CHANGES IN SLOT ${current.slot} THAT ARE NOT SAVED. OPENING SLOT ${n} REPLACES THEM.`,
confirmText: 'DISCARD', danger: true,
}))) { slotSel.value = String(current.slot); return; }
try { await openSlot(n); say(h('p', { class: 'dim' }, `OPENED SLOT ${n}.`)); } catch (e) { toast(e.message, 'err'); slotSel.value = String(current.slot); }
});
async function put(n, name) {
try {
await api.saveSlot(n, name, editor.value);
slots = slots.map((s) => (s.slot === n ? { slot: n, name, builtin: false, empty: false } : s));
current = { slot: n, name, builtin: false, saved: editor.value };
writeJSON(draftKey, { slot: n, text: editor.value });
renderSelect();
renderStatus();
toast(`SAVED IN SLOT ${n}: ${name}`);
return true;
} catch (e) {
toast(`SAVE FAILED: ${e.message}`, 'err');
return false;
}
}
async function save() {
if (current.builtin) return saveAs();
if (!current.name) return saveAs();
return put(current.slot, current.name);
}
async function saveAs() {
const writable = slots.filter((s) => !s.builtin);
const firstFree = writable.find((s) => s.empty);
const target = current.builtin ? (firstFree ?? writable[0]).slot : current.slot;
const sel = h('select', { id: 'sa-slot' }, writable.map((s) => h('option', { value: s.slot, selected: s.slot === target }, slotLabel(s))));
const name = h('input', { id: 'sa-name', type: 'text', maxlength: '40', value: current.name || 'MY PROGRAM', spellcheck: 'false' });
const warn = h('p', { class: 'warn', 'aria-live': 'polite' });
const dlg = customDialog({
title: 'SAVE IN A SLOT',
content: [h('label', { for: 'sa-slot' }, 'SLOT'), sel, h('label', { for: 'sa-name' }, 'NAME'), name, warn],
confirmText: 'SAVE',
});
const check = () => {
const s = info(Number(sel.value));
warn.textContent = s.empty || s.slot === current.slot ? '' : `THIS REPLACES "${s.name}".`;
dlg.confirm.disabled = !name.value.trim();
};
sel.addEventListener('change', check);
name.addEventListener('input', check);
name.addEventListener('keydown', (e) => {
if (e.key !== 'Enter') return;
e.preventDefault(); // otherwise the same keystroke lands on the SAVE AS button that regains focus, and reopens this dialog
if (!dlg.confirm.disabled) dlg.close(true);
});
check();
name.select();
if (await dlg.result) await put(Number(sel.value), name.value.trim());
}
async function clearSlot() {
const s = info(current.slot);
if (!(await confirmDialog({ title: `CLEAR SLOT ${s.slot}?`, body: [`"${s.name}" WILL BE DELETED FROM THE SLOT.`, 'THE TEXT NOW IN THE EDITOR STAYS UNTIL YOU CHANGE SLOT.'], confirmText: 'CLEAR SLOT', danger: true }))) return;
try {
await api.deleteSlot(s.slot);
slots = slots.map((x) => (x.slot === s.slot ? { slot: s.slot, name: '', builtin: false, empty: true } : x));
current = { slot: s.slot, name: '', builtin: false, saved: '' };
renderSelect();
renderStatus();
toast(`SLOT ${s.slot} CLEARED`);
} catch (e) { toast(`CLEAR FAILED: ${e.message}`, 'err'); }
}
// ---- bars --------------------------------------------------------------
const bar = (label, ...buttons) =>
h('div', { class: 'bar', role: 'group', 'aria-label': label }, h('span', { class: 'bar-label' }, label), h('div', { class: 'bar-buttons' }, ...buttons));
const slotBar = bar('SLOT', slotSel, saveBtn, saveAsBtn, clearBtn);
const buildBar = bar('BUILD',
btn('ASSEMBLE', assemble),
btn('DOWNLOAD .BIN', async () => { const r = await bytesOrNull(); if (r) download(`slot-${current.slot}-program.bin`, r.bytes); }),
);
const fileBar = bar('FILE',
btn('INSERT EQUATES', async () => {
if (/\bP_UPNEW\b/.test(editor.value)) { toast('EQUATES ALREADY PRESENT'); return; }
try { editor.insertAtTop(`${await api.example('ports')}\n`); } catch (e) { toast(e.message, 'err'); }
}),
btn('DOWNLOAD .S', () => download(`slot-${current.slot}.s`, editor.value, 'text/plain')),
);
// ---- reference ---------------------------------------------------------
const card = h('pre', { class: 'refcard' }, 'LOADING...');
const ref = h('details', { class: 'panel ref', onToggle: async (e) => {
if (!e.target.open || card.dataset.loaded) return;
card.dataset.loaded = '1';
try {
// Appendix E of the manual source is one fenced block: the quick reference card.
const txt = await (await fetch('/manual.md')).text();
const open = txt.indexOf('```', txt.indexOf('# Appendix E'));
const close = txt.indexOf('```', open + 3);
card.textContent = txt.slice(txt.indexOf('\n', open) + 1, close).trim();
} catch { card.textContent = 'COULD NOT LOAD THE MANUAL.'; }
} },
h('summary', { class: 'panel-title' }, 'QUICK REFERENCE (HC-33 MANUAL, APPENDIX E)'),
card,
h('p', { class: 'hint pad' }, h('a', { href: '/manual.pdf', target: '_blank', rel: 'noopener' }, 'OPEN THE FULL MANUAL (PDF)')),
);
const view = h('div', { class: 'stack-lg' },
h('section', { class: 'panel' },
h('div', { class: 'panel-title' }, 'FLIGHT PROGRAM EDITOR', h('span', { class: 'spacer' }), cursor),
h('div', { class: 'panel-body' },
h('p', { class: 'hint' }, 'YOU HAVE 16 CODE SLOTS: 1-3 HOLD EXAMPLES, 4-16 ARE YOURS. TO FLY A PROGRAM, LAUNCH A SHIP FROM THE FLEET TAB AND CHOOSE ITS SLOT.'),
slotBar,
status,
editor.el,
h('p', { class: 'hint' }, 'YOUR UNSAVED TEXT IS KEPT IN THIS BROWSER. TAB INDENTS; ESC THEN TAB LEAVES THE EDITOR.'),
buildBar,
fileBar,
output,
),
),
ref,
);
(async () => {
try {
slots = await api.slots();
const draft = readJSON(draftKey);
const start = draft && slots.some((s) => s.slot === draft.slot) ? draft.slot : DEFAULT_SLOT;
ready = true;
slotSel.disabled = false;
await openSlot(start, { keepText: draft && draft.slot === start && typeof draft.text === 'string' ? draft.text : null });
} catch (e) {
status.textContent = `COULD NOT LOAD YOUR SLOTS: ${e.message}`;
status.classList.add('bad');
}
})();
queueMicrotask(() => editor.render());
return view;
}