diff --git a/ts/index.html b/ts/index.html index bdbfa6e..0ed9c06 100644 --- a/ts/index.html +++ b/ts/index.html @@ -95,519 +95,7 @@ - + diff --git a/ts/package.json b/ts/package.json index 1b8399c..dc17b66 100644 --- a/ts/package.json +++ b/ts/package.json @@ -3,7 +3,9 @@ "module": "src/index.ts", "type": "module", "scripts": { - "build": "bun build src/browser.ts --target browser --outfile public/mono-display.js", + "build_monoformat": "bun build src/browser.ts --target browser --outfile public/mono-display.js", + "build_web": "bun build src/web.ts --target browser --outfile public/web.js", + "build": "bun build_monoformat && bun build_web", "test": "bun test" }, "devDependencies": { diff --git a/ts/src/driver.ts b/ts/src/driver.ts index 60b11da..e9004b7 100644 --- a/ts/src/driver.ts +++ b/ts/src/driver.ts @@ -64,7 +64,7 @@ export class MonoDisplayDriver { if (!el || el.tagName !== "CANVAS") throw new Error(`#${canvasId} is not a `); this.canvas = el as HTMLCanvasElement; this.opts = { - onColor: options.onColor ?? "#ffffff", + onColor: options.onColor ?? "#EC0", offColor: options.offColor ?? "#000000", scale: options.scale ?? 1, displayWidth: options.displayWidth ?? 120, diff --git a/ts/src/web.ts b/ts/src/web.ts new file mode 100644 index 0000000..bdacece --- /dev/null +++ b/ts/src/web.ts @@ -0,0 +1,512 @@ + +const W = 120, H = 60; +let sections = []; +let activeSec = null, activeEl = null; +let secCounter = 0, elCounter = 0; +let currentFilename = 'untitled'; +let isDirty = false; + +// --- Element type definitions ------------------------------------------------ +const EL_TYPES = ['Image2D', 'Animation', 'ClippedText', 'HScrollText', 'CurrentTime']; +const EL_FIELDS = { + Image2D: [ + { k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 0 }, + { k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: H }, + ], + Animation: [ + { k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 0 }, + { k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: H }, + { k: 'updateInterval', l: 'Update interval', t: 'number', d: 12 }, + ], + ClippedText: [ + { k: 'text', l: 'Text', t: 'text', d: 'Hello, World!', full: true }, + { k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 32 }, + { k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: 16 }, + ], + HScrollText: [ + { k: 'text', l: 'Text', t: 'text', d: 'Scrolling text - ', full: true }, + { k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 32 }, + { k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: 16 }, + { k: 'scrollSpeed', l: 'Scroll speed', t: 'number', d: 50 }, + ], + CurrentTime: [ + { k: 'xOffset', l: 'X offset', t: 'number', d: 0 }, { k: 'yOffset', l: 'Y offset', t: 'number', d: 8 }, + { k: 'width', l: 'Width', t: 'number', d: W }, { k: 'height', l: 'Height', t: 'number', d: 16 }, + { k: 'utcOffsetMinutes', l: 'UTC offset (min)', t: 'number', d: 0 }, + ], +}; +const EL_FLAGS = { + Image2D: [], Animation: [], ClippedText: [], + HScrollText: [{ k: 'endless', l: 'Endless' }, { k: 'invertDirection', l: 'Invert direction' }], + CurrentTime: [{ k: 'clock12h', l: '12h mode' }, { k: 'showHours', l: 'Show hours' }, { k: 'showSeconds', l: 'Show seconds' }], +}; + +// --- Confirm dialog ----------------------------------------------------------- +function confirm(msg, buttons) { + // buttons: [{label,primary,action}] + return new Promise(resolve => { + const overlay = document.getElementById('confirm-overlay'); + document.getElementById('confirm-msg').textContent = msg; + const btns = document.getElementById('confirm-btns'); + btns.innerHTML = ''; + buttons.forEach(b => { + const el = document.createElement('button'); + el.className = 'cb' + (b.primary ? ' primary' : ''); + el.textContent = b.label; + el.onclick = () => { overlay.classList.remove('show'); resolve(b.action); }; + btns.appendChild(el); + }); + overlay.classList.add('show'); + }); +} + +// --- Dirty tracking ----------------------------------------------------------- +function markDirty() { + isDirty = true; + document.getElementById('filename').classList.add('dirty'); +} +function markClean() { + isDirty = false; + document.getElementById('filename').classList.remove('dirty'); +} +async function guardDirty() { + if (!isDirty || !sections.length) return true; + const action = await confirm( + 'You have unsaved changes. Loading a demo will replace the current editor state.', + [{ label: 'Cancel', action: false }, { label: 'Load anyway', primary: true, action: true }] + ); + return action; +} + +// --- Helpers ----------------------------------------------------------------- +function newSec() { + secCounter++; + return { + id: 's' + secCounter, name: 'Section ' + secCounter, open: true, + flags: { drawFront: true, clearBuffer: true }, elements: [] + }; +} +function newEl(type) { + elCounter++; + const fields = {}; + (EL_FIELDS[type] || []).forEach(f => fields[f.k] = f.d); + const flags = {}; + (EL_FLAGS[type] || []).forEach(f => flags[f.k] = false); + return { id: 'e' + elCounter, type, fields, flags }; +} +function getSec(id) { return sections.find(s => s.id === id) } +function getEl(secId, elId) { const s = getSec(secId); return s && s.elements.find(e => e.id === elId) } + +// --- Mutations ---------------------------------------------------------------- +function addSection() { + const s = newSec(); sections.push(s); + activeSec = s.id; activeEl = null; + markDirty(); render(); triggerPreview(); +} +function removeSection(id) { + sections = sections.filter(s => s.id !== id); + if (activeSec === id) { activeSec = sections.length ? sections[sections.length - 1].id : null; activeEl = null; } + markDirty(); render(); triggerPreview(); +} +function toggleSection(id) { + const s = getSec(id); if (!s) return; + s.open = !s.open; activeSec = id; render(); updateMeta(); +} +function setSectionFlag(flag, val) { + if (!activeSec) return; + const s = getSec(activeSec); if (!s) return; + s.flags[flag] = val; markDirty(); triggerPreview(); +} +function addElement(secId) { + const s = getSec(secId); if (!s) return; + const el = newEl(EL_TYPES[0]); s.elements.push(el); + activeSec = secId; activeEl = { secId, elId: el.id }; + markDirty(); render(); triggerPreview(); +} +function removeElement(secId, elId) { + const s = getSec(secId); if (!s) return; + s.elements = s.elements.filter(e => e.id !== elId); + if (activeEl && activeEl.secId === secId && activeEl.elId === elId) activeEl = null; + markDirty(); render(); triggerPreview(); +} +function selectElement(secId, elId) { + activeSec = secId; + activeEl = activeEl && activeEl.secId === secId && activeEl.elId === elId ? null : { secId, elId }; + render(); +} +function changeElType(secId, elId, type) { + const el = getEl(secId, elId); if (!el) return; + const fresh = newEl(type); + el.type = type; el.fields = fresh.fields; el.flags = fresh.flags; + markDirty(); render(); triggerPreview(); +} +function setElField(secId, elId, key, val) { + const el = getEl(secId, elId); if (!el) return; + el.fields[key] = val; markDirty(); triggerPreview(); +} +function setElFlag(secId, elId, key, val) { + const el = getEl(secId, elId); if (!el) return; + el.flags[key] = val; markDirty(); triggerPreview(); +} + +// --- Load demo into editor state --------------------------------------------- +const DEMO_DEFS = [ + { + label: 'Checkerboard', build() { + const s = newSec(); s.name = 'Checkerboard'; + const el = newEl('Image2D'); + s.elements.push(el); + s.flags = { drawFront: true, clearBuffer: false }; + return [s]; + } + }, + { + label: 'Blink', build() { + const s = newSec(); s.name = 'Blink'; s.flags = { drawFront: true, clearBuffer: true }; + const el = newEl('Animation'); el.fields.updateInterval = 12; + s.elements.push(el); return [s]; + } + }, + { + label: 'Text', build() { + const s = newSec(); s.name = 'Text'; s.flags = { drawFront: true, clearBuffer: true }; + const el = newEl('ClippedText'); el.fields.text = 'Hello, World!'; el.fields.yOffset = 32; + s.elements.push(el); return [s]; + } + }, + { + label: 'Scrolltext', build() { + const s = newSec(); s.name = 'Scrolltext'; s.flags = { drawFront: true, clearBuffer: true }; + const el = newEl('HScrollText'); + el.fields.text = 'MONO DISPLAY - scrolling ticker - ๐Ÿš€ '; + el.fields.yOffset = 32; el.fields.scrollSpeed = 50; + el.flags.endless = true; el.flags.invertDirection = false; + s.elements.push(el); return [s]; + } + }, + { + label: 'Time', build() { + const s = newSec(); s.name = 'Time'; s.flags = { drawFront: true, clearBuffer: true }; + const defs = [ + { flags: {}, yOffset: 8 }, { flags: { clock12h: true }, xOffset: 40, yOffset: 16 }, + { flags: { clock12h: true, showHours: true }, yOffset: 24 }, + { flags: { clock12h: true, showHours: false }, xOffset: 40, yOffset: 32 }, + { flags: { clock12h: true, showSeconds: true }, xOffset: 120, yOffset: 32 }, + ]; + defs.forEach(d => { + const el = newEl('CurrentTime'); + Object.assign(el.fields, { utcOffsetMinutes: 120, xOffset: d.xOffset || 0, yOffset: d.yOffset }); + el.flags = d.flags; s.elements.push(el); + }); + return [s]; + } + }, +]; + +// --- Preview via MonoDisplayFile (same as original demos) -------------------- +const DEMO_PREVIEWS = { + Checkerboard() { + const { MonoDisplayFile, ElementType } = window.MonoDisplay; + const pixels = new Uint8Array(W * H); + for (let y = 0; y < H; y++)for (let x = 0; x < W; x++)pixels[y * W + x] = (x + y) % 2; + return new MonoDisplayFile({ + width: W, height: H, + elements_always: [{ type: ElementType.Image2D, pixels, width: W, height: H }] + }).toBuffer(); + }, + Blink() { + const { MonoDisplayFile, ElementType } = window.MonoDisplay; + return new MonoDisplayFile({ + elements_always: { + flags: { drawFront: true, clearBuffer: true }, + elements: [{ + type: ElementType.Animation, width: W, height: H, updateInterval: 12, + frames: [{ pixels: new Uint8Array(W * H).fill(1) }, { pixels: new Uint8Array(W * H).fill(0) }] + }] + } + }).toBuffer(); + }, + Text() { + const { MonoDisplayFile, ElementType } = window.MonoDisplay; + return new MonoDisplayFile({ + elements_always: { + flags: { drawFront: true, clearBuffer: true }, + elements: [{ type: ElementType.ClippedText, text: 'Hello, World!', xOffset: 0, yOffset: 32, width: W, height: 16 }] + } + }).toBuffer(); + }, + Scrolltext() { + const { MonoDisplayFile, ElementType } = window.MonoDisplay; + return new MonoDisplayFile({ + elements_always: { + flags: { drawFront: true, clearBuffer: true }, + elements: [{ + type: ElementType.HScrollText, text: 'MONO DISPLAY - scrolling ticker - ๐Ÿš€ ', + xOffset: 0, yOffset: 32, width: W, height: 16, scrollSpeed: 50, flags: { endless: true, invertDirection: false } + }] + } + }).toBuffer(); + }, + Time() { + const { MonoDisplayFile, ElementType } = window.MonoDisplay; + return new MonoDisplayFile({ + elements_always: { + flags: { drawFront: true, clearBuffer: true }, elements: [ + { flags: {}, type: ElementType.CurrentTime, xOffset: 0, yOffset: 8, width: W, height: 16, utcOffsetMinutes: 120 }, + { flags: { clock12h: true }, type: ElementType.CurrentTime, xOffset: 40, yOffset: 16, width: W, height: 16, utcOffsetMinutes: 120 }, + { flags: { clock12h: true, showHours: true }, type: ElementType.CurrentTime, xOffset: 0, yOffset: 24, width: W, height: 16, utcOffsetMinutes: 120 }, + { flags: { clock12h: true, showHours: false }, type: ElementType.CurrentTime, xOffset: 40, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 }, + { flags: { clock12h: true, showSeconds: true }, type: ElementType.CurrentTime, xOffset: 80, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 }, + ] + } + }).toBuffer(); + }, +}; + +function initDemos() { + const wrap = document.getElementById('demo-btns'); + let activeBtn = null; + DEMO_DEFS.forEach(d => { + const btn = document.createElement('button'); + btn.className = 'tb-btn'; btn.textContent = d.label; + btn.onclick = async () => { + const ok = await guardDirty(); + if (!ok) return; + // load into editor + sections = d.build(); + activeSec = sections[0]?.id || null; activeEl = null; + currentFilename = d.label.toLowerCase(); + document.getElementById('filename').textContent = currentFilename; + markClean(); render(); + // drive preview with full fidelity build + if (!window._mdDriver) + window._mdDriver = new window.MonoDisplay.MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 }); + window._mdDriver.load(() => Promise.resolve(DEMO_PREVIEWS[d.label]())); + if (activeBtn) activeBtn.style.color = ''; + btn.style.color = '#33ff66'; activeBtn = btn; + }; + wrap.appendChild(btn); + }); +} + +// --- Render ------------------------------------------------------------------- +function render() { + updateMeta(); + const wrap = document.getElementById('sections-wrap'); + if (!sections.length) { + wrap.innerHTML = '
No sections yet.
Use Add section to get started.
'; + return; + } + wrap.innerHTML = sections.map(s => renderSection(s)).join(''); +} + +function renderSection(s) { + const isActive = activeSec === s.id, isOpen = s.open; + return ` +
+
+โ–ถ +${esc(s.name)} +${s.elements.length} el + +
+${isOpen ? ` +
+${s.elements.map(el => renderElement(s.id, el)).join('')} + +
`: ''} +
`; +} + +function renderElement(secId, el) { + const isActive = activeEl && activeEl.secId === secId && activeEl.elId === el.id; + const fields = EL_FIELDS[el.type] || []; + const flags = EL_FLAGS[el.type] || []; + return ` +
+
+${el.type} +${elSummary(el)} + +
+${isActive ? ` +
+
+ + +
+
+ ${fields.map(f => ` +
+ + ${f.t === 'text' + ? `` + : `` + } +
`).join('')} +
+${flags.length ? ` +
+ +
+ ${flags.map(f => ` + `).join('')} +
+
`: ''} +
`: ''} +
`; +} + +function elSummary(el) { + if (el.fields.text) return esc(el.fields.text.slice(0, 24) + (el.fields.text.length > 24 ? '...' : '')); + return `${el.fields.xOffset ?? 0}, ${el.fields.yOffset ?? 0}`; +} +function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } + +function updateMeta() { + const s = activeSec ? getSec(activeSec) : null; + document.getElementById('meta-name').textContent = s ? s.name : '-'; + document.getElementById('meta-count').textContent = s ? s.elements.length + ' element(s)' : '-'; + document.getElementById('flag-drawFront').checked = s ? !!s.flags.drawFront : false; + document.getElementById('flag-clearBuffer').checked = s ? !!s.flags.clearBuffer : false; +} + +// --- Preview ------------------------------------------------------------------ +let previewTimer = null; +function triggerPreview() { + clearTimeout(previewTimer); + previewTimer = setTimeout(() => { try { buildPreview(); } catch (e) { console.warn('preview', e); } }, 150); +} +function buildPreview() { + if (!window.MonoDisplay) return; + const { MonoDisplayDriver, MonoDisplayFile, ElementType } = window.MonoDisplay; + if (!window._mdDriver) + window._mdDriver = new MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 }); + const s = activeSec ? getSec(activeSec) : sections[0]; + if (!s || !s.elements.length) { + window._mdDriver.load(() => Promise.resolve(new MonoDisplayFile({ elements_always: [] }).toBuffer())); + return; + } + const elDefs = s.elements.map(el => ({ type: ElementType[el.type], ...el.fields, flags: el.flags })).filter(e => e.type != null); + window._mdDriver.load(() => Promise.resolve( + new MonoDisplayFile({ elements_always: { flags: s.flags, elements: elDefs } }).toBuffer() + )); +} + +// --- Load / Export ------------------------------------------------------------ +function parsedToSections(parsedSecs) { + const TYPE_MAP = { 1: 'Image2D', 2: 'Animation', 16: 'ClippedText', 17: 'HScrollText', 32: 'CurrentTime' }; + return parsedSecs + .filter(s => s.sectionType === 1 || s.sectionType === 2) + .map(s => { + secCounter++; + const elements = (s.elements || []).map(el => { + const typeName = TYPE_MAP[el.type]; + if (!typeName) return null; + elCounter++; + let fields = {}, flags = {}; + switch (typeName) { + case 'Image2D': + fields = { xOffset: el.xOffset, yOffset: el.yOffset, width: el.image?.width ?? W, height: el.image?.height ?? H, pixels: el.image?.pixels }; + break; + case 'Animation': + fields = { xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height, updateInterval: el.updateInterval, frames: el.frames }; + break; + case 'ClippedText': + fields = { text: el.text, xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height }; + break; + case 'HScrollText': + fields = { text: el.text, xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height, scrollSpeed: el.scrollSpeed }; + flags = { endless: !!el.flags?.endless, invertDirection: !!el.flags?.invertDirection }; + break; + case 'CurrentTime': + fields = { xOffset: el.xOffset, yOffset: el.yOffset, width: el.width, height: el.height, utcOffsetMinutes: el.utcOffsetMinutes }; + flags = { clock12h: !!el.flags?.clock12h, showHours: !!el.flags?.showHours, showSeconds: !!el.flags?.showSeconds }; + break; + } + return { id: 'e' + elCounter, type: typeName, fields, flags }; + }).filter(Boolean); + return { + id: 's' + secCounter, + name: 'Section ' + secCounter, + open: true, + flags: { drawFront: !!s.flags?.drawFront, clearBuffer: !!s.flags?.clearBuffer }, + elements + }; + }); +} + +function loadBin(input) { + const file = input.files[0]; if (!file) return; + currentFilename = file.name; + document.getElementById('filename').textContent = file.name; + const reader = new FileReader(); + reader.onload = e => { + const arrayBuf = e.target.result; + const buf = new Uint8Array(arrayBuf); + if (!window._mdDriver && window.MonoDisplay) + window._mdDriver = new window.MonoDisplay.MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 }); + if (window._mdDriver) window._mdDriver.load(() => Promise.resolve(buf)); + try { + const parsed = new window.MonoDisplay.MonoDisplayParser().parse(arrayBuf); + sections = parsedToSections(parsed.sections); + activeSec = sections.length ? sections[0].id : null; + activeEl = null; + render(); + } catch (err) { + console.warn('Could not parse sections from bin:', err); + } + }; + reader.readAsArrayBuffer(file); + input.value = ''; markClean(); +} +function exportBin() { + if (!window.MonoDisplay) { alert('MonoDisplay library not loaded.'); return; } + const { MonoDisplayFile, ElementType } = window.MonoDisplay; + const s = activeSec ? getSec(activeSec) : sections[0]; + if (!s) { alert('Nothing to export.'); return; } + const elDefs = s.elements.map(el => ({ type: ElementType[el.type], ...el.fields, flags: el.flags })).filter(e => e.type != null); + const buf = new MonoDisplayFile({ elements_always: { flags: s.flags, elements: elDefs } }).toBuffer(); + const blob = new Blob([buf], { type: 'application/octet-stream' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = currentFilename.replace(/\.bin$/, '') + '.bin'; + a.click(); markClean(); +} + +// --- Theme -------------------------------------------------------------------- +var _THEMES = ['dark', 'light', 'auto']; +function _getTheme() { return document.documentElement.getAttribute('data-theme') || 'auto'; } +function _applyTheme(t) { + if (t === 'auto') { document.documentElement.removeAttribute('data-theme'); localStorage.removeItem('theme'); } + else { document.documentElement.setAttribute('data-theme', t); localStorage.setItem('theme', t); } + _updateThemeBtn(); +} +function cycleTheme() { + var cur = _getTheme(), idx = _THEMES.indexOf(cur); + _applyTheme(_THEMES[(idx + 1) % _THEMES.length]); +} +function _detectDR() { + return !!(document.querySelector('meta[name="darkreader"]') || + document.querySelector('style[data-darkreader-style]') || + document.documentElement.getAttribute('data-darkreader-mode')); +} +function _updateThemeBtn() { + var btn = document.getElementById('theme-toggle'); + if (!btn) return; + var t = _getTheme(), dr = _detectDR(); + var lbl = { dark: 'โ˜พ dark', light: 'โ˜€ light', auto: 'โŠ™ auto' }; + btn.textContent = (dr ? 'DR ยท ' : '') + (lbl[t] || lbl.auto); + btn.title = dr ? 'DarkReader active - controlling theme' : ('Theme: ' + t + ' (click to cycle)'); +} +window.addEventListener('DOMContentLoaded', _updateThemeBtn); + +// --- Init --------------------------------------------------------------------- +render(); +if (window.MonoDisplay) { initDemos(); } +else { const iv = setInterval(() => { if (window.MonoDisplay) { clearInterval(iv); initDemos(); } }, 100); }