// 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; }