Files
2026-09-19 20:21:47 +02:00

132 lines
4.4 KiB
JavaScript

// A small assembly editor: line numbers, syntax highlighting (a coloured <pre>
// underneath a transparent <textarea>), Tab-indents and error-line marking.
import { h } from './dom.js';
const MNEMONICS = new Set(
('nop yield halt ldi lui mov add sub mul div mod and or xor shl shr sar addi jmp beq bne blt bge ' +
'call ret push pop ldb ldh ldw stb sth stw in out li').split(' '),
);
const TOKEN = new RegExp(
[
'(\\.[A-Za-z_]\\w*)', // 1 directive
'([A-Za-z_]\\w*)(?=:)', // 2 label definition
'\\b(r(?:1[0-5]|\\d)|sp)\\b', // 3 register
'(-?\\b(?:0x[0-9a-fA-F]+|0b[01]+|\\d+)\\b)', // 4 number
'([A-Za-z_]\\w*)', // 5 identifier
'([\\[\\]+,:-])', // 6 punctuation
].join('|'),
'gi',
);
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const span = (cls, text) => `<span class="${cls}">${esc(text)}</span>`;
/** Highlight one line of assembly, returning HTML. Exported for testing. */
export function highlightLine(line) {
const c = line.search(/[;#]/);
const code = c < 0 ? line : line.slice(0, c);
const comment = c < 0 ? '' : line.slice(c);
let out = '';
let pos = 0;
TOKEN.lastIndex = 0;
for (let m; (m = TOKEN.exec(code)); ) {
out += esc(code.slice(pos, m.index));
pos = m.index + m[0].length;
if (m[1]) out += span('hl-dir', m[0]);
else if (m[2]) out += span('hl-lbl', m[0]);
else if (m[3]) out += span('hl-reg', m[0]);
else if (m[4]) out += span('hl-num', m[0]);
else if (m[5]) out += span(MNEMONICS.has(m[5].toLowerCase()) ? 'hl-mn' : 'hl-sym', m[0]);
else out += span('hl-pun', m[0]);
}
out += esc(code.slice(pos));
if (comment) out += span('hl-cmt', comment);
return out;
}
export function createEditor({ value = '', onInput = () => {}, onCursor = () => {} } = {}) {
const gutter = h('div', { class: 'ed-gutter', 'aria-hidden': 'true' });
const hl = h('div', { class: 'ed-hl', 'aria-hidden': 'true' });
const input = h('textarea', {
class: 'ed-input',
spellcheck: 'false',
autocapitalize: 'off',
autocomplete: 'off',
autocorrect: 'off',
wrap: 'off',
'aria-label': 'Assembly source code',
});
const el = h('div', { class: 'editor' }, gutter, h('div', { class: 'ed-body' }, hl, input));
let errorLine = null;
let escaped = false; // Escape pressed: let the next Tab leave the editor
function render() {
const lines = input.value.split('\n');
hl.innerHTML = lines
.map((l, i) => `<div class="ed-line${i + 1 === errorLine ? ' err' : ''}">${highlightLine(l) || ' '}</div>`)
.join('');
gutter.innerHTML = lines
.map((_, i) => `<div class="ed-num${i + 1 === errorLine ? ' err' : ''}">${i + 1}</div>`)
.join('');
syncScroll();
}
function syncScroll() {
hl.scrollTop = gutter.scrollTop = input.scrollTop;
hl.scrollLeft = input.scrollLeft;
}
function cursor() {
const upTo = input.value.slice(0, input.selectionStart);
const line = upTo.split('\n').length;
const col = upTo.length - upTo.lastIndexOf('\n');
onCursor({ line, col });
}
input.addEventListener('input', () => {
errorLine = null;
render();
onInput(input.value);
});
input.addEventListener('scroll', syncScroll);
for (const ev of ['keyup', 'click', 'focus']) input.addEventListener(ev, cursor);
input.addEventListener('blur', () => { escaped = false; });
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { escaped = true; return; }
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !escaped) {
e.preventDefault();
input.setRangeText(' ', input.selectionStart, input.selectionEnd, 'end');
input.dispatchEvent(new Event('input'));
}
});
input.value = value;
render();
return {
el,
get value() { return input.value; },
set value(v) { input.value = v; errorLine = null; render(); onInput(v); },
insertAtTop(text) {
input.value = text + input.value;
errorLine = null;
render();
onInput(input.value);
},
setError(line) {
errorLine = line;
render();
if (line) {
const lh = parseFloat(getComputedStyle(input).lineHeight) || 22;
input.scrollTop = Math.max(0, (line - 3) * lh);
syncScroll();
}
},
focus() { input.focus(); },
render, // re-measure after the element is attached
};
}