Compare commits

..

No commits in common. '85ca7dd31844381b8fb4feb6f11b4bf93a59afb2' and '45617022f7e2313190493678dbb7ec27ba34d598' have entirely different histories.

  1. 962
      ts/index.html
  2. 622
      ts/src/browser.ts
  3. 2
      ts/src/driver.ts
  4. 166
      ts/src/file.ts
  5. 26
      ts/src/themes.ts
  6. 219
      ts/src/types.ts
  7. 624
      ts/style.css

@ -5,8 +5,519 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>MonoDisplay Editor</title> <title>MonoDisplay Editor</title>
<script>!function(){var t=localStorage.getItem('theme');t&&document.documentElement.setAttribute('data-theme',t)}();</script> <style>
<link href="style.css" rel="stylesheet"/> *,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0
}
body {
background: #111;
color: #ccc;
font-family: monospace;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 12px
}
#topbar {
height: 36px;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
flex-shrink: 0
}
#topbar .app-name {
color: #555;
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
margin-right: 4px
}
.tb-btn {
background: #161616;
color: #888;
border: 1px solid #2d2d2d;
border-radius: 3px;
padding: 3px 10px;
font-family: monospace;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap
}
.tb-btn:hover {
background: #222;
color: #bbb;
border-color: #444
}
.tb-btn svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0
}
#file-input {
display: none
}
#filename {
color: #3a3a3a;
font-size: 11px;
margin-left: 2px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
.tb-sep {
width: 1px;
height: 18px;
background: #2a2a2a
}
.dirty {
color: #886622 !important
}
#main {
flex: 1;
display: flex;
min-height: 0
}
#left {
width: 48%;
border-right: 1px solid #1e1e1e;
display: flex;
flex-direction: column;
padding: 14px;
gap: 10px;
overflow-y: auto;
flex-shrink: 0
}
#display-box {
width: 100%;
aspect-ratio: 2/1;
background: #000;
border: 1px solid #2a2a2a;
border-radius: 3px;
overflow: hidden
}
#canvas_root {
width: 100%;
height: 100%;
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges
}
.pv-label {
font-size: 10px;
color: #333;
letter-spacing: .1em;
text-transform: uppercase
}
#demo-btns {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px
}
#sec-meta {
background: #141414;
border: 1px solid #1e1e1e;
border-radius: 3px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px
}
.meta-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px
}
.meta-row label {
color: #444;
min-width: 90px
}
.meta-val {
color: #666
}
.green {
color: #33ff66
}
.flag-check {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #555;
cursor: pointer
}
.flag-check input {
accent-color: #33ff66;
cursor: pointer
}
#right {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden
}
#right-hdr {
height: 36px;
background: #141414;
border-bottom: 1px solid #1e1e1e;
display: flex;
align-items: center;
padding: 0 10px;
gap: 6px;
flex-shrink: 0
}
#right-hdr .rh-title {
flex: 1;
color: #444;
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase
}
#sections-wrap {
flex: 1;
overflow-y: auto;
padding: 8px
}
.empty-state {
color: #2e2e2e;
font-size: 11px;
padding: 28px 16px;
text-align: center;
line-height: 2
}
/* section card */
.sec-card {
border: 1px solid #222;
border-radius: 3px;
margin-bottom: 5px;
overflow: hidden
}
.sec-card.active {
border-color: #2b3d2b
}
.sec-hdr {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 8px;
cursor: pointer;
user-select: none;
background: #161616;
position: relative
}
.sec-hdr:hover {
background: #1b1b1b
}
.sec-card.active .sec-hdr {
background: #182018
}
.sec-arrow {
color: #333;
font-size: 13px;
line-height: 1;
transition: transform .12s;
flex-shrink: 0
}
.sec-card.open .sec-arrow {
transform: rotate(90deg)
}
.sec-label {
flex: 1;
color: #777;
font-size: 11px
}
.sec-badge {
font-size: 10px;
color: #33ff66;
border: 1px solid #2b3d2b;
background: #141e14;
border-radius: 2px;
padding: 1px 6px;
flex-shrink: 0
}
/* red × — section & element */
.x-btn {
background: none;
border: none;
color: #2a2a2a;
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 2px 5px;
border-radius: 2px;
flex-shrink: 0;
transition: color .1s, background .1s
}
.x-btn:hover {
color: #ff4444;
background: #1e1010
}
/* element */
.el-list {
background: #0f0f0f;
border-top: 1px solid #1c1c1c;
padding: 6px
}
.el-item {
border: 1px solid #1e1e1e;
border-radius: 3px;
background: #141414;
margin-bottom: 4px;
overflow: hidden
}
.el-item.active {
border-color: #33ff66
}
.el-item-hdr {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
cursor: pointer
}
.el-item-hdr:hover {
background: #1a1a1a
}
.el-type {
font-size: 10px;
color: #666;
border: 1px solid #252525;
border-radius: 2px;
padding: 1px 6px;
flex-shrink: 0
}
.el-item.active .el-type {
color: #33ff66;
border-color: #2b3d2b
}
.el-name {
flex: 1;
color: #555;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px
}
.el-fields {
padding: 6px 8px 8px;
border-top: 1px solid #1a1a1a;
display: flex;
flex-direction: column;
gap: 5px
}
.fields-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 10px
}
.field {
display: flex;
flex-direction: column;
gap: 2px
}
.field.full {
grid-column: 1/-1
}
.field>label {
font-size: 10px;
color: #444
}
.field input[type=text],
.field input[type=number],
.field select,
.field textarea {
background: #0a0a0a;
color: #999;
border: 1px solid #222;
border-radius: 2px;
padding: 3px 6px;
font-family: monospace;
font-size: 11px;
width: 100%;
outline: none
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: #33ff66;
color: #ccc
}
.field textarea {
resize: vertical;
min-height: 44px;
line-height: 1.4
}
.field select option {
background: #1a1a1a;
color: #aaa
}
.flags-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 2px 0
}
.add-el {
background: transparent;
color: #333;
border: 1px dashed #222;
border-radius: 3px;
padding: 5px 8px;
font-family: monospace;
font-size: 11px;
cursor: pointer;
width: 100%;
text-align: center;
margin-top: 2px
}
.add-el:hover {
color: #888;
border-color: #444
}
/* confirm dialog */
#confirm-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .6);
z-index: 100;
align-items: center;
justify-content: center
}
#confirm-overlay.show {
display: flex
}
#confirm-box {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 20px 24px;
max-width: 340px;
width: 90%;
display: flex;
flex-direction: column;
gap: 14px
}
#confirm-msg {
color: #aaa;
font-size: 12px;
line-height: 1.6
}
#confirm-btns {
display: flex;
gap: 8px;
justify-content: flex-end
}
.cb {
background: #161616;
color: #888;
border: 1px solid #2d2d2d;
border-radius: 3px;
padding: 4px 14px;
font-family: monospace;
font-size: 11px;
cursor: pointer
}
.cb:hover {
background: #222;
color: #bbb
}
.cb.primary {
border-color: #2b3d2b;
color: #33ff66
}
.cb.primary:hover {
background: #182018
}
</style>
</head> </head>
<body> <body>
@ -40,9 +551,6 @@
Add section Add section
</button> </button>
<span id="filename">untitled</span> <span id="filename">untitled</span>
<div style="flex:1"></div>
<div class="tb-sep"></div>
<button class="tb-btn" id="theme-toggle" onclick="cycleTheme()">⊙ auto</button>
</div> </div>
<div id="main"> <div id="main">
@ -56,11 +564,11 @@
<div id="sec-meta"> <div id="sec-meta">
<div class="meta-row"> <div class="meta-row">
<label>Selected section</label> <label>Selected section</label>
<span class="meta-val green" id="meta-name">-</span> <span class="meta-val green" id="meta-name"></span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<label>Elements</label> <label>Elements</label>
<span class="meta-val" id="meta-count">-</span> <span class="meta-val" id="meta-count"></span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<label>Section flags</label> <label>Section flags</label>
@ -68,11 +576,7 @@
<input type="checkbox" id="flag-drawFront" onchange="setSectionFlag('drawFront',this.checked)" /> <input type="checkbox" id="flag-drawFront" onchange="setSectionFlag('drawFront',this.checked)" />
drawFront drawFront
</label> </label>
<label class="flag-check" style="margin-left:-8px"> <label class="flag-check" style="margin-left:8px">
<input type="checkbox" id="flag-drawBack" onchange="setSectionFlag('drawBack',this.checked)" />
drawBack
</label>
<label class="flag-check" style="margin-left:-8px">
<input type="checkbox" id="flag-clearBuffer" onchange="setSectionFlag('clearBuffer',this.checked)" /> <input type="checkbox" id="flag-clearBuffer" onchange="setSectionFlag('clearBuffer',this.checked)" />
clearBuffer clearBuffer
</label> </label>
@ -99,6 +603,440 @@
</div> </div>
<script src="./public/mono-display.js"></script> <script src="./public/mono-display.js"></script>
<script>
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: false, 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: 120, 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 = '<div class="empty-state">No sections yet.<br/>Use <b>Add section</b> to get started.</div>';
return;
}
wrap.innerHTML = sections.map(s => renderSection(s)).join('');
}
function renderSection(s) {
const isActive = activeSec === s.id, isOpen = s.open;
return `
<div class="sec-card${isActive ? ' active' : ''}${isOpen ? ' open' : ''}" id="sc-${s.id}">
<div class="sec-hdr${isActive ? ' active' : ''}" onclick="toggleSection('${s.id}')">
<span class="sec-arrow"></span>
<span class="sec-label">${esc(s.name)}</span>
<span class="sec-badge">${s.elements.length} el</span>
<button class="x-btn" title="Remove section" onclick="event.stopPropagation();removeSection('${s.id}')">×</button>
</div>
${isOpen ? `
<div class="el-list">
${s.elements.map(el => renderElement(s.id, el)).join('')}
<button class="add-el" onclick="addElement('${s.id}')">+ add element</button>
</div>`: ''}
</div>`;
}
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 `
<div class="el-item${isActive ? ' active' : ''}" id="eli-${el.id}">
<div class="el-item-hdr" onclick="selectElement('${secId}','${el.id}')">
<span class="el-type">${el.type}</span>
<span class="el-name">${elSummary(el)}</span>
<button class="x-btn" title="Remove element" onclick="event.stopPropagation();removeElement('${secId}','${el.id}')">×</button>
</div>
${isActive ? `
<div class="el-fields">
<div class="field full">
<label>Type</label>
<select onchange="changeElType('${secId}','${el.id}',this.value)">
${EL_TYPES.map(t => `<option value="${t}"${t === el.type ? ' selected' : ''}>${t}</option>`).join('')}
</select>
</div>
<div class="fields-grid">
${fields.map(f => `
<div class="field${f.full ? ' full' : ''}">
<label>${f.l}</label>
${f.t === 'text'
? `<textarea onchange="setElField('${secId}','${el.id}','${f.k}',this.value)">${esc(el.fields[f.k] ?? f.d)}</textarea>`
: `<input type="number" value="${el.fields[f.k] ?? f.d}" onchange="setElField('${secId}','${el.id}','${f.k}',+this.value)"/>`
}
</div>`).join('')}
</div>
${flags.length ? `
<div class="field full">
<label>Flags</label>
<div class="flags-row">
${flags.map(f => `
<label class="flag-check">
<input type="checkbox" ${el.flags[f.k] ? 'checked' : ''} onchange="setElFlag('${secId}','${el.id}','${f.k}',this.checked)"/>
${f.l}
</label>`).join('')}
</div>
</div>`: ''}
</div>`: ''}
</div>`;
}
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
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 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 buf = new Uint8Array(e.target.result);
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));
};
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();
}
// ─── Init ─────────────────────────────────────────────────────────────────────
render();
if (window.MonoDisplay) { initDemos(); }
else { const iv = setInterval(() => { if (window.MonoDisplay) { clearInterval(iv); initDemos(); } }, 100); }
</script>
</body> </body>
</html> </html>

@ -4,628 +4,6 @@
// without ES module syntax. // without ES module syntax.
import * as MonoDisplay from "./index"; import * as MonoDisplay from "./index";
import { MonoDisplayFile } from "./file";
import { cycleTheme } from "./themes";
import { ElementType } from "./types";
// Expose on globalThis (= window in browser, globalThis elsewhere) // Expose on globalThis (= window in browser, globalThis elsewhere)
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay; (globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay;
const W = 120, H = 60;
let file: MonoDisplayFile = new MonoDisplayFile([]);
let activeSecIndex:number|null = null, activeElIndex:number|null = null;
let currentFilename = 'untitled';
let isDirty = false;
// --- Element type definitions ------------------------------------------------
type ElFieldMeta = { key: string; label: string; type: string; default: any; full?: boolean };
type ElFlagMeta = { key: string; label: string };
const EL_FIELDS: Partial<Record<keyof typeof ElementType, ElFieldMeta[]>> = {
Image2D: [
{ key: 'xOffset', label: 'X offset', type: 'number', default: 0 }, { key: 'yOffset', label: 'Y offset', type: 'number', default: 0 },
{ key: 'width', label: 'Width', type: 'number', default: W }, { key: 'height', label: 'Height', type: 'number', default: H },
],
Animation: [
{ key: 'xOffset', label: 'X offset', type: 'number', default: 0 }, { key: 'yOffset', label: 'Y offset', type: 'number', default: 0 },
{ key: 'width', label: 'Width', type: 'number', default: W }, { key: 'height', label: 'Height', type: 'number', default: H },
{ key: 'updateInterval', label: 'Update interval', type: 'number', default: 12 },
{ key: 'frames', label: 'Frames', type: 'list', default: [] },
],
ClippedText: [
{ key: 'text', label: 'Text', type: 'text', default: 'Hello, World!', full: true },
{ key: 'xOffset', label: 'X offset', type: 'number', default: 0 }, { key: 'yOffset', label: 'Y offset', type: 'number', default: 32 },
{ key: 'width', label: 'Width', type: 'number', default: W }, { key: 'height', label: 'Height', type: 'number', default: 16 },
{ key: 'fontIndex', label: 'Font', type: 'font', default: 'Font 0' },
],
HScrollText: [
{ key: 'text', label: 'Text', type: 'text', default: 'Scrolling text - ', full: true },
{ key: 'xOffset', label: 'X offset', type: 'number', default: 0 }, { key: 'yOffset', label: 'Y offset', type: 'number', default: 32 },
{ key: 'width', label: 'Width', type: 'number', default: W }, { key: 'height', label: 'Height', type: 'number', default: 16 },
{ key: 'scrollSpeed', label: 'Scroll speed', type: 'number', default: 50 },
{ key: 'fontIndex', label: 'Font', type: 'font', default: 'Font 0' },
],
VScrollText: [
{ key: 'text', label: 'Text', type: 'text', default: 'Scrolling text - ', full: true },
{ key: 'xOffset', label: 'X offset', type: 'number', default: 0 }, { key: 'yOffset', label: 'Y offset', type: 'number', default: 32 },
{ key: 'width', label: 'Width', type: 'number', default: W }, { key: 'height', label: 'Height', type: 'number', default: 16 },
{ key: 'scrollSpeed', label: 'Scroll speed', type: 'number', default: 50 },
{ key: 'fontIndex', label: 'Font', type: 'font', default: 'Font 0' },
],
CurrentTime: [
{ key: 'xOffset', label: 'X offset', type: 'number', default: 0 }, { key: 'yOffset', label: 'Y offset', type: 'number', default: 8 },
{ key: 'width', label: 'Width', type: 'number', default: W }, { key: 'height', label: 'Height', type: 'number', default: 16 },
{ key: 'utcOffsetMinutes', label: 'UTC offset (min)', type: 'number', default: 0 },
{ key: 'fontIndex', label: 'Font', type: 'font', default: 'Font 0' },
],
};
const EL_FLAGS: Partial<Record<keyof typeof ElementType, ElFlagMeta[]>> = {
HScrollText: [{ key: 'endless', label: 'Endless' }, { key: 'invertDirection', label: 'Invert direction' }],
CurrentTime: [{ key: 'clock12h', label: '12h mode' }, { key: 'showHours', label: 'Show hours' }, { key: 'showSeconds', label: 'Show seconds' }],
};
const EL_TYPES: ElementType[] = Object.keys(EL_FIELDS).map((x: string) => MonoDisplay.StringToElementType[x as MonoDisplay.ElementTypeName]);
const DEFAULT_FONTS = [
"Font 1",
"Font 2",
"Font 3",
"Font 4",
"Font 5",
]
// --- Confirm dialog -----------------------------------------------------------
function confirm(msg: string, buttons: {
primary?: boolean,
label: string,
action: boolean,
}[]) {
// buttons: [{label,primary,action}]
return new Promise(resolve => {
const overlay: HTMLElement | null = document.getElementById('confirm-overlay');
const confirm = document.getElementById('confirm-msg');
if(confirm) confirm.textContent = msg;
const btns = document.getElementById('confirm-btns');
if(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); };
if(btns) 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 || !file.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 getElementId(elementIndex: number): string {
return `Element-${elementIndex}`;
}
function getSectionId(sectionIndex: number): string {
return `Section-${sectionIndex}`;
}
function newSec() {
return {
sectionType: MonoDisplay.SectionType.ElementsAlways,
elements: [],
flags: {},
};
}
function newEl(type: MonoDisplay.ElementType) {
const fields = {};
(EL_FIELDS[type] || []).forEach(f => fields[f.k] = f.d);
const flags = {};
(EL_FLAGS[type] || []).forEach(f => flags[f.k] = false);
return { type, fields, flags };
}
function getSec(sectionIndex: number | null) {
return file.sections.find((s,index) => index === sectionIndex)
}
function getEl(sectionIndex: number, elementIndex: number) {
const section = getSec(sectionIndex);
return section && "elements" in section && section.elements.find((e, index) => index == elementIndex)
}
// --- Mutations ----------------------------------------------------------------
function addSection() {
const s = newSec(); file.sections.push(s);
activeSecIndex = file.sections.length-1; activeElIndex = null;
markDirty(); renderHTML(); triggerPreview();
}
function removeSection(sectionIndex:number) {
file.sections = file.sections.filter((section, index) => index !== sectionIndex);
if (activeSecIndex === sectionIndex) {
activeSecIndex = file.sections.length ? file.sections.length - 1 : null;
activeElIndex = null;
}
markDirty(); renderHTML(); triggerPreview();
}
function toggleSection(id) {
const s = getSec(id); if (!s) return;
s.open = !s.open; activeSecIndex = id; renderHTML(); updateMeta();
}
function setSectionFlag(flag, val) {
if (!activeSecIndex) return;
const s = getSec(activeSecIndex); if (!s) return;
s.flags[flag] = val; markDirty(); triggerPreview();
}
function addElement(sectionIndex:number) {
const s = getSec(secId); if (!s) return;
const el = newEl(EL_TYPES[0]); s.elements.push(el);
activeSecIndex = secId; activeElIndex = { secId, elId: el.id };
markDirty(); renderHTML(); triggerPreview();
}
function removeElement(sectionIndex:number, elementIndex:number) {
const s = getSec(sectionIndex); if (!s) return;
s.elements = s.elements.filter(e => e.id !== elId);
if (activeElIndex && activeElIndex.secId === secId && activeElIndex.elId === elId) activeElIndex = null;
markDirty(); renderHTML(); triggerPreview();
}
function selectElement(sectionIndex:number, elementIndex:number) {
activeSecIndex = sectionIndex;
activeElIndex = activeElIndex &&
activeElIndex === sectionIndex &&
activeElIndex === elementIndex ?
null : elementIndex;
renderHTML();
}
function changeElType(sectionIndex:number, elementIndex:number, typeString: string) {
const el = getEl(sectionIndex, elementIndex); if (!el) return;
const type = MonoDisplay.StringToElementType[typeString as MonoDisplay.ElementTypeName];
const fresh = newEl(type);
el.type = type; el.fields = fresh.fields; el.flags = fresh.flags;
markDirty(); renderHTML(); triggerPreview();
}
function setElField(sectionIndex:number, elementIndex:number, key:string, val:any) {
const el = getEl(sectionIndex, elementIndex); if (!el) return;
el[key] = val;
markDirty(); triggerPreview();
}
function setElFlag(sectionIndex:number, elementIndex:number, key:string, val:any) {
const el = getEl(sectionIndex, elementIndex); if (!el) return;
el.flags[key] = val;
markDirty(); triggerPreview();
}
function setSectionType(sectionIndex: number, sectionType: string) {
const sec = getSec(sectionIndex);
if (sec) {
sec.sectionType = MonoDisplay.StringToSectionType[sectionType as MonoDisplay.SectionTypeName];
if (sec.sectionType == MonoDisplay.SectionType.CustomFont) {
sec.fontData = new Uint8Array();
} else {
sec.elements = [];
sec.flags = {};
}
markDirty(); triggerPreview();
}
}
// --- Load demo into editor state ---------------------------------------------
const DEMO = {
Checkerboard() {
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(
[{
sectionType: MonoDisplay.SectionType.ElementsAlways,
elements: [{
type: ElementType.Image2D,
image: {
pixels,
height: H,
width: W,
},
xOffset: 0, yOffset: 0,
}],
flags: {
clearBuffer: true,
drawFront: true,
drawBack: true
}
}]);
},
Blink() {
return new MonoDisplayFile(
[{
sectionType: MonoDisplay.SectionType.ElementsAlways,
elements:[{
type: ElementType.Animation, width: W, height: H, updateInterval: 12,
xOffset: 0,
yOffset: 0,
frames: [{
pixels: new Uint8Array(W * H).fill(1),
width: 0,
height: 0
}, {
pixels: new Uint8Array(W * H).fill(0),
width: 0,
height: 0
}]
}],
flags: {
clearBuffer: true,
drawFront: true,
drawBack: true
}
}]);
},
Text() {
return new MonoDisplayFile(
[{
sectionType: MonoDisplay.SectionType.ElementsAlways,
elements: [{
type: ElementType.ClippedText,
fontIndex: 0,
text: 'Hello, World!',
xOffset: 0, yOffset: 32,
width: W, height: 16
}],
flags: {
clearBuffer: true,
drawFront: true,
drawBack: true
}
}]);
},
Scrolltext() {
return new MonoDisplayFile(
[{
sectionType: MonoDisplay.SectionType.ElementsAlways,
elements: [{
type: ElementType.HScrollText,
fontIndex: 0,
text: 'MONO DISPLAY - scrolling ticker - 🚀 ',
xOffset: 0, yOffset: 32,
width: W, height: 16,
scrollSpeed: 50,
flags: {
endless: true,
invertDirection: false,
padStart: false,
padEnd: false
}
}],
flags: {
clearBuffer: true,
drawFront: true,
drawBack: true
}
}]);
},
Time() {
return new MonoDisplayFile(
[{
sectionType: MonoDisplay.SectionType.ElementsAlways,
elements: [
{
type: ElementType.CurrentTime,
fontIndex: 0,
flags: {},
xOffset: 0, yOffset: 8, width: W, height: 16, utcOffsetMinutes: 120
},
{
type: ElementType.CurrentTime,
fontIndex: 0,
flags: { clock12h: true },
xOffset: 40, yOffset: 16, width: W, height: 16, utcOffsetMinutes: 120
},
{
type: ElementType.CurrentTime,
fontIndex: 0,
flags: { clock12h: true, showHours: true },
xOffset: 0, yOffset: 24, width: W, height: 16, utcOffsetMinutes: 120
},
{
type: ElementType.CurrentTime,
flags: { clock12h: true, showHours: false },
fontIndex: 0,
xOffset: 40, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120
},
{
type: ElementType.CurrentTime,
fontIndex: 0,
flags: { clock12h: true, showSeconds: true },
xOffset: 80, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120
},
],
flags: {
clearBuffer: true,
drawFront: true,
drawBack: true
}
}]);
},
};
function initDemos() {
const wrap = document.getElementById('demo-btns');
let activeBtn = null;
Object.entries(DEMO).forEach((entry: [demoName:string, demoFileFunc: () => MonoDisplayFile]) => {
const [demoName, demoFileFunc] = entry;
const btn = document.createElement('button');
btn.className = 'tb-btn'; btn.textContent = demoName;
btn.onclick = async () => {
const ok = await guardDirty();
if (!ok) return;
// load into editor
file = demoFileFunc();
activeSecIndex = null;
activeElIndex = null;
currentFilename = demoName.toLowerCase();
document.getElementById('filename').textContent = currentFilename;
markClean(); renderHTML();
triggerPreview();
if (activeBtn) activeBtn.style.color = '';
btn.style.color = '#33ff66'; activeBtn = btn;
};
wrap.appendChild(btn);
});
}
// --- Render -------------------------------------------------------------------
function renderHTML() {
updateMeta();
const wrap = document.getElementById('sections-wrap');
if (!file.sections.length) {
wrap.innerHTML = '<div class="empty-state">No sections yet.<br/>Use <b>Add section</b> to get started.</div>';
return;
}
wrap.innerHTML = file.sections.map((section, index, sections) => renderHTMLSection(index)).join('');
}
function renderHTMLSection(sectionIndex: number) {
const section = file.sections.at(sectionIndex);
if (!section) return ``;
if (activeSecIndex == null && file.sections.length == 1) activeSecIndex = 0;
const isActive = activeSecIndex === sectionIndex, isOpen = true;
const sectionId = getSectionId(sectionIndex);
switch (section.sectionType) {
case MonoDisplay.SectionType.ElementsAlways:
case MonoDisplay.SectionType.ElementsTimespan:
return `
<div class="sec-card${isActive ? ' active' : ''}${isOpen ? ' open' : ''}" id="${sectionId}">
<div class="sec-hdr${isActive ? ' active' : ''}" onclick="toggleSection('${sectionId}')">
<span class="sec-arrow"></span>
<span class="sec-label">${MonoDisplay.SectionTypeToString[section.sectionType]}</span>
<span class="sec-badge">${section.elements.length} el</span>
<button class="x-btn" title="Remove section" onclick="event.stopPropagation();removeSection(${sectionIndex})">X</button>
</div>
${isOpen ? `
<div class="el-list">
${section.elements.map((el: MonoDisplay.MonoFormatElement, index:number) => renderHTMLElement(sectionIndex, index, el)).join('')}
<button class="add-el" onclick="addElement('${sectionId}')">+ add element</button>
</div>`: ''}
</div>`;
case MonoDisplay.SectionType.CustomFont: {
return `
<div class="sec-card${isActive ? ' active' : ''}${isOpen ? ' open' : ''}" id="${sectionId}">
<div class="sec-hdr${isActive ? ' active' : ''}" onclick="toggleSection('${sectionId}')">
<span class="sec-arrow"></span>
<span class="sec-label">Custom Font</span>
<span class="sec-badge">1 el</span>
<button class="x-btn" title="Remove section" onclick="event.stopPropagation();removeSection(${sectionIndex})">X</button>
</div>
${isOpen ? `
<div class="el-list">
?
</div>`: ''}
</div>`;
}
}
}
function renderHTMLElement(sectionIndex: number, elementIndex: number, el: MonoDisplay.MonoFormatElement) {
const section = file.sections.at(sectionIndex);
const currentSectionElements: any[] = section && "elements" in section ? section.elements: [];
if (activeElIndex == null && currentSectionElements.length == 1) activeElIndex = 0;
const isActive = activeElIndex == elementIndex && activeSecIndex == sectionIndex;
const elementId = getElementId(elementIndex);
const sectionId = getSectionId(sectionIndex);
const typeString = MonoDisplay.ElementTypeToString[el.type];
const fields = EL_FIELDS[typeString] || [];
const flags = EL_FLAGS[typeString] || [];
const getCustomFonts = ():{fontname:string, index:number}[] => {
return file.sections
.filter(section => section.sectionType == MonoDisplay.SectionType.CustomFont)
.map((fontSection, index) => {
return {
fontname: `CustomFont ${index}`,
index: 0x8000 + index,
};
});
}
function renderHTMLField(field: ElFieldMeta) {
switch (field.type) {
case 'text': {
return `<textarea
onchange="setElField(${sectionIndex},${elementIndex},'${field.key}',this.value)"
>${esc(el[field.key] ?? field.default)}</textarea>`
}
case 'font': {
return `<select onchange="setElField(${sectionIndex},${elementIndex},'${field.key}',+this.value)">
${DEFAULT_FONTS.map((fontname, index) => `<option value="${index}" >${fontname}</option>`).join("")}
${getCustomFonts().map((k:{fontname:string, index:number}) => `<option value="${k.index}" >${k.fontname}</option>`).join("")}
</select>
`;
}
default:
return `<input type="number"
value="${el[field.key as string] ?? field.default}"
onchange="setElField(${sectionIndex},${elementIndex},'${field.key}',+this.value)"
/>`;
}
}
function renderHTMLFlag(el: MonoDisplay.MonoFormatElement & {flags: Record<string, any>}, flag: ElFlagMeta | null) {
if (el.flags && flag)
return `
<label class="flag-check">
<input type="checkbox" ${el.flags[flag.key] ? 'checked' : ''}
onchange="setElFlag(${sectionIndex},${elementIndex},'${flag.key}',this.checked)"
/>
${flag.label}
</label>`;
return ``;
}
const fieldsGrid = fields.map(field => `
<div class="field${field.full ? ' full' : ''}">
<label>${field.label}</label>
${renderHTMLField(field)}
</div>`).join('');
const flagsGrid = flags.length ? `
<div class="field full">
<label>Flags</label>
<div class="flags-row">
${flags.map(flag => renderHTMLFlag(el, flag)).join('')}
</div>
</div>`: ``;
const activeRender = `
<div class="el-fields">
<div class="field full">
<label>Type</label>
<select onchange="changeElType(${sectionIndex},${elementIndex},this.value)">
${EL_TYPES.map(t => `<option value="${MonoDisplay.ElementTypeToString[t]}"${t === el.type ? ' selected' : ''}>${MonoDisplay.ElementTypeToString[t]}</option>`).join('')}
</select>
</div>
<div class="fields-grid">
${fieldsGrid}
</div>
${flagsGrid}
</div>`;
return `
<div class="el-item${isActive ? ' active' : ''}" id="${elementId}">
<div class="el-item-hdr" onclick="selectElement(${sectionIndex},${elementIndex})">
<span class="el-type">${MonoDisplay.ElementTypeToString[el.type]}</span>
<span class="el-name">${elSummary(el)}</span>
<button class="x-btn" title="Remove element" onclick="event.stopPropagation();removeElement(${sectionIndex},${elementIndex})">X</button>
</div>
${isActive ? activeRender : ``}
</div>`;
}
function elSummary(el: MonoDisplay.MonoFormatElement) {
switch (el.type) {
case ElementType.ClippedText:
case ElementType.HScrollText:
// case ElementType.VScrollText:
return esc(el.text.slice(0, 24) + (el.text.length > 24 ? '...' : ''));
case ElementType.CurrentTime:
case ElementType.Image2D:
case ElementType.HorizontalScroll:
case ElementType.VerticalScroll:
return `${el.xOffset ?? 0}, ${el.yOffset ?? 0}`;
case ElementType.Animation:
case ElementType.Line:
return `${el.type.toString()}`;
default:
return "?";
}
}
function esc(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
function updateMeta() {
const section = getSec(activeSecIndex);
const sectionSelection = document.getElementById('meta-name');
if (sectionSelection) {
if (section) {
const options = Object.entries(MonoDisplay.SectionTypeToString)
.map((element, index) => `<option value=${element[1]}>${element[1]}</option>`)
.join("");
sectionSelection.innerHTML = `<select id="section-type-selection" onchange="setSectionType(activeSecIndex, this.value)">
${options}
</select>`;
} else {
sectionSelection.innerHTML = "-";
}
}
document.getElementById('meta-count').textContent = section && "elements" in section ? section.elements.length + ' element(s)' : '-';
document.getElementById('flag-drawFront').checked = section ? !!section.flags.drawFront : false;
document.getElementById('flag-drawBack').checked = section ? !!section.flags.drawBack : false;
document.getElementById('flag-clearBuffer').checked = section ? !!section.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;
if (!window._mdDriver)
window._mdDriver = new MonoDisplay.MonoDisplayDriver('canvas_root', { onColor: '#EC0', offColor: '#000', fps: 25 });
const s = file.sections[activeSecIndex || 0];
window._mdDriver.load(() => Promise.resolve(file.toBuffer()));
// now update the sections
renderHTML();
}
// --- Load / Export ------------------------------------------------------------
function loadBin(input: { files: any[]; value: string; }) {
const binFile = input.files[0]; if (!binFile) return;
currentFilename = binFile.name;
document.getElementById('filename').textContent = binFile.name;
const reader = new FileReader();
reader.onload = e => {
const arrayBuf = e.target.result;
try {
file = new window.MonoDisplay.MonoDisplayParser().parse(arrayBuf);
activeSecIndex = null;
activeElIndex = null;
triggerPreview();
} catch (err) {
console.warn('Could not parse sections from bin:', err);
}
};
reader.readAsArrayBuffer(binFile);
input.value = ''; markClean();
}
function exportBin() {
if (!window.MonoDisplay) { alert('MonoDisplay library not loaded.'); return; }
const blob = new Blob([file.toBuffer() as any], { type: 'application/octet-stream' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = currentFilename.replace(/\.bin$/, '') + '.bin';
a.click(); markClean();
}
// --- Init ---------------------------------------------------------------------
// Pin all functions referenced from inline HTML onclick/onchange strings so
// Bun's bundler does not tree-shake them as unreachable dead code.
Object.assign(window, {
loadBin, exportBin,
addSection, removeSection, toggleSection, setSectionFlag,
addElement, removeElement, selectElement,
changeElType, setElField, setElFlag, setSectionType,
cycleTheme,
});
triggerPreview();
if (window.MonoDisplay) { initDemos(); }
else { const iv = setInterval(() => { if (window.MonoDisplay) { clearInterval(iv); initDemos(); } }, 100); }

@ -64,7 +64,7 @@ export class MonoDisplayDriver {
if (!el || el.tagName !== "CANVAS") throw new Error(`#${canvasId} is not a <canvas>`); if (!el || el.tagName !== "CANVAS") throw new Error(`#${canvasId} is not a <canvas>`);
this.canvas = el as HTMLCanvasElement; this.canvas = el as HTMLCanvasElement;
this.opts = { this.opts = {
onColor: options.onColor ?? "#EC0", onColor: options.onColor ?? "#ffffff",
offColor: options.offColor ?? "#000000", offColor: options.offColor ?? "#000000",
scale: options.scale ?? 1, scale: options.scale ?? 1,
displayWidth: options.displayWidth ?? 120, displayWidth: options.displayWidth ?? 120,

@ -4,68 +4,41 @@
import { packedSize, packPixels, pad32 } from "./helper"; import { packedSize, packPixels, pad32 } from "./helper";
import { MONOFORMAT_MAGIC_HEADER } from "./parser"; import { MONOFORMAT_MAGIC_HEADER } from "./parser";
import { import { ElementType, SectionType, type AnimationElement, type ClippedTextElement, type CurrentTimeElement, type DrawElement, type ElementsAlwaysDescriptor, type HScrollElement, type HScrollTextElement, type Image2DElement, type LineElement, type MonoDisplayFileDescriptor, type ScrollElementFlags, type SectionFlags, type VScrollElement } from "./types";
ElementType,
SectionType,
type MonoFormatAnimation,
type MonoFormatClippedText,
type MonoFormatCurrentTime,
type MonoFormatCustomFont,
type MonoFormatElement,
type MonoFormatElementsAlways,
type MonoFormatElementsTimespan,
type MonoFormatFile,
type MonoFormatHScroll,
type MonoFormatHScrollText,
type MonoFormatImage2D,
type MonoFormatLine,
type MonoFormatScrollElementFlags,
type MonoFormatSection,
type MonoFormatVScroll,
} from "./types";
/** /**
* Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer. * Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer.
* *
* elements_always -> Section type 1 (ElementsAlways). * elements_always Section type 1 (ElementsAlways).
* Pass a DrawElement[] or { flags, elements }. * Pass a DrawElement[] or { flags, elements }.
* Default flags: drawFront=true, drawBack=false, clearBuffer=false. * Default flags: drawFront=true, drawBack=false, clearBuffer=false.
* *
* PLAN: encode ElementsTimespan sections, CustomFont sections. * PLAN: encode ElementsTimespan sections, CustomFont sections.
*/ */
export class MonoDisplayFile implements MonoFormatFile { export class MonoDisplayFile {
constructor(public sections: MonoFormatSection[], public version: number = 1) { private desc: MonoDisplayFileDescriptor;
constructor(descriptor: MonoDisplayFileDescriptor) {
this.desc = descriptor;
} }
toBuffer(): Uint8Array<ArrayBufferLike> { toBuffer(): Uint8Array<ArrayBufferLike> {
const sections: Uint8Array[] = []; const sections: Uint8Array[] = [];
for (const section of this.sections) { // Section 1: elements always
switch (section.sectionType) { const alwaysDesc = this.desc.elements_always;
case SectionType.ElementsAlways: if (alwaysDesc) {
{ const isArray = Array.isArray(alwaysDesc);
sections.push(this.#encodeElementsAlwaysSection(section)); const elements = isArray ? alwaysDesc as DrawElement[] : (alwaysDesc as ElementsAlwaysDescriptor).elements;
break; const flags = isArray ? undefined : (alwaysDesc as ElementsAlwaysDescriptor).flags;
sections.push(this.#encodeElementsSection(SectionType.ElementsAlways, flags, elements));
} }
case SectionType.ElementsTimespan:
{
sections.push(this.#encodeElementsTimespanSection(section));
break;
}
case SectionType.CustomFont: {
sections.push(this.#encodeCustomFont(section));
break;
}
}
}
// File header (12 bytes) // File header (12 bytes)
const hdrBuf = new ArrayBuffer(12); const hdrBuf = new ArrayBuffer(12);
const hdrView = new DataView(hdrBuf); const hdrView = new DataView(hdrBuf);
hdrView.setUint32(0, MONOFORMAT_MAGIC_HEADER, true); hdrView.setUint32(0, MONOFORMAT_MAGIC_HEADER, true);
hdrView.setUint32(4, this.version, true); // version hdrView.setUint32(4, 1, true); // version
hdrView.setUint16(8, sections.length, true); hdrView.setUint16(8, sections.length, true);
hdrView.setUint16(10, 0, true); // reserved hdrView.setUint16(10, 0, true); // reserved
@ -74,85 +47,36 @@ export class MonoDisplayFile implements MonoFormatFile {
// --- section encoders --- // --- section encoders ---
#encodeElementsAlwaysSection( #encodeElementsSection(
section: MonoFormatElementsAlways, type: SectionType.ElementsAlways | SectionType.ElementsTimespan,
flags: SectionFlags | undefined,
elements: DrawElement[],
): Uint8Array { ): Uint8Array {
const flagBits = const flagBits =
(section.flags.drawFront ?? true ? 0x01 : 0) | (flags?.drawFront ?? true ? 0x01 : 0) |
(section.flags.drawBack ?? false ? 0x02 : 0) | (flags?.drawBack ?? false ? 0x02 : 0) |
(section.flags.clearBuffer ?? false ? 0x04 : 0); (flags?.clearBuffer ?? false ? 0x04 : 0);
const encodedEls = section.elements.map(el => this.#encodeElement(el)); const encodedEls = elements.map(el => this.#encodeElement(el));
const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0); const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0);
// sectionSize (in header) = 4 (header) + sectionDataSize // sectionSize (in header) = 4 (header) + sectionDataSize
const sectionSize = 4 + sectionDataSize; const sectionSize = 4 + sectionDataSize;
const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4) const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4)
const v = new DataView(hdr.buffer); const v = new DataView(hdr.buffer);
v.setUint8(0, section.sectionType); v.setUint8(0, type);
v.setUint8(1, sectionSize & 0xFF);
v.setUint8(2, (sectionSize >> 8) & 0xFF);
v.setUint8(3, (sectionSize >> 16) & 0xFF);
v.setUint16(4, flagBits, true); // flags
v.setUint16(6, section.elements.length, true); // numElements
return this.#concat(hdr, ...encodedEls);
}
#encodeElementsTimespanSection(
section: MonoFormatElementsTimespan,
): Uint8Array {
const flagBits =
(section.flags.drawFront ?? true ? 0x01 : 0) |
(section.flags.drawBack ?? false ? 0x02 : 0) |
(section.flags.clearBuffer ?? false ? 0x04 : 0);
const encodedEls = section.elements.map(el => this.#encodeElement(el));
const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0);
// sectionSize (in header) = 4 (header) + sectionDataSize
const sectionSize = 4 + sectionDataSize;
const hdr = new Uint8Array(4 + 5*4); // section header (4) + section sub-header (4)
const v = new DataView(hdr.buffer);
v.setUint8(0, section.sectionType);
v.setUint8(1, sectionSize & 0xFF); v.setUint8(1, sectionSize & 0xFF);
v.setUint8(2, (sectionSize >> 8) & 0xFF); v.setUint8(2, (sectionSize >> 8) & 0xFF);
v.setUint8(3, (sectionSize >> 16) & 0xFF); v.setUint8(3, (sectionSize >> 16) & 0xFF);
v.setUint16(4, flagBits, true); // flags v.setUint16(4, flagBits, true); // flags
v.setUint16(6, section.elements.length, true); // numElements v.setUint16(6, elements.length, true); // numElements
// section.startTimestamp.valueOf()
const startTimestampLow = 0, startTimestampHigh = 0, endTimestampLow = 0, endTimestampHigh = 0;
v.setUint32(8, startTimestampLow, true); // flags
v.setUint32(12, startTimestampHigh, true); // numElements
v.setUint32(16, endTimestampLow, true); // numElements
v.setUint32(20, endTimestampHigh, true); // numElements
return this.#concat(hdr, ...encodedEls); return this.#concat(hdr, ...encodedEls);
} }
#encodeCustomFont(
section: MonoFormatCustomFont,
): Uint8Array {
const sectionSize = 4 + section.fontData.length;
const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4)
const v = new DataView(hdr.buffer);
v.setUint8(0, section.sectionType);
v.setUint8(1, sectionSize & 0xFF);
v.setUint8(2, (sectionSize >> 8) & 0xFF);
v.setUint8(3, (sectionSize >> 16) & 0xFF);
v.setUint8(4, section.fontData.length & 0xFF);
v.setUint8(5, (section.fontData.length >> 8) & 0xFF);
v.setUint8(6, (section.fontData.length >> 16) & 0xFF);
v.setUint8(7, 0);
return this.#concat(hdr, section.fontData);
}
// --- element encoders --- // --- element encoders ---
#encodeElement(el: MonoFormatElement): Uint8Array { #encodeElement(el: DrawElement): Uint8Array {
switch (el.type) { switch (el.type) {
case ElementType.Image2D: return this.#encodeImage2D(el); case ElementType.Image2D: return this.#encodeImage2D(el);
case ElementType.Animation: return this.#encodeAnimation(el); case ElementType.Animation: return this.#encodeAnimation(el);
@ -166,8 +90,8 @@ export class MonoDisplayFile implements MonoFormatFile {
} }
} }
#encodeImage2D(el: MonoFormatImage2D): Uint8Array { #encodeImage2D(el: Image2DElement): Uint8Array {
const packed = packPixels(el.image.pixels, el.image.width, el.image.height); const packed = packPixels(el.pixels, el.width, el.height);
const fixed = 12; // bytes before pixel data const fixed = 12; // bytes before pixel data
const rawSize = fixed + packed.byteLength; const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize); const total = rawSize + pad32(rawSize);
@ -176,14 +100,14 @@ export class MonoDisplayFile implements MonoFormatFile {
v.setUint16(0, ElementType.Image2D, true); v.setUint16(0, ElementType.Image2D, true);
v.setUint16(2, el.xOffset ?? 0, true); v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true); v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.image.width, true); v.setUint16(6, el.width, true);
v.setUint16(8, el.image.height, true); v.setUint16(8, el.height, true);
v.setUint16(10, 0, true); // reserved v.setUint16(10, 0, true); // reserved
out.set(packed, 12); out.set(packed, 12);
return out; return out;
} }
#encodeAnimation(el: MonoFormatAnimation): Uint8Array { #encodeAnimation(el: AnimationElement): Uint8Array {
const frameBytes = packedSize(el.width, el.height); const frameBytes = packedSize(el.width, el.height);
const fixed = 16; const fixed = 16;
const rawSize = fixed + el.frames.length * frameBytes; const rawSize = fixed + el.frames.length * frameBytes;
@ -206,7 +130,7 @@ export class MonoDisplayFile implements MonoFormatFile {
return out; return out;
} }
#encodeScrollFlags(f: MonoFormatScrollElementFlags | undefined): number { #encodeScrollFlags(f: ScrollElementFlags | undefined): number {
return ( return (
(f?.endless ? 0x01 : 0) | (f?.endless ? 0x01 : 0) |
(f?.invertDirection ? 0x02 : 0) | (f?.invertDirection ? 0x02 : 0) |
@ -215,8 +139,8 @@ export class MonoDisplayFile implements MonoFormatFile {
); );
} }
#encodeHScroll(el: MonoFormatHScroll): Uint8Array { #encodeHScroll(el: HScrollElement): Uint8Array {
const packed = packPixels(el.content.pixels, el.contentWidth, el.height); const packed = packPixels(el.pixels, el.contentWidth, el.height);
const fixed = 16; const fixed = 16;
const rawSize = fixed + packed.byteLength; const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize); const total = rawSize + pad32(rawSize);
@ -235,8 +159,8 @@ export class MonoDisplayFile implements MonoFormatFile {
return out; return out;
} }
#encodeVScroll(el: MonoFormatVScroll): Uint8Array { #encodeVScroll(el: VScrollElement): Uint8Array {
const packed = packPixels(el.content.pixels, el.width, el.contentHeight); const packed = packPixels(el.pixels, el.width, el.contentHeight);
const fixed = 16; const fixed = 16;
const rawSize = fixed + packed.byteLength; const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize); const total = rawSize + pad32(rawSize);
@ -255,7 +179,7 @@ export class MonoDisplayFile implements MonoFormatFile {
return out; return out;
} }
#encodeLine(el: MonoFormatLine): Uint8Array { #encodeLine(el: LineElement): Uint8Array {
// 12 bytes, already 32-bit aligned // 12 bytes, already 32-bit aligned
const out = new Uint8Array(12); const out = new Uint8Array(12);
const v = new DataView(out.buffer); const v = new DataView(out.buffer);
@ -269,7 +193,7 @@ export class MonoDisplayFile implements MonoFormatFile {
return out; return out;
} }
#encodeClippedText(el: MonoFormatClippedText): Uint8Array { #encodeClippedText(el: ClippedTextElement): Uint8Array {
const enc = new TextEncoder().encode(el.text); const enc = new TextEncoder().encode(el.text);
const fixed = 14; // 2+2+2+2+2+2+2 const fixed = 14; // 2+2+2+2+2+2+2
const rawSize = fixed + enc.byteLength; const rawSize = fixed + enc.byteLength;
@ -287,7 +211,7 @@ export class MonoDisplayFile implements MonoFormatFile {
return out; return out;
} }
#encodeHScrollText(el: MonoFormatHScrollText): Uint8Array { #encodeHScrollText(el: HScrollTextElement): Uint8Array {
const enc = new TextEncoder().encode(el.text); const enc = new TextEncoder().encode(el.text);
const fixed = 16; // 2+2+2+2+2+1+1+2+2 const fixed = 16; // 2+2+2+2+2+1+1+2+2
const rawSize = fixed + enc.byteLength; const rawSize = fixed + enc.byteLength;
@ -307,7 +231,7 @@ export class MonoDisplayFile implements MonoFormatFile {
return out; return out;
} }
#encodeCurrentTime(el: MonoFormatCurrentTime): Uint8Array { #encodeCurrentTime(el: CurrentTimeElement): Uint8Array {
// Fixed 16 bytes, already 32-bit aligned // Fixed 16 bytes, already 32-bit aligned
const out = new Uint8Array(16); const out = new Uint8Array(16);
const v = new DataView(out.buffer); const v = new DataView(out.buffer);
@ -354,14 +278,6 @@ export async function loadBinFile(url: string): Promise<ArrayBuffer> {
* Build a single-element-always .bin buffer containing one Image2D element. * Build a single-element-always .bin buffer containing one Image2D element.
* Convenience for tests; for production use MonoDisplayFile directly. * Convenience for tests; for production use MonoDisplayFile directly.
*/ */
export function buildBinBuffer(el: MonoFormatImage2D): Uint8Array { export function buildBinBuffer(el: Image2DElement): Uint8Array {
return new MonoDisplayFile([{ return new MonoDisplayFile({ elements_always: [el] }).toBuffer();
sectionType: SectionType.ElementsAlways,
elements: [el],
flags: {
drawFront: true,
drawBack: true,
clearBuffer: true,
},
}]).toBuffer();
} }

@ -1,26 +0,0 @@
// --- Theme --------------------------------------------------------------------
var _THEMES = ['dark', 'light', 'auto'];
function _getTheme() { return document.documentElement.getAttribute('data-theme') || 'auto'; }
function _applyTheme(t: string | undefined) {
if (t === 'auto') { document.documentElement.removeAttribute('data-theme'); localStorage.removeItem('theme'); }
else { document.documentElement.setAttribute('data-theme', t); localStorage.setItem('theme', t); }
_updateThemeBtn();
}
export 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);

@ -22,19 +22,6 @@ export enum SectionType {
/** U8G2-format custom font used by draw elements in this file */ /** U8G2-format custom font used by draw elements in this file */
CustomFont = 32, CustomFont = 32,
} }
export type SectionTypeName = keyof typeof SectionType;
export const SectionTypeToString = Object.fromEntries(
(Object.keys(SectionType) as SectionTypeName[])
.filter(k => isNaN(Number(k)))
.map(k => [SectionType[k], k])
) as Record<SectionType, SectionTypeName>;
export const StringToSectionType = Object.fromEntries(
(Object.keys(SectionType) as SectionTypeName[])
.filter(k => isNaN(Number(k)))
.map(k => [k, SectionType[k]])
) as Record<SectionTypeName, SectionType>;
export enum ElementType { export enum ElementType {
Image2D = 1, Image2D = 1,
@ -44,29 +31,15 @@ export enum ElementType {
Line = 5, Line = 5,
ClippedText = 16, ClippedText = 16,
HScrollText = 17, HScrollText = 17,
VScrollText = 18,
CurrentTime = 32, CurrentTime = 32,
} }
export type ElementTypeName = keyof typeof ElementType;
export const ElementTypeToString = Object.fromEntries(
(Object.keys(ElementType) as ElementTypeName[])
.filter(k => isNaN(Number(k)))
.map(k => [ElementType[k], k])
) as Record<ElementType, ElementTypeName>;
export const StringToElementType = Object.fromEntries(
(Object.keys(ElementType) as ElementTypeName[])
.filter(k => isNaN(Number(k)))
.map(k => [k, ElementType[k]])
) as Record<ElementTypeName, ElementType>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Section flags (section types 1 and 2) // Section flags (section types 1 and 2)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Bit-field flags for ElementsAlways / ElementsTimespan sections. */ /** Bit-field flags for ElementsAlways / ElementsTimespan sections. */
export interface MonoFormatSectionFlags { export interface SectionFlags {
/** Bit 0 - render elements onto the front-side buffer. */ /** Bit 0 - render elements onto the front-side buffer. */
drawFront?: boolean; drawFront?: boolean;
/** Bit 1 - render elements onto the back-side buffer. */ /** Bit 1 - render elements onto the back-side buffer. */
@ -80,7 +53,7 @@ export interface MonoFormatSectionFlags {
// Scroll element flags (HorizontalScroll / VerticalScroll / HScrollText) // Scroll element flags (HorizontalScroll / VerticalScroll / HScrollText)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface MonoFormatScrollElementFlags { export interface ScrollElementFlags {
/** Bit 0 - wrap content endlessly. */ /** Bit 0 - wrap content endlessly. */
endless?: boolean; endless?: boolean;
/** Bit 1 - reverse scroll direction. */ /** Bit 1 - reverse scroll direction. */
@ -96,7 +69,7 @@ export interface MonoFormatScrollElementFlags {
// CurrentTime element flags // CurrentTime element flags
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface MonoFormatCurrentTimeFlags { export interface CurrentTimeFlags {
/** Bit 0 - use 12-hour clock (default 24-hour). */ /** Bit 0 - use 12-hour clock (default 24-hour). */
clock12h?: boolean; clock12h?: boolean;
/** Bit 1 - show hours. */ /** Bit 1 - show hours. */
@ -112,7 +85,7 @@ export interface MonoFormatCurrentTimeFlags {
// Line element flags // Line element flags
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface MonoFormatLineFlags { export interface LineFlags {
/** Bit 0 - invert pixel values along the line. */ /** Bit 0 - invert pixel values along the line. */
invertPixels?: boolean; invertPixels?: boolean;
// Bits 1-7: reserved. // Bits 1-7: reserved.
@ -143,15 +116,174 @@ export function customFontOrdinal(fontIndex: FontIndex): number {
return fontIndex - CUSTOM_FONT_BASE; return fontIndex - CUSTOM_FONT_BASE;
} }
// ---------------------------------------------------------------------------
// User-facing element descriptors (MonoDisplayFile / builder input)
// Pixels are 1 byte/pixel (0 = off, 1 = on) in the API; packed to 1bpp on
// serialisation.
// ---------------------------------------------------------------------------
export interface Image2DElement {
type: ElementType.Image2D;
/** Row-major, 1 byte/pixel (0 = off, 1 = on). Packed to 1bpp on write. */
pixels: Uint8Array;
width: number;
height: number;
xOffset?: number; // default 0
yOffset?: number; // default 0
}
export interface AnimationFrameDescriptor {
/** 1 byte/pixel, same dimensions as the parent AnimationElement. */
pixels: Uint8Array;
}
export interface AnimationElement {
type: ElementType.Animation;
width: number;
height: number;
frames: AnimationFrameDescriptor[];
/**
* Advance frame every (updateInterval + 1) ticks.
* 0 = every tick, 1 = every 2nd tick, ..., 65535 = every 65536th tick.
* Default 0.
*/
updateInterval?: number;
xOffset?: number;
yOffset?: number;
}
export interface HScrollElement {
type: ElementType.HorizontalScroll;
/** Viewport width in pixels. */
width: number;
/** Viewport height in pixels. */
height: number;
/** Scrolling content pixels (contentWidth × height), 1 byte/pixel. */
pixels: Uint8Array;
contentWidth: number;
/**
* Scroll speed byte SS; moves (SS + 1) / 16 pixels per tick.
* Range 0-255. Default 0 (= 1/16 px/tick).
*/
scrollSpeed?: number;
flags?: ScrollElementFlags;
xOffset?: number;
yOffset?: number;
}
export interface VScrollElement {
type: ElementType.VerticalScroll;
/** Viewport width in pixels. */
width: number;
/** Viewport height in pixels. */
height: number;
/** Scrolling content pixels (width × contentHeight), 1 byte/pixel. */
pixels: Uint8Array;
contentHeight: number;
/** Scroll speed byte SS; moves (SS + 1) / 16 pixels per tick. Default 0. */
scrollSpeed?: number;
flags?: ScrollElementFlags;
xOffset?: number;
yOffset?: number;
}
export interface LineElement {
type: ElementType.Line;
xOrigin: number;
yOrigin: number;
xTarget: number;
yTarget: number;
/** Reserved by spec; writers MUST write 0. */
lineStyle?: number;
invertPixels?: boolean; // flags bit 0
}
export interface ClippedTextElement {
type: ElementType.ClippedText;
text: string; // UTF-8
width: number;
height: number;
/** 0-32767 = built-in font; 0x8000+ = custom font in file. Default 0. */
fontIndex?: FontIndex;
xOffset?: number;
yOffset?: number;
}
export interface HScrollTextElement {
type: ElementType.HScrollText;
text: string; // UTF-8
width: number;
height: number;
scrollSpeed?: number; // SS byte, default 0
fontIndex?: FontIndex;
flags?: ScrollElementFlags;
xOffset?: number;
yOffset?: number;
}
export interface CurrentTimeElement {
type: ElementType.CurrentTime;
width: number;
height: number;
/** 0-32767 = built-in font; 0x8000+ = custom font in file. Default 0. */
fontIndex?: FontIndex;
/**
* UTC offset in whole minutes (signed 16-bit integer).
* e.g. UTC+5:30 330, UTC-8 -480.
*/
utcOffsetMinutes?: number;
flags?: CurrentTimeFlags;
xOffset?: number;
yOffset?: number;
}
export type DrawElement =
| Image2DElement
| AnimationElement
| HScrollElement
| VScrollElement
| LineElement
| ClippedTextElement
| HScrollTextElement
| CurrentTimeElement;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Section descriptors (MonoDisplayFile / builder input) // Section descriptors (MonoDisplayFile / builder input)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface ElementsAlwaysDescriptor {
flags?: SectionFlags;
elements: DrawElement[];
}
export interface ElementsTimespanDescriptor {
flags?: SectionFlags;
elements: DrawElement[];
/** POSIX timestamp (seconds) - section visible when now >= startTimestamp. */
startTimestamp: bigint;
/** POSIX timestamp (seconds) - section visible when now < endTimestamp. */
endTimestamp: bigint;
}
export interface CustomFontDescriptor { export interface CustomFontDescriptor {
/** Raw U8G2 font data. */ /** Raw U8G2 font data. */
fontData: Uint8Array; fontData: Uint8Array;
} }
/**
* Top-level descriptor passed to MonoDisplayFile.
*
* Sections are written in the order they appear here.
* Custom fonts MUST appear before any element that references them.
*/
export interface MonoDisplayFileDescriptor {
/** Section type 1 - drawn every render tick. */
elements_always?: DrawElement[] | ElementsAlwaysDescriptor;
/** Section type 2 - drawn only within the given timestamp window. */
elements_timespan?: ElementsTimespanDescriptor[];
/** Section type 32 - embedded U8G2 custom fonts. */
custom_fonts?: CustomFontDescriptor[];
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// MonoFormat types (MonoDisplayParser output → renderer input) // MonoFormat types (MonoDisplayParser output → renderer input)
@ -182,6 +314,12 @@ export interface MonoFormatAnimation {
frames: MonoFormatPixelImage[]; frames: MonoFormatPixelImage[];
} }
export interface MonoFormatScrollFlags {
endless: boolean;
invertDirection: boolean;
padStart: boolean;
padEnd: boolean;
}
export interface MonoFormatHScroll { export interface MonoFormatHScroll {
type: ElementType.HorizontalScroll; type: ElementType.HorizontalScroll;
@ -191,7 +329,7 @@ export interface MonoFormatHScroll {
height: number; height: number;
contentWidth: number; contentWidth: number;
scrollSpeed: number; scrollSpeed: number;
flags: MonoFormatScrollElementFlags; flags: MonoFormatScrollFlags;
content: MonoFormatPixelImage; content: MonoFormatPixelImage;
} }
@ -203,7 +341,7 @@ export interface MonoFormatVScroll {
height: number; height: number;
contentHeight: number; contentHeight: number;
scrollSpeed: number; scrollSpeed: number;
flags: MonoFormatScrollElementFlags; flags: MonoFormatScrollFlags;
content: MonoFormatPixelImage; content: MonoFormatPixelImage;
} }
@ -235,10 +373,17 @@ export interface MonoFormatHScrollText {
height: number; height: number;
scrollSpeed: number; scrollSpeed: number;
fontIndex: FontIndex; fontIndex: FontIndex;
flags: MonoFormatScrollElementFlags; flags: MonoFormatScrollFlags;
text: string; text: string;
} }
export interface MonoFormatCurrentTimeFlags {
clock12h: boolean;
showHours: boolean;
showMinutes: boolean;
showSeconds: boolean;
}
export interface MonoFormatCurrentTime { export interface MonoFormatCurrentTime {
type: ElementType.CurrentTime; type: ElementType.CurrentTime;
xOffset: number; xOffset: number;
@ -261,7 +406,11 @@ export type MonoFormatElement =
| MonoFormatHScrollText | MonoFormatHScrollText
| MonoFormatCurrentTime; | MonoFormatCurrentTime;
export interface MonoFormatSectionFlags {
drawFront: boolean;
drawBack: boolean;
clearBuffer: boolean;
}
export interface MonoFormatElementsAlways { export interface MonoFormatElementsAlways {
sectionType: SectionType.ElementsAlways; sectionType: SectionType.ElementsAlways;

@ -1,624 +0,0 @@
:root {
--bg: #111;
--bg-bar: #1a1a1a;
--bg-raised: #161616;
--bg-hover: #1b1b1b;
--bg-hover2: #222;
--bg-sunken: #141414;
--bg-accent: #141e14;
--bg-active: #182018;
--bg-deep: #0f0f0f;
--bg-input: #0a0a0a;
--bg-canvas: #000;
--bg-xhover: #1e1010;
--bg-overlay: rgba(0,0,0,.6);
--bd: #2a2a2a;
--bd-inner: #1e1e1e;
--bd-deep: #1c1c1c;
--bd-card: #222;
--bd-btn: #2d2d2d;
--bd-type: #252525;
--bd-active: #2b3d2b;
--bd-hover: #444;
--bd-strong: #333;
--tx: #ccc;
--tx-hover: #bbb;
--tx-sub: #888;
--tx-label: #777;
--tx-meta: #666;
--tx-dim: #555;
--tx-faint: #444;
--tx-ghost: #333;
--tx-file: #3a3a3a;
--tx-empty: #2e2e2e;
--ac: #33ff66;
--dirty: #886622
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f5f5f5;
--bg-bar: #e8e8e8;
--bg-raised: #efefef;
--bg-hover: #e5e5e5;
--bg-hover2: #e0e0e0;
--bg-sunken: #ebebeb;
--bg-accent: #e8f5e8;
--bg-active: #dff0df;
--bg-deep: #f0f0f0;
--bg-input: #fff;
--bg-canvas: #fff;
--bg-xhover: #ffe8e8;
--bg-overlay: rgba(0,0,0,.4);
--bd: #d0d0d0;
--bd-inner: #ddd;
--bd-deep: #e0e0e0;
--bd-card: #ddd;
--bd-btn: #ccc;
--bd-type: #d8d8d8;
--bd-active: #7dc87d;
--bd-hover: #bbb;
--bd-strong: #ccc;
--tx: #222;
--tx-hover: #333;
--tx-sub: #555;
--tx-label: #666;
--tx-meta: #777;
--tx-dim: #888;
--tx-faint: #999;
--tx-ghost: #aaa;
--tx-file: #aaa;
--tx-empty: #bbb;
--ac: #1a9940;
--dirty: #b37a00
}
}
[data-theme="light"] {
--bg: #f5f5f5;
--bg-bar: #e8e8e8;
--bg-raised: #efefef;
--bg-hover: #e5e5e5;
--bg-hover2: #e0e0e0;
--bg-sunken: #ebebeb;
--bg-accent: #e8f5e8;
--bg-active: #dff0df;
--bg-deep: #f0f0f0;
--bg-input: #fff;
--bg-canvas: #fff;
--bg-xhover: #ffe8e8;
--bg-overlay: rgba(0,0,0,.4);
--bd: #d0d0d0;
--bd-inner: #ddd;
--bd-deep: #e0e0e0;
--bd-card: #ddd;
--bd-btn: #ccc;
--bd-type: #d8d8d8;
--bd-active: #7dc87d;
--bd-hover: #bbb;
--bd-strong: #ccc;
--tx: #222;
--tx-hover: #333;
--tx-sub: #555;
--tx-label: #666;
--tx-meta: #777;
--tx-dim: #888;
--tx-faint: #999;
--tx-ghost: #aaa;
--tx-file: #aaa;
--tx-empty: #bbb;
--ac: #1a9940;
--dirty: #b37a00
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0
}
body {
background: var(--bg);
color: var(--tx);
font-family: monospace;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 12px
}
#topbar {
height: 36px;
background: var(--bg-bar);
border-bottom: 1px solid var(--bd);
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
flex-shrink: 0
}
#topbar .app-name {
color: var(--tx-dim);
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
margin-right: 4px
}
.tb-btn {
background: var(--bg-raised);
color: var(--tx-sub);
border: 1px solid var(--bd-btn);
border-radius: 3px;
padding: 3px 10px;
font-family: monospace;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap
}
.tb-btn:hover {
background: var(--bg-hover2);
color: var(--tx-hover);
border-color: var(--bd-hover)
}
.tb-btn svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0
}
#file-input {
display: none
}
#filename {
color: var(--tx-file);
font-size: 11px;
margin-left: 2px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
.tb-sep {
width: 1px;
height: 18px;
background: var(--bd)
}
.dirty {
color: var(--dirty) !important
}
#main {
flex: 1;
display: flex;
min-height: 0
}
#left {
width: 48%;
border-right: 1px solid var(--bd-inner);
display: flex;
flex-direction: column;
padding: 14px;
gap: 10px;
overflow-y: auto;
flex-shrink: 0
}
#display-box {
width: 100%;
aspect-ratio: 2/1;
background: var(--bg-canvas);
border: 1px solid var(--bd);
border-radius: 3px;
overflow: hidden
}
#canvas_root {
width: 100%;
height: 100%;
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges
}
.pv-label {
font-size: 10px;
color: var(--tx-ghost);
letter-spacing: .1em;
text-transform: uppercase
}
#demo-btns {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px
}
#sec-meta {
background: var(--bg-sunken);
border: 1px solid var(--bd-inner);
border-radius: 3px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px
}
.meta-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px
}
.meta-row label {
color: var(--tx-faint);
min-width: 90px
}
.meta-val {
color: var(--tx-meta)
}
.green {
color: var(--ac)
}
.flag-check {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--tx-dim);
cursor: pointer
}
.flag-check input {
accent-color: var(--ac);
cursor: pointer
}
#right {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden
}
#right-hdr {
height: 36px;
background: var(--bg-sunken);
border-bottom: 1px solid var(--bd-inner);
display: flex;
align-items: center;
padding: 0 10px;
gap: 6px;
flex-shrink: 0
}
#right-hdr .rh-title {
flex: 1;
color: var(--tx-faint);
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase
}
#sections-wrap {
flex: 1;
overflow-y: auto;
padding: 8px
}
.empty-state {
color: var(--tx-empty);
font-size: 11px;
padding: 28px 16px;
text-align: center;
line-height: 2
}
/* section card */
.sec-card {
border: 1px solid var(--bd-card);
border-radius: 3px;
margin-bottom: 5px;
overflow: hidden
}
.sec-card.active {
border-color: var(--bd-active)
}
.sec-hdr {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 8px;
cursor: pointer;
user-select: none;
background: var(--bg-raised);
position: relative
}
.sec-hdr:hover {
background: var(--bg-hover)
}
.sec-card.active .sec-hdr {
background: var(--bg-active)
}
.sec-arrow {
color: var(--tx-ghost);
font-size: 13px;
line-height: 1;
transition: transform .12s;
flex-shrink: 0
}
.sec-card.open .sec-arrow {
transform: rotate(90deg)
}
.sec-label {
flex: 1;
color: var(--tx-label);
font-size: 11px
}
.sec-badge {
font-size: 10px;
color: var(--ac);
border: 1px solid var(--bd-active);
background: var(--bg-accent);
border-radius: 2px;
padding: 1px 6px;
flex-shrink: 0
}
/* red × - section & element */
.x-btn {
background: none;
border: none;
color: var(--bd);
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 2px 5px;
border-radius: 2px;
flex-shrink: 0;
transition: color .1s, background .1s
}
.x-btn:hover {
color: #ff4444;
background: var(--bg-xhover)
}
/* element */
.el-list {
background: var(--bg-deep);
border-top: 1px solid var(--bd-deep);
padding: 6px
}
.el-item {
border: 1px solid var(--bd-inner);
border-radius: 3px;
background: var(--bg-sunken);
margin-bottom: 4px;
overflow: hidden
}
.el-item.active {
border-color: var(--ac)
}
.el-item-hdr {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
cursor: pointer
}
.el-item-hdr:hover {
background: var(--bg-hover)
}
.el-type {
font-size: 10px;
color: var(--tx-meta);
border: 1px solid var(--bd-type);
border-radius: 2px;
padding: 1px 6px;
flex-shrink: 0
}
.el-item.active .el-type {
color: var(--ac);
border-color: var(--bd-active)
}
.el-name {
flex: 1;
color: var(--tx-dim);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px
}
.el-fields {
padding: 6px 8px 8px;
border-top: 1px solid var(--bg-bar);
display: flex;
flex-direction: column;
gap: 5px
}
.fields-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 10px
}
.field {
display: flex;
flex-direction: column;
gap: 2px
}
.field.full {
grid-column: 1/-1
}
.field>label {
font-size: 10px;
color: var(--tx-faint)
}
.field input[type=text],
.field input[type=number],
.field select,
.field textarea {
background: var(--bg-input);
color: var(--tx-sub);
border: 1px solid var(--bd-card);
border-radius: 2px;
padding: 3px 6px;
font-family: monospace;
font-size: 11px;
width: 100%;
outline: none
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--ac);
color: var(--tx)
}
.field textarea {
resize: vertical;
min-height: 44px;
line-height: 1.4
}
.field select option {
background: var(--bg-bar);
color: var(--tx-ghost)
}
.flags-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 2px 0
}
.add-el {
background: transparent;
color: var(--tx-ghost);
border: 1px dashed var(--bd-card);
border-radius: 3px;
padding: 5px 8px;
font-family: monospace;
font-size: 11px;
cursor: pointer;
width: 100%;
text-align: center;
margin-top: 2px
}
.add-el:hover {
color: var(--tx-sub);
border-color: var(--bd-hover)
}
/* confirm dialog */
#confirm-overlay {
display: none;
position: fixed;
inset: 0;
background: var(--bg-overlay);
z-index: 100;
align-items: center;
justify-content: center
}
#confirm-overlay.show {
display: flex
}
#confirm-box {
background: var(--bg-bar);
border: 1px solid var(--bd-strong);
border-radius: 4px;
padding: 20px 24px;
max-width: 340px;
width: 90%;
display: flex;
flex-direction: column;
gap: 14px
}
#confirm-msg {
color: var(--tx-ghost);
font-size: 12px;
line-height: 1.6
}
#confirm-btns {
display: flex;
gap: 8px;
justify-content: flex-end
}
.cb {
background: var(--bg-raised);
color: var(--tx-sub);
border: 1px solid var(--bd-btn);
border-radius: 3px;
padding: 4px 14px;
font-family: monospace;
font-size: 11px;
cursor: pointer
}
.cb:hover {
background: var(--bg-hover2);
color: var(--tx-hover)
}
.cb.primary {
border-color: var(--bd-active);
color: var(--ac)
}
.cb.primary:hover {
background: var(--bg-active)
}
Loading…
Cancel
Save