// browser.ts - Mithril entry point // Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js import m from "mithril"; import * as MonoDisplay from "./index"; import { MonoDisplayFile } from "./file"; import { cycleTheme } from "./themes"; (globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay; const W = 120, H = 60; // --- State ------------------------------------------------------------------- let file: MonoDisplayFile = new MonoDisplayFile([]); let activeSecIndex: number | null = null; let activeElIndex: number | null = null; let currentFilename = "untitled"; let isDirty = false; // --- Element metadata -------------------------------------------------------- type ElFieldMeta = { key: string; label: string; type: string; default: any; full?: boolean }; type ElFlagMeta = { key: string; label: string; default?: boolean }; const EL_FIELDS: Partial> = { 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: H }, { key: "fontIndex", label: "Font", type: "font", default: 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: H }, { key: "scrollSpeed", label: "Scroll speed", type: "number", default: 50 }, { key: "fontIndex", label: "Font", type: "font", default: 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: H }, { key: "scrollSpeed", label: "Scroll speed", type: "number", default: 50 }, { key: "fontIndex", label: "Font", type: "font", default: 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: H }, { key: "utcOffsetMinutes", label: "UTC offset (min)", type: "number", default: 0 }, { key: "fontIndex", label: "Font", type: "font", default: 0 }, ], }; const EL_FLAGS: Partial> = { HScrollText: [ { key: "endless", label: "Endless" }, { key: "invertDirection", label: "Invert direction" }, ], CurrentTime: [ { key: "clock12h", label: "12h mode", default: false }, { key: "showHours", label: "Show hours", default: true }, { key: "showSeconds", label: "Show seconds", default: true }, ], }; const EL_TYPES: MonoDisplay.ElementType[] = Object.keys(EL_FIELDS).map( (x) => MonoDisplay.StringToElementType[x as MonoDisplay.ElementTypeName] ); const DEFAULT_FONTS = ["Font 1", "Font 2", "Font 3", "Font 4", "Font 5"]; // --- Confirm dialog ---------------------------------------------------------- function showConfirm( msg: string, buttons: { label: string; primary?: boolean; action: boolean }[] ): Promise { return new Promise(resolve => { const overlay = document.getElementById("confirm-overlay"); const msgEl = document.getElementById("confirm-msg"); const btnsEl = document.getElementById("confirm-btns"); if (msgEl) msgEl.textContent = msg; if (btnsEl) btnsEl.innerHTML = ""; buttons.forEach(b => { const btn = document.createElement("button"); btn.className = "cb" + (b.primary ? " primary" : ""); btn.textContent = b.label; btn.onclick = () => { overlay?.classList.remove("show"); resolve(b.action); }; btnsEl?.appendChild(btn); }); 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(): Promise { if (!isDirty || !file.sections.length) return true; return showConfirm( "You have unsaved changes. Loading a demo will replace the current editor state.", [{ label: "Cancel", action: false }, { label: "Load anyway", primary: true, action: true }] ); } // --- Helpers ----------------------------------------------------------------- function newSec(): MonoDisplay.MonoFormatElementsAlways { return { sectionType: MonoDisplay.SectionType.ElementsAlways, elements: [], flags: { clearBuffer: true, drawBack: true, drawFront: true, } }; } function createNewElement(type: MonoDisplay.ElementType): MonoDisplay.MonoFormatElement { const fields: Record = {}; (EL_FIELDS[MonoDisplay.ElementTypeToString[type]] as ElFieldMeta[] || []) .forEach(f => (fields[f.key] = f.default)); const flags: Record = {}; (EL_FLAGS[MonoDisplay.ElementTypeToString[type]] as ElFlagMeta[] || []) .forEach(f => (flags[f.key] = f.default ?? false)); return { type, ...fields, flags } as MonoDisplay.MonoFormatElement; } function getSectionByIndex(i: number | null): MonoDisplay.MonoFormatSection | undefined { return i !== null ? file.sections[i] : undefined; } function getElementByIndex(si: number, ei: number): MonoDisplay.MonoFormatElement | undefined { const s = getSectionByIndex(si); return s && "elements" in s ? s.elements[ei] : undefined; } function getCustomFonts(): { fontname: string; index: number }[] { return file.sections .filter(s => s.sectionType === MonoDisplay.SectionType.CustomFont) .map((_, i) => ({ fontname: `CustomFont ${i}`, index: 0x8000 + i })); } function elSummary(el: MonoDisplay.MonoFormatElement): string { switch (el.type) { case MonoDisplay.ElementType.ClippedText: case MonoDisplay.ElementType.HScrollText: return el.text.slice(0, 24) + (el.text.length > 24 ? "..." : ""); case MonoDisplay.ElementType.CurrentTime: case MonoDisplay.ElementType.Image2D: case MonoDisplay.ElementType.HorizontalScroll: case MonoDisplay.ElementType.VerticalScroll: return `${(el as any).xOffset ?? 0}, ${(el as any).yOffset ?? 0}`; default: return el.type.toString(); } } // --- Mutations ---------------------------------------------------------------- function addSection() { file.sections.push(newSec()); activeSecIndex = file.sections.length - 1; activeElIndex = null; markDirty(); triggerPreview(); m.redraw(); } function removeSection(si: number) { file.sections.splice(si, 1); if (activeSecIndex === si) { activeSecIndex = file.sections.length ? file.sections.length - 1 : null; activeElIndex = null; } markDirty(); triggerPreview(); } function toggleSection(si: number) { activeSecIndex = si; } function setSectionFlag(flag: string, val: boolean) { if (activeSecIndex === null) return; const s = getSectionByIndex(activeSecIndex); if (!s) return; (s as any).flags[flag] = val; markDirty(); triggerPreview(); } function setSectionField(si: number, key: string, val: any) { const s = getSectionByIndex(si); if (!s) return; (s as any)[key] = val; markDirty(); triggerPreview(); } function setSectionType(si: number, sectionType: string) { const sec = getSectionByIndex(si); if (!sec) return; const wasElementsSection = sec.sectionType !== MonoDisplay.SectionType.CustomFont; (sec as any).sectionType = MonoDisplay.StringToSectionType[sectionType as MonoDisplay.SectionTypeName]; if ((sec as any).sectionType === MonoDisplay.SectionType.CustomFont) { (sec as any).fontData = new Uint8Array(); delete (sec as any).elements; delete (sec as any).flags; } else { if (!wasElementsSection) { (sec as any).elements = []; (sec as any).flags = {}; } delete (sec as any).fontData; if ((sec as any).sectionType === MonoDisplay.SectionType.ElementsTimespan) { const now = BigInt(Math.floor(Date.now() / 1000)); (sec as any).startTimestamp = now; (sec as any).endTimestamp = now + 3600n; } else { delete (sec as any).startTimestamp; delete (sec as any).endTimestamp; } } markDirty(); triggerPreview(); } function addElement(si: number) { const s = getSectionByIndex(si); if (!s || !("elements" in s)) return; s.elements.push(createNewElement(MonoDisplay.ElementType.HScrollText)); activeElIndex = s.elements.length - 1; activeSecIndex = si; markDirty(); triggerPreview(); } function removeElement(si: number, ei: number) { const s = getSectionByIndex(si); if (!s || !("elements" in s)) return; s.elements.splice(ei, 1); activeElIndex = null; markDirty(); triggerPreview(); } function selectElement(si: number, ei: number) { if (activeSecIndex === si && activeElIndex === ei) { activeElIndex = null; } else { activeSecIndex = si; activeElIndex = ei; } } function changeElType(si: number, ei: number, typeString: MonoDisplay.ElementTypeName) { const el = getElementByIndex(si, ei); if (!el) return; const fresh = createNewElement(MonoDisplay.StringToElementType[typeString]); Object.assign(el, fresh); markDirty(); triggerPreview(); } function setElField(si: number, ei: number, key: string, val: any) { const el = getElementByIndex(si, ei); if (!el) return; (el as any)[key] = val; markDirty(); triggerPreview(); } function setElFlag(si: number, ei: number, key: string, val: any) { const el = getElementByIndex(si, ei); if (!el) return; (el as any).flags[key] = val; markDirty(); triggerPreview(); } // --- DEMO -------------------------------------------------------------------- const DEMO: Record MonoDisplayFile> = { 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: MonoDisplay.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: MonoDisplay.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: MonoDisplay.ElementType.ClippedText, fontIndex: 0, text: "Hello, World!", xOffset: 0, yOffset: 16, width: 60, height: 10 }], flags: { clearBuffer: true, drawFront: true, drawBack: true }, }]); }, Scrolltext() { return new MonoDisplayFile([{ sectionType: MonoDisplay.SectionType.ElementsAlways, elements: [{ type: MonoDisplay.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: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: {}, xOffset: 0, yOffset: 8, width: W, height: 16, utcOffsetMinutes: 120 }, { type: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true }, xOffset: 40, yOffset: 16, width: W, height: 16, utcOffsetMinutes: 120 }, { type: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true, showHours: true }, xOffset: 0, yOffset: 24, width: W, height: 16, utcOffsetMinutes: 120 }, { type: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: { clock12h: true, showHours: false }, xOffset: 40, yOffset: 32, width: W, height: 16, utcOffsetMinutes: 120 }, { type: MonoDisplay.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 }, }]); }, }; // --- Preview ----------------------------------------------------------------- let previewTimer: ReturnType | null = null; function triggerPreview() { if (previewTimer) 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 }); window._mdDriver.load(() => Promise.resolve(file.toBuffer())); } // --- Load / Export ----------------------------------------------------------- function loadBin(input: HTMLInputElement) { const binFile = input.files?.[0]; if (!binFile) return; currentFilename = binFile.name; const filenameEl = document.getElementById("filename"); if (filenameEl) filenameEl.textContent = binFile.name; const reader = new FileReader(); reader.onload = e => { try { const parsed = new window.MonoDisplay.MonoDisplayParser().parse(e.target!.result as ArrayBuffer); file = new MonoDisplayFile(parsed.sections); activeSecIndex = null; activeElIndex = null; triggerPreview(); m.redraw(); } 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(); } // --- Mithril Components ------------------------------------------------------- function FieldInput(si: number, ei: number, field: ElFieldMeta, el: MonoDisplay.MonoFormatElement): m.Vnode { const val = (el as any)[field.key] ?? field.default; switch (field.type) { case "text": return m("textarea", { onchange: (e: Event) => setElField(si, ei, field.key, (e.target as HTMLTextAreaElement).value), }, val); case "font": return m("select", { onchange: (e: Event) => setElField(si, ei, field.key, +(e.target as HTMLSelectElement).value), }, [ ...DEFAULT_FONTS.map((name, i) => m("option", { value: i, selected: val === i }, name)), ...getCustomFonts().map(cf => m("option", { value: cf.index, selected: val === cf.index }, cf.fontname)), ]); default: return m("input[type=number]", { value: val, onchange: (e: Event) => setElField(si, ei, field.key, +(e.target as HTMLInputElement).value), }); } } const ElementItem: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatElement }> = { view({ attrs: { si, ei, el } }) { const isActive = activeSecIndex === si && activeElIndex === ei; const typeStr = MonoDisplay.ElementTypeToString[el.type]; const fields = EL_FIELDS[typeStr] || []; const flags = EL_FLAGS[typeStr] || []; return m(".el-item", { class: isActive ? "active" : "" }, m(".el-item-hdr", { onclick: () => selectElement(si, ei) }, m("span.el-type", typeStr), m("span.el-name", elSummary(el)), m("button.x-btn", { title: "Remove element", onclick: (e: Event) => { e.stopPropagation(); removeElement(si, ei); }, }, "X"), ), isActive && m(".el-fields", m(".field.full", m("label", "Type"), m("select", { onchange: (e: Event) => changeElType(si, ei, (e.target as HTMLSelectElement).value as MonoDisplay.ElementTypeName), }, EL_TYPES.map(t => m("option", { value: MonoDisplay.ElementTypeToString[t], selected: t === el.type }, MonoDisplay.ElementTypeToString[t]) ) ), ), m(".fields-grid", fields.map(field => m(`.field${field.full ? ".full" : ""}`, m("label", field.label), FieldInput(si, ei, field, el), ) ) ), flags.length ? m(".field.full", m("label", "Flags"), m(".flags-row", flags.map(flag => m("label.flag-check", m("input[type=checkbox]", { checked: !!(el as any).flags?.[flag.key], onchange: (e: Event) => setElFlag(si, ei, flag.key, (e.target as HTMLInputElement).checked), }), " " + flag.label, ) ) ), ) : null, ), ); }, }; const SectionCard: m.Component<{ si: number }> = { view({ attrs: { si } }) { const section = file.sections[si]; if (!section) return null; const isActive = activeSecIndex === si; const typeStr = MonoDisplay.SectionTypeToString[section.sectionType]; const hdr = m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) }, m("span.sec-arrow", "▶"), m("span.sec-label", typeStr), m("button.x-btn", { title: "Remove section", onclick: (e: Event) => { e.stopPropagation(); removeSection(si); }, }, "X"), ); if (section.sectionType === MonoDisplay.SectionType.CustomFont) { return m(`.sec-card.open${isActive ? ".active" : ""}`, m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) }, m("span.sec-arrow", "▶"), m("span.sec-label", "Custom Font"), m("span.sec-badge", "1 el"), m("button.x-btn", { title: "Remove section", onclick: (e: Event) => { e.stopPropagation(); removeSection(si); }, }, "X"), ), m(".el-list", "?"), ); } const elSection = section as MonoDisplay.MonoFormatElementsAlways | MonoDisplay.MonoFormatElementsTimespan; return m(`.sec-card.open${isActive ? ".active" : ""}`, m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) }, m("span.sec-arrow", "▶"), m("span.sec-label", typeStr), m("span.sec-badge", `${elSection.elements.length} el`), m("button.x-btn", { title: "Remove section", onclick: (e: Event) => { e.stopPropagation(); removeSection(si); }, }, "X"), ), section.sectionType === MonoDisplay.SectionType.ElementsTimespan ? m(".el-list", ([ ["Start Time", "startTimestamp"], ["Stop Time", "endTimestamp"] ] as [string, string][]) .map(([label, key]) => { let posix: bigint = (section as any)[key]; if (!posix) { posix = BigInt(Math.floor(Date.now() / 1000)); setSectionField(si, key, posix); } const dtLocal = new Date(Number(posix) * 1000).toISOString().slice(0, 16); return m(".field", m("label", label), m("input[type=datetime-local]", { value: dtLocal, onchange: (e: Event) => { const ms = new Date((e.target as HTMLInputElement).value).getTime(); setSectionField(si, key, BigInt(Math.floor(ms / 1000))); }, }), ); }) ) : null, m(".el-list", elSection.elements.map((el, ei) => m(ElementItem, { key: String(ei), si, ei, el })), m("button.add-el", { onclick: () => addElement(si) }, "+ add element"), ), ); }, }; const SectionsList: m.Component = { view() { if (!file.sections.length) return m(".empty-state", m.trust("No sections yet.
Use Add section to get started.")); if (activeSecIndex === null && file.sections.length === 1) activeSecIndex = 0; return m("", file.sections.map((_, si) => m(SectionCard, { key: String(si), si }))); }, }; const MetaPanel: m.Component = { view() { const section = getSectionByIndex(activeSecIndex); return m("", m(".meta-row", m("label", "Selected section"), section ? m("select.meta-val.green", { value: MonoDisplay.SectionTypeToString[section.sectionType], onchange: (e: Event) => activeSecIndex !== null && setSectionType(activeSecIndex, (e.target as HTMLSelectElement).value), }, Object.values(MonoDisplay.SectionTypeToString).map(name => m("option", { value: name }, name) ) ) : m("span.meta-val.green", "-"), ), m(".meta-row", m("label", "Elements"), m("span.meta-val", section && "elements" in section ? `${section.elements.length} element(s)` : "-"), ), m(".meta-row", m("label", "Section flags"), (["drawFront", "drawBack", "clearBuffer"] as const).map((flag, i) => m("label.flag-check", { style: i > 0 ? "margin-left:-8px" : "" }, m("input[type=checkbox]", { checked: section ? !!(section as any).flags?.[flag] : false, onchange: (e: Event) => setSectionFlag(flag, (e.target as HTMLInputElement).checked), }), " " + flag, ) ), ), ); }, }; let activeDemoName: string | null = null; const DemoButtons: m.Component = { view() { return Object.entries(DEMO).map(([name, fn]) => m("button.tb-btn", { style: activeDemoName === name ? "color:#33ff66" : "", onclick: async () => { const ok = await guardDirty(); if (!ok) return; file = fn(); activeSecIndex = null; activeElIndex = null; currentFilename = name.toLowerCase(); const filenameEl = document.getElementById("filename"); if (filenameEl) filenameEl.textContent = currentFilename; activeDemoName = name; markClean(); triggerPreview(); m.redraw(); }, }, name) ); }, }; // --- Mount -------------------------------------------------------------------- m.mount(document.getElementById("sections-wrap")!, SectionsList); m.mount(document.getElementById("demo-btns")!, DemoButtons); m.mount(document.getElementById("sec-meta")!, MetaPanel); // Expose globals referenced by topbar inline handlers Object.assign(window, { loadBin, exportBin, addSection, cycleTheme }); triggerPreview();