Compare commits

...

5 Commits

  1. 190
      ts-editor/src/browser.ts
  2. 156
      ts/build-dts.ts
  3. 4
      ts/bun.lock
  4. 112
      ts/index.html
  5. 215
      ts/live.php
  6. 12
      ts/package.json
  7. 19
      ts/src/index.ts
  8. 682
      ts/style.css

@ -2,10 +2,29 @@
// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js // Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js
import m from "mithril"; import m from "mithril";
import * as MonoDisplay from "libmonoformat"; import {
import { MonoDisplayFile, cycleTheme } from "libmonoformat"; ElementType,
ElementTypeToString,
MonoDisplayDriver,
MonoDisplayFile,
MonoDisplayParser,
MonoDisplayRenderer,
SectionType,
SectionTypeToString,
StringToElementType,
StringToSectionType,
cycleTheme,
type ElementTypeName,
type MonoFormatAnimation,
type MonoFormatElement,
type MonoFormatElementsAlways,
type MonoFormatElementsTimespan,
type MonoFormatImage2D,
type MonoFormatPixelImage,
type MonoFormatSection,
type SectionTypeName
} from "libmonoformat";
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay;
const W = 120, H = 60; const W = 120, H = 60;
@ -20,7 +39,7 @@ let isDirty = false;
type ElFieldMeta = { key: string; label: string; type: string; default: any; full?: boolean }; type ElFieldMeta = { key: string; label: string; type: string; default: any; full?: boolean };
type ElFlagMeta = { key: string; label: string; default?: boolean }; type ElFlagMeta = { key: string; label: string; default?: boolean };
const EL_FIELDS: Partial<Record<MonoDisplay.ElementTypeName, ElFieldMeta[]>> = { const EL_FIELDS: Partial<Record<ElementTypeName, ElFieldMeta[]>> = {
Image2D: [ Image2D: [
{ key: "xOffset", label: "X offset", type: "number", default: 0 }, { key: "xOffset", label: "X offset", type: "number", default: 0 },
{ key: "yOffset", label: "Y offset", type: "number", default: 0 }, { key: "yOffset", label: "Y offset", type: "number", default: 0 },
@ -78,7 +97,7 @@ const EL_FIELDS: Partial<Record<MonoDisplay.ElementTypeName, ElFieldMeta[]>> = {
], ],
}; };
const EL_FLAGS: Partial<Record<MonoDisplay.ElementTypeName, ElFlagMeta[]>> = { const EL_FLAGS: Partial<Record<ElementTypeName, ElFlagMeta[]>> = {
HScrollText: [ HScrollText: [
{ key: "endless", label: "Endless" }, { key: "endless", label: "Endless" },
{ key: "invertDirection", label: "Invert direction" }, { key: "invertDirection", label: "Invert direction" },
@ -92,11 +111,11 @@ const EL_FLAGS: Partial<Record<MonoDisplay.ElementTypeName, ElFlagMeta[]>> = {
], ],
}; };
const EL_TYPES: MonoDisplay.ElementType[] = Object.keys(EL_FIELDS).map( const EL_TYPES: ElementType[] = Object.keys(EL_FIELDS).map(
(x) => MonoDisplay.StringToElementType[x as MonoDisplay.ElementTypeName] (x) => StringToElementType[x as ElementTypeName]
); );
const DEFAULT_FONTS = Object.keys(MonoDisplay.MonoDisplayRenderer.builtinFonts); const DEFAULT_FONTS = Object.keys(MonoDisplayRenderer.builtinFonts);
// --- Confirm dialog ---------------------------------------------------------- // --- Confirm dialog ----------------------------------------------------------
function showConfirm( function showConfirm(
@ -137,7 +156,7 @@ function loadFromStorage() {
if (!raw) return; if (!raw) return;
const { filename, data } = JSON.parse(raw); const { filename, data } = JSON.parse(raw);
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0)); const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
const parsed = new MonoDisplay.MonoDisplayParser().parse(bytes.buffer); const parsed = new MonoDisplayParser().parse(bytes.buffer);
file = new MonoDisplayFile(parsed.sections); file = new MonoDisplayFile(parsed.sections);
currentFilename = filename; currentFilename = filename;
const filenameEl = document.getElementById("filename"); const filenameEl = document.getElementById("filename");
@ -167,9 +186,9 @@ async function guardDirty(): Promise<boolean> {
} }
// --- Helpers ----------------------------------------------------------------- // --- Helpers -----------------------------------------------------------------
function newSec(): MonoDisplay.MonoFormatElementsAlways { function newSec(): MonoFormatElementsAlways {
return { return {
sectionType: MonoDisplay.SectionType.ElementsAlways, elements: [], flags: { sectionType: SectionType.ElementsAlways, elements: [], flags: {
clearBuffer: true, clearBuffer: true,
drawBack: true, drawBack: true,
drawFront: true, drawFront: true,
@ -177,47 +196,47 @@ function newSec(): MonoDisplay.MonoFormatElementsAlways {
}; };
} }
function createNewElement(type: MonoDisplay.ElementType): MonoDisplay.MonoFormatElement { function createNewElement(type: ElementType): MonoFormatElement {
const fields: Record<string, any> = {}; const fields: Record<string, any> = {};
(EL_FIELDS[MonoDisplay.ElementTypeToString[type]] as ElFieldMeta[] || []) (EL_FIELDS[ElementTypeToString[type]] as ElFieldMeta[] || [])
.forEach(f => (fields[f.key] = f.default)); .forEach(f => (fields[f.key] = f.default));
const flags: Record<string, any> = {}; const flags: Record<string, any> = {};
(EL_FLAGS[MonoDisplay.ElementTypeToString[type]] as ElFlagMeta[] || []) (EL_FLAGS[ElementTypeToString[type]] as ElFlagMeta[] || [])
.forEach(f => (flags[f.key] = f.default ?? false)); .forEach(f => (flags[f.key] = f.default ?? false));
const el: any = { type, ...fields, flags }; const el: any = { type, ...fields, flags };
if (type === MonoDisplay.ElementType.Image2D) { if (type === ElementType.Image2D) {
el.image = { pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height }; el.image = { pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height };
} }
if (type === MonoDisplay.ElementType.Animation) { if (type === ElementType.Animation) {
el.frames = [{ pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height }]; el.frames = [{ pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height }];
} }
return el as MonoDisplay.MonoFormatElement; return el as MonoFormatElement;
} }
function getSectionByIndex(i: number | null): MonoDisplay.MonoFormatSection | undefined { function getSectionByIndex(i: number | null): MonoFormatSection | undefined {
return i !== null ? file.sections[i] : undefined; return i !== null ? file.sections[i] : undefined;
} }
function getElementByIndex(si: number, ei: number): MonoDisplay.MonoFormatElement | undefined { function getElementByIndex(si: number, ei: number): MonoFormatElement | undefined {
const s = getSectionByIndex(si); const s = getSectionByIndex(si);
return s && "elements" in s ? s.elements[ei] : undefined; return s && "elements" in s ? s.elements[ei] : undefined;
} }
function getCustomFonts(): { fontname: string; index: number }[] { function getCustomFonts(): { fontname: string; index: number }[] {
return file.sections return file.sections
.filter(s => s.sectionType === MonoDisplay.SectionType.CustomFont) .filter(s => s.sectionType === SectionType.CustomFont)
.map((_, i) => ({ fontname: `CustomFont ${i}`, index: 0x8000 + i })); .map((_, i) => ({ fontname: `CustomFont ${i}`, index: 0x8000 + i }));
} }
function elSummary(el: MonoDisplay.MonoFormatElement): string { function elSummary(el: MonoFormatElement): string {
switch (el.type) { switch (el.type) {
case MonoDisplay.ElementType.ClippedText: case ElementType.ClippedText:
case MonoDisplay.ElementType.HScrollText: case ElementType.HScrollText:
return el.text.slice(0, 24) + (el.text.length > 24 ? "..." : ""); return el.text.slice(0, 24) + (el.text.length > 24 ? "..." : "");
case MonoDisplay.ElementType.CurrentTime: case ElementType.CurrentTime:
case MonoDisplay.ElementType.Image2D: case ElementType.Image2D:
case MonoDisplay.ElementType.HorizontalScroll: case ElementType.HorizontalScroll:
case MonoDisplay.ElementType.VerticalScroll: case ElementType.VerticalScroll:
return `${(el as any).xOffset ?? 0}, ${(el as any).yOffset ?? 0}`; return `${(el as any).xOffset ?? 0}, ${(el as any).yOffset ?? 0}`;
default: default:
return el.type.toString(); return el.type.toString();
@ -263,9 +282,9 @@ function setSectionField(si: number, key: string, val: any) {
function setSectionType(si: number, sectionType: string) { function setSectionType(si: number, sectionType: string) {
const sec = getSectionByIndex(si); const sec = getSectionByIndex(si);
if (!sec) return; if (!sec) return;
const wasElementsSection = sec.sectionType !== MonoDisplay.SectionType.CustomFont; const wasElementsSection = sec.sectionType !== SectionType.CustomFont;
(sec as any).sectionType = MonoDisplay.StringToSectionType[sectionType as MonoDisplay.SectionTypeName]; (sec as any).sectionType = StringToSectionType[sectionType as SectionTypeName];
if ((sec as any).sectionType === MonoDisplay.SectionType.CustomFont) { if ((sec as any).sectionType === SectionType.CustomFont) {
(sec as any).fontData = new Uint8Array(); (sec as any).fontData = new Uint8Array();
delete (sec as any).elements; delete (sec as any).elements;
delete (sec as any).flags; delete (sec as any).flags;
@ -275,7 +294,7 @@ function setSectionType(si: number, sectionType: string) {
(sec as any).flags = {}; (sec as any).flags = {};
} }
delete (sec as any).fontData; delete (sec as any).fontData;
if ((sec as any).sectionType === MonoDisplay.SectionType.ElementsTimespan) { if ((sec as any).sectionType === SectionType.ElementsTimespan) {
const now = BigInt(Math.floor(Date.now() / 1000)); const now = BigInt(Math.floor(Date.now() / 1000));
(sec as any).startTimestamp = now; (sec as any).startTimestamp = now;
(sec as any).endTimestamp = now + 3600n; (sec as any).endTimestamp = now + 3600n;
@ -290,7 +309,7 @@ function setSectionType(si: number, sectionType: string) {
function addElement(si: number) { function addElement(si: number) {
const s = getSectionByIndex(si); const s = getSectionByIndex(si);
if (!s || !("elements" in s)) return; if (!s || !("elements" in s)) return;
s.elements.push(createNewElement(MonoDisplay.ElementType.HScrollText)); s.elements.push(createNewElement(ElementType.HScrollText));
activeElIndex = s.elements.length - 1; activeElIndex = s.elements.length - 1;
activeSecIndex = si; activeSecIndex = si;
markDirty(); triggerPreview(); markDirty(); triggerPreview();
@ -313,10 +332,10 @@ function selectElement(si: number, ei: number) {
} }
} }
function changeElType(si: number, ei: number, typeString: MonoDisplay.ElementTypeName) { function changeElType(si: number, ei: number, typeString: ElementTypeName) {
const el = getElementByIndex(si, ei); const el = getElementByIndex(si, ei);
if (!el) return; if (!el) return;
const fresh = createNewElement(MonoDisplay.StringToElementType[typeString]); const fresh = createNewElement(StringToElementType[typeString]);
Object.assign(el, fresh); Object.assign(el, fresh);
markDirty(); triggerPreview(); markDirty(); triggerPreview();
} }
@ -341,16 +360,16 @@ const DEMO: Record<string, () => MonoDisplayFile> = {
const pixels = new Uint8Array(W * H); 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; for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) pixels[y * W + x] = (x + y) % 2;
return new MonoDisplayFile([{ return new MonoDisplayFile([{
sectionType: MonoDisplay.SectionType.ElementsAlways, sectionType: SectionType.ElementsAlways,
elements: [{ type: MonoDisplay.ElementType.Image2D, image: { pixels, height: H, width: W }, xOffset: 0, yOffset: 0 }], elements: [{ type: ElementType.Image2D, image: { pixels, height: H, width: W }, xOffset: 0, yOffset: 0 }],
flags: { clearBuffer: true, drawFront: true, drawBack: true }, flags: { clearBuffer: true, drawFront: true, drawBack: true },
}]); }]);
}, },
Blink() { Blink() {
return new MonoDisplayFile([{ return new MonoDisplayFile([{
sectionType: MonoDisplay.SectionType.ElementsAlways, sectionType: SectionType.ElementsAlways,
elements: [{ elements: [{
type: MonoDisplay.ElementType.Animation, width: W, height: H, updateInterval: 12, xOffset: 0, yOffset: 0, type: ElementType.Animation, width: W, height: H, updateInterval: 12, xOffset: 0, yOffset: 0,
frames: [ frames: [
{ pixels: new Uint8Array(W * H).fill(1), width: 0, height: 0 }, { pixels: new Uint8Array(W * H).fill(1), width: 0, height: 0 },
{ pixels: new Uint8Array(W * H).fill(0), width: 0, height: 0 }, { pixels: new Uint8Array(W * H).fill(0), width: 0, height: 0 },
@ -361,16 +380,16 @@ const DEMO: Record<string, () => MonoDisplayFile> = {
}, },
Text() { Text() {
return new MonoDisplayFile([{ return new MonoDisplayFile([{
sectionType: MonoDisplay.SectionType.ElementsAlways, sectionType: SectionType.ElementsAlways,
elements: [{ type: MonoDisplay.ElementType.ClippedText, fontIndex: 0, text: "Hello, World!", xOffset: 0, yOffset: 16, width: 60, height: 10 }], elements: [{ type: ElementType.ClippedText, fontIndex: 0, text: "Hello, World!", xOffset: 0, yOffset: 16, width: 60, height: 10 }],
flags: { clearBuffer: true, drawFront: true, drawBack: true }, flags: { clearBuffer: true, drawFront: true, drawBack: true },
}]); }]);
}, },
Scrolltext() { Scrolltext() {
return new MonoDisplayFile([{ return new MonoDisplayFile([{
sectionType: MonoDisplay.SectionType.ElementsAlways, sectionType: SectionType.ElementsAlways,
elements: [{ elements: [{
type: MonoDisplay.ElementType.HScrollText, fontIndex: 0, type: ElementType.HScrollText, fontIndex: 0,
text: "MONO DISPLAY - scrolling ticker - 🚀 ", text: "MONO DISPLAY - scrolling ticker - 🚀 ",
xOffset: 0, yOffset: 32, width: W, height: 16, scrollSpeed: 50, xOffset: 0, yOffset: 32, width: W, height: 16, scrollSpeed: 50,
flags: { endless: true, invertDirection: false, padStart: false, padEnd: false }, flags: { endless: true, invertDirection: false, padStart: false, padEnd: false },
@ -380,13 +399,13 @@ const DEMO: Record<string, () => MonoDisplayFile> = {
}, },
Time() { Time() {
return new MonoDisplayFile([{ return new MonoDisplayFile([{
sectionType: MonoDisplay.SectionType.ElementsAlways, sectionType: SectionType.ElementsAlways,
elements: [ elements: [
{ type: MonoDisplay.ElementType.CurrentTime, fontIndex: 0, flags: {}, xOffset: 0, yOffset: 8, width: W, height: 16, utcOffsetMinutes: 120 }, { type: 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: 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: 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: 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 }, { 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 }, flags: { clearBuffer: true, drawFront: true, drawBack: true },
}]); }]);
@ -404,10 +423,9 @@ function triggerPreview() {
} }
function buildPreview() { function buildPreview() {
if (!window.MonoDisplay) return; if (!(window as any)._mdDriver)
if (!window._mdDriver) (window as any)._mdDriver = new MonoDisplayDriver("canvas_root", { onColor: "#EC0", offColor: "#000", fps: 25 });
window._mdDriver = new MonoDisplay.MonoDisplayDriver("canvas_root", { onColor: "#EC0", offColor: "#000", fps: 25 }); (window as any)._mdDriver.load(() => Promise.resolve(file.toBuffer()));
window._mdDriver.load(() => Promise.resolve(file.toBuffer()));
} }
// --- Load / Export ----------------------------------------------------------- // --- Load / Export -----------------------------------------------------------
@ -420,7 +438,7 @@ function loadBin(input: HTMLInputElement) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = e => { reader.onload = e => {
try { try {
const parsed = new window.MonoDisplay.MonoDisplayParser().parse(e.target!.result as ArrayBuffer); const parsed = new MonoDisplayParser().parse(e.target!.result as ArrayBuffer);
file = new MonoDisplayFile(parsed.sections); file = new MonoDisplayFile(parsed.sections);
activeSecIndex = null; activeSecIndex = null;
activeElIndex = null; activeElIndex = null;
@ -436,7 +454,7 @@ function loadBin(input: HTMLInputElement) {
} }
function exportBin() { function exportBin() {
if (!window.MonoDisplay) { alert("MonoDisplay library not loaded."); return; } // if (!window.MonoDisplay) { alert("MonoDisplay library not loaded."); return; }
const blob = new Blob([file.toBuffer() as any], { type: "application/octet-stream" }); const blob = new Blob([file.toBuffer() as any], { type: "application/octet-stream" });
const a = document.createElement("a"); const a = document.createElement("a");
a.href = URL.createObjectURL(blob); a.href = URL.createObjectURL(blob);
@ -447,7 +465,7 @@ function exportBin() {
// --- Mithril Components ------------------------------------------------------- // --- Mithril Components -------------------------------------------------------
function FieldInput(si: number, ei: number, field: ElFieldMeta, el: MonoDisplay.MonoFormatElement): m.Vnode { function FieldInput(si: number, ei: number, field: ElFieldMeta, el: MonoFormatElement): m.Vnode {
const val = (el as any)[field.key] ?? field.default; const val = (el as any)[field.key] ?? field.default;
switch (field.type) { switch (field.type) {
case "text": case "text":
@ -475,11 +493,11 @@ function FieldInput(si: number, ei: number, field: ElFieldMeta, el: MonoDisplay.
const PIXEL_SCALE = 4; const PIXEL_SCALE = 4;
function getPixelIndex(img: MonoDisplay.MonoFormatPixelImage, x: number, y: number): number { function getPixelIndex(img: MonoFormatPixelImage, x: number, y: number): number {
return y * img.width + x; return y * img.width + x;
} }
function drawPixelCanvas(canvas: HTMLCanvasElement, img: MonoDisplay.MonoFormatPixelImage) { function drawPixelCanvas(canvas: HTMLCanvasElement, img: MonoFormatPixelImage) {
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#000"; ctx.fillStyle = "#000";
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
@ -495,10 +513,11 @@ function drawPixelCanvas(canvas: HTMLCanvasElement, img: MonoDisplay.MonoFormatP
interface PixelCanvasState { drawing: boolean; drawValue: number; } interface PixelCanvasState { drawing: boolean; drawValue: number; }
const PixelCanvas: m.Component<{ img: MonoDisplay.MonoFormatPixelImage; onpaint: () => void }, PixelCanvasState> = { const PixelCanvas: m.Component<{ img: MonoFormatPixelImage; onpaint: () => void }, PixelCanvasState> = {
drawing: false, oninit(vnode) {
drawValue: 1, vnode.state.drawing = false;
vnode.state.drawValue = 1;
},
view({ attrs: { img, onpaint }, state }) { view({ attrs: { img, onpaint }, state }) {
function pixelFromEvent(e: MouseEvent): { x: number; y: number } | null { function pixelFromEvent(e: MouseEvent): { x: number; y: number } | null {
const canvas = e.currentTarget as HTMLCanvasElement; const canvas = e.currentTarget as HTMLCanvasElement;
@ -567,10 +586,10 @@ const PixelCanvas: m.Component<{ img: MonoDisplay.MonoFormatPixelImage; onpaint:
img.height = h; img.height = h;
// then read imageData from the scaled offscreen canvas as before // then read imageData from the scaled offscreen canvas as before
img.pixels = new Uint8Array(w * h).map((_, i) => { img.pixels = new Uint8Array(w * h).map((_, i) => {
const r = imageData.data[i * 4]; const r = imageData.data[i * 4] || 0;
const g = imageData.data[i * 4 + 1]; const g = imageData.data[i * 4 + 1] || 0;
const b = imageData.data[i * 4 + 2]; const b = imageData.data[i * 4 + 2] || 0;
const a = imageData.data[i * 4 + 3]; const a = imageData.data[i * 4 + 3] || 0;
// transparent = 0, dark pixels = 1, light pixels = 0 // transparent = 0, dark pixels = 1, light pixels = 0
return a > 128 && (r * 299 + g * 587 + b * 114) < 128_000 ? 0 : 1; return a > 128 && (r * 299 + g * 587 + b * 114) < 128_000 ? 0 : 1;
}); });
@ -586,7 +605,7 @@ const PixelCanvas: m.Component<{ img: MonoDisplay.MonoFormatPixelImage; onpaint:
// --- Image2D editor ----------------------------------------------------------- // --- Image2D editor -----------------------------------------------------------
const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatImage2D }> = { const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoFormatImage2D }> = {
view({ attrs: { si, ei, el } }) { view({ attrs: { si, ei, el } }) {
if (!el.image) { if (!el.image) {
const w = (el as any).width || W; const w = (el as any).width || W;
@ -604,9 +623,10 @@ const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoF
interface AnimationEditorState { activeFrame: number; } interface AnimationEditorState { activeFrame: number; }
const AnimationEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatAnimation }, AnimationEditorState> = { const AnimationEditor: m.Component<{ si: number; ei: number; el: MonoFormatAnimation }, AnimationEditorState> = {
activeFrame: 0, oninit(vnode) {
vnode.state.activeFrame = 0;
},
view({ attrs: { si, ei, el }, state }) { view({ attrs: { si, ei, el }, state }) {
const w = el.width || W; const w = el.width || W;
const h = el.height || H; const h = el.height || H;
@ -649,7 +669,7 @@ const AnimationEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.Mon
m("button.add-el", { onclick: addFrame }, "+ frame"), m("button.add-el", { onclick: addFrame }, "+ frame"),
), ),
m("div", { key: state.activeFrame }, m("div", { key: state.activeFrame },
m(PixelCanvas, { m(PixelCanvas as any, {
img: frame, img: frame,
onpaint: () => { markDirty(); triggerPreview(); }, onpaint: () => { markDirty(); triggerPreview(); },
}), }),
@ -658,10 +678,10 @@ const AnimationEditor: m.Component<{ si: number; ei: number; el: MonoDisplay.Mon
}, },
}; };
const ElementItem: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFormatElement }> = { const ElementItem: m.Component<{ si: number; ei: number; el: MonoFormatElement }> = {
view({ attrs: { si, ei, el } }) { view({ attrs: { si, ei, el } }) {
const isActive = activeSecIndex === si && activeElIndex === ei; const isActive = activeSecIndex === si && activeElIndex === ei;
const typeStr = MonoDisplay.ElementTypeToString[el.type]; const typeStr = ElementTypeToString[el.type];
const fields = EL_FIELDS[typeStr] || []; const fields = EL_FIELDS[typeStr] || [];
const flags = EL_FLAGS[typeStr] || []; const flags = EL_FLAGS[typeStr] || [];
@ -678,10 +698,10 @@ const ElementItem: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFor
m(".field.full", m(".field.full",
m("label", "Type"), m("label", "Type"),
m("select", { m("select", {
onchange: (e: Event) => changeElType(si, ei, (e.target as HTMLSelectElement).value as MonoDisplay.ElementTypeName), onchange: (e: Event) => changeElType(si, ei, (e.target as HTMLSelectElement).value as ElementTypeName),
}, },
EL_TYPES.map(t => EL_TYPES.map(t =>
m("option", { value: MonoDisplay.ElementTypeToString[t], selected: t === el.type }, MonoDisplay.ElementTypeToString[t]) m("option", { value: ElementTypeToString[t], selected: t === el.type }, ElementTypeToString[t])
) )
), ),
), ),
@ -707,15 +727,15 @@ const ElementItem: m.Component<{ si: number; ei: number; el: MonoDisplay.MonoFor
) )
), ),
) : null, ) : null,
el.type === MonoDisplay.ElementType.Image2D el.type === ElementType.Image2D
? m(".field.full", ? m(".field.full",
m("label", "Pixels"), m("label", "Pixels"),
m(Image2DEditor, { si, ei, el: el as MonoDisplay.MonoFormatImage2D }), m(Image2DEditor, { si, ei, el: el as MonoFormatImage2D }),
) )
: el.type === MonoDisplay.ElementType.Animation : el.type === ElementType.Animation
? m(".field.full", ? m(".field.full",
m("label", "Frames"), m("label", "Frames"),
m(AnimationEditor, { si, ei, el: el as MonoDisplay.MonoFormatAnimation }), m(AnimationEditor, { si, ei, el: el as MonoFormatAnimation }),
) )
: null, : null,
), ),
@ -728,7 +748,7 @@ const SectionCard: m.Component<{ si: number }> = {
const section = file.sections[si]; const section = file.sections[si];
if (!section) return null; if (!section) return null;
const isActive = activeSecIndex === si; const isActive = activeSecIndex === si;
const typeStr = MonoDisplay.SectionTypeToString[section.sectionType]; const typeStr = SectionTypeToString[section.sectionType];
const hdr = m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) }, const hdr = m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) },
m("span.sec-arrow", "▶"), m("span.sec-arrow", "▶"),
@ -739,7 +759,7 @@ const SectionCard: m.Component<{ si: number }> = {
}, "X"), }, "X"),
); );
if (section.sectionType === MonoDisplay.SectionType.CustomFont) { if (section.sectionType === SectionType.CustomFont) {
return m(`.sec-card.open${isActive ? ".active" : ""}`, return m(`.sec-card.open${isActive ? ".active" : ""}`,
m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) }, m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) },
m("span.sec-arrow", "▶"), m("span.sec-arrow", "▶"),
@ -754,7 +774,7 @@ const SectionCard: m.Component<{ si: number }> = {
); );
} }
const elSection = section as MonoDisplay.MonoFormatElementsAlways | MonoDisplay.MonoFormatElementsTimespan; const elSection = section as MonoFormatElementsAlways | MonoFormatElementsTimespan;
return m(`.sec-card.open${isActive ? ".active" : ""}`, return m(`.sec-card.open${isActive ? ".active" : ""}`,
m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) }, m(`.sec-hdr${isActive ? ".active" : ""}`, { onclick: () => toggleSection(si) },
@ -766,7 +786,7 @@ const SectionCard: m.Component<{ si: number }> = {
onclick: (e: Event) => { e.stopPropagation(); removeSection(si); }, onclick: (e: Event) => { e.stopPropagation(); removeSection(si); },
}, "X"), }, "X"),
), ),
section.sectionType === MonoDisplay.SectionType.ElementsTimespan section.sectionType === SectionType.ElementsTimespan
? m(".el-list", ? m(".el-list",
([["Start Time", "startTimestamp"], ["Stop Time", "endTimestamp"]] as [string, string][]) ([["Start Time", "startTimestamp"], ["Stop Time", "endTimestamp"]] as [string, string][])
.map(([label, key]) => { .map(([label, key]) => {
@ -814,12 +834,12 @@ const MetaPanel: m.Component = {
m("label", "Selected section"), m("label", "Selected section"),
section section
? m("select.meta-val.green", { ? m("select.meta-val.green", {
value: MonoDisplay.SectionTypeToString[section.sectionType], value: SectionTypeToString[section.sectionType],
onchange: (e: Event) => onchange: (e: Event) =>
activeSecIndex !== null && setSectionType(activeSecIndex, (e.target as HTMLSelectElement).value), activeSecIndex !== null && setSectionType(activeSecIndex, (e.target as HTMLSelectElement).value),
}, },
Object.values(MonoDisplay.SectionTypeToString).map(name => Object.values(SectionTypeToString).map(name =>
m("option", { value: name, selected: name === MonoDisplay.SectionTypeToString[section.sectionType] }, name) m("option", { value: name, selected: name === SectionTypeToString[section.sectionType] }, name)
) )
) )
: m("span.meta-val.green", "-"), : m("span.meta-val.green", "-"),

@ -0,0 +1,156 @@
import { readFileSync, writeFileSync } from "fs";
import { resolve, dirname, relative } from "path";
import ts from "typescript";
const ENTRY = resolve("src/index.ts");
const OUT = resolve("dist/index.d.ts");
const configFile = ts.findConfigFile("./", ts.sys.fileExists, "tsconfig.json");
const configHost: ts.ParseConfigFileHost = {
...ts.sys,
onUnRecoverableConfigFileDiagnostic: (d) => {
throw new Error(ts.flattenDiagnosticMessageText(d.messageText, "\n"));
},
};
const parsed = ts.getParsedCommandLineOfConfigFile(
configFile!,
{ emitDeclarationOnly: true, declaration: true, noEmit: false },
configHost
)!;
const program = ts.createProgram([ENTRY], parsed.options);
const checker = program.getTypeChecker();
const emitted = new Set<string>(); // dedupe across files
const lines: string[] = ["// Auto-generated by build-dts.ts", ""];
function relativePath(fullfilename: string): string {
return relative(process.cwd(), fullfilename);
}
function fileText(sf: ts.SourceFile, node: ts.Node): string {
return sf.text.slice(node.getFullStart(), node.getEnd()).trim();
}
function tryResolveType(sf: ts.SourceFile, node: ts.TypeAliasDeclaration): string | null {
try {
const type = checker.getTypeAtLocation(node.name);
const resolved = checker.typeToString(
type,
undefined,
ts.TypeFormatFlags.NoTruncation | ts.TypeFormatFlags.UseFullyQualifiedType
);
if (resolved === node.name.text) return null;
return `export type ${node.name.text} = ${resolved};`;
} catch {
return null;
}
}
function visitFile(sf: ts.SourceFile) {
function visit(node: ts.Node) {
const isExported = (n: ts.Node) =>
(n as any).modifiers?.some((m: ts.Modifier) => m.kind === ts.SyntaxKind.ExportKeyword);
// export * from "./x" or export type * from "./x" — follow and inline
if (ts.isExportDeclaration(node)) {
const modSpec = node.moduleSpecifier;
if (modSpec && ts.isStringLiteral(modSpec)) {
const resolved = ts.resolveModuleName(
modSpec.text,
sf.fileName,
parsed.options,
ts.sys
).resolvedModule;
if (resolved && !resolved.isExternalLibraryImport) {
const targetSf = program.getSourceFile(resolved.resolvedFileName);
if (targetSf) {
visitFile(targetSf); // recurse into the re-exported file
return;
}
}
}
// external or unresolved — copy verbatim
const text = fileText(sf, node);
if (!emitted.has(text)) {
emitted.add(text);
lines.push(`// ${relativePath(sf.fileName)}\n`);
lines.push(text);
}
return;
}
// export type Foo = ...
if (ts.isTypeAliasDeclaration(node) && isExported(node)) {
const resolved = tryResolveType(sf, node);
const text = resolved ?? fileText(sf, node);
if (!emitted.has(node.name.text)) {
emitted.add(node.name.text);
lines.push(`// ${relativePath(sf.fileName)}\n`);
lines.push(text);
}
return;
}
// export interface, enum, const enum
if (
(ts.isInterfaceDeclaration(node) || ts.isEnumDeclaration(node)) &&
isExported(node)
) {
const text = fileText(sf, node);
if (!emitted.has((node as any).name.text)) {
emitted.add((node as any).name.text);
lines.push(`// ${relativePath(sf.fileName)}\n`);
lines.push(text);
}
return;
}
// export class
if (ts.isClassDeclaration(node) && isExported(node) && node.name) {
const text = fileText(sf, node);
if (!emitted.has(node.name.text)) {
emitted.add(node.name.text);
lines.push(`// ${relativePath(sf.fileName)}\n`);
lines.push(text);
}
return;
}
// export function / export const
if (
(ts.isFunctionDeclaration(node) || ts.isVariableStatement(node)) &&
isExported(node)
) {
try {
if (ts.isFunctionDeclaration(node) && node.name) {
const sym = checker.getSymbolAtLocation(node.name);
if (sym && !emitted.has(sym.name)) {
emitted.add(sym.name);
const type = checker.getTypeOfSymbolAtLocation(sym, node);
for (const sig of type.getCallSignatures()) {
lines.push(`export declare function ${sym.name}${checker.signatureToString(sig)};`);
}
return;
}
}
} catch { }
const text = fileText(sf, node);
if (!emitted.has(text)) { emitted.add(text); lines.push(text); }
return;
}
ts.forEachChild(node, visit);
}
ts.forEachChild(sf, visit);
}
const entryFile = program.getSourceFile(ENTRY)!;
visitFile(entryFile);
lines.push("");
writeFileSync(OUT, lines.join("\n"));
// console.log(`✓ wrote ${OUT}`);

@ -21,7 +21,7 @@
"@types/mithril": ["@types/mithril@2.2.8", "", {}, "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ=="], "@types/mithril": ["@types/mithril@2.2.8", "", {}, "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ=="],
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
@ -29,6 +29,6 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
} }
} }

@ -1,112 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>MonoDisplay Editor</title>
<script>!function(){var t=localStorage.getItem('theme');t&&document.documentElement.setAttribute('data-theme',t)}();</script>
<link href="style.css" rel="stylesheet"/>
</head>
<body>
<div id="topbar">
<span class="app-name">MonoDisplay</span>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="document.getElementById('file-input').click()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Load .bin
</button>
<input type="file" id="file-input" accept=".bin" onchange="loadBin(this)" />
<button class="tb-btn" onclick="exportBin()">
<svg viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export .bin
</button>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="clearAll()">
<svg viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
Clear all
</button>
<button class="tb-btn" onclick="addSection()">
<svg viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add section
</button>
<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 id="main">
<div id="left">
<span class="pv-label">preview · 120 × 60</span>
<div id="display-box">
<canvas id="canvas_root" width="120" height="60"></canvas>
</div>
<span class="pv-label">demos</span>
<div id="demo-btns"></div>
<div id="sec-meta">
<div class="meta-row">
<label>Selected section</label>
<span class="meta-val green" id="meta-name">-</span>
</div>
<div class="meta-row">
<label>Elements</label>
<span class="meta-val" id="meta-count">-</span>
</div>
<div class="meta-row">
<label>Section flags</label>
<label class="flag-check">
<input type="checkbox" id="flag-drawFront" onchange="setSectionFlag('drawFront',this.checked)" />
drawFront
</label>
<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)" />
clearBuffer
</label>
</div>
</div>
</div>
<div id="right">
<div id="right-hdr">
<span class="rh-title">sections</span>
</div>
<div id="sections-wrap">
<div class="empty-state">No sections yet.<br />Use <b>Add section</b> to get started.</div>
</div>
</div>
</div>
<!-- confirm dialog -->
<div id="confirm-overlay">
<div id="confirm-box">
<div id="confirm-msg"></div>
<div id="confirm-btns"></div>
</div>
</div>
<script src="./public/mono-display.js"></script>
</body>
</html>

@ -1,215 +0,0 @@
<?php
session_start();
define('PASSWORD', 'password');
define('FILES_DIR', __DIR__ . '/avj2305');
define('ACTIVE_FILE', __DIR__ . '/active.txt');
// =============================================================================
// HELPERS
// =============================================================================
function redirect(string $url): never {
header('Location: ' . $url);
exit;
}
function require_auth(): void {
if (empty($_SESSION['auth'])) redirect('?login');
}
function get_active(): string {
if (!file_exists(ACTIVE_FILE)) return '';
$v = trim(file_get_contents(ACTIVE_FILE));
return $v !== '' ? $v : '';
}
function set_active(string $filename): void {
file_put_contents(ACTIVE_FILE, $filename);
}
function get_bin_files(): array {
$files = glob(FILES_DIR . '/*.bin');
return $files ? array_map('basename', $files) : [];
}
function safe_filename(string $name): bool {
return $name === basename($name)
&& str_ends_with($name, '.bin')
&& !str_contains($name, "\0");
}
function has_png(string $bin_basename): bool {
$png = substr($bin_basename, 0, -4) . '.png';
return file_exists(FILES_DIR . '/' . $png);
}
function png_path(string $bin_basename): string {
return FILES_DIR . '/' . substr($bin_basename, 0, -4) . '.png';
}
// =============================================================================
// ACTIONS (POST handlers - redirect, never render)
// =============================================================================
function action_login(): never {
if (hash_equals(PASSWORD, $_POST['password'] ?? '')) {
$_SESSION['auth'] = true;
redirect('?edit');
}
redirect('?login&err=1');
}
function action_set_file(): never {
require_auth();
$f = $_POST['file'] ?? '';
if (safe_filename($f) && file_exists(FILES_DIR . '/' . $f)) {
set_active($f);
}
redirect('?edit');
}
// =============================================================================
// RENDERERS (GET handlers - output HTML, never redirect)
// =============================================================================
function render_login(): never {
$err = !empty($_GET['err']); ?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Login</title>
<style>
body{font:14px monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#111;color:#eee}
form{display:flex;flex-direction:column;gap:8px;width:220px}
input[type=password]{padding:6px;background:#222;border:1px solid #555;color:#eee}
button{padding:6px;background:#444;border:none;color:#eee;cursor:pointer}
button:hover{background:#555}
.err{color:#f66;font-size:12px}
</style>
</head>
<body>
<form method="post" action="?login">
<label>Password</label>
<input type="password" name="password" autofocus>
<button type="submit">Login</button>
<?php if ($err): ?><span class="err">Wrong password.</span><?php endif; ?>
</form>
</body></html>
<?php exit; }
function render_edit(): never {
require_auth();
$files = get_bin_files();
$active = get_active(); ?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Select File</title>
<style>
body{font:14px monospace;background:#111;color:#eee;padding:24px;margin:0}
h2{margin:0 0 16px}
ul{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:6px}
li form{margin:0}
.row{display:flex;align-items:center;gap:10px}
button{padding:6px 12px;background:#333;border:1px solid #555;color:#eee;cursor:pointer;flex:1;text-align:left}
button:hover{background:#444}
button.active{border-color:#6af;color:#6af}
.thumb{width:480px;height:270px;object-fit:cover;border:1px solid #444;background:#222;flex-shrink:0}
.nothumb{width:64px;height:64px;flex-shrink:0}
.none{color:#888}
</style>
</head>
<body>
<h2>Live file selector</h2>
<p>Active: <strong><?= $active !== '' ? htmlspecialchars($active) : '<span class="none">none</span>' ?></strong></p>
<?php if ($files): ?>
<ul>
<?php foreach ($files as $f): ?>
<li>
<form method="post" action="?edit">
<div class="row">
<?php if (has_png($f)): ?>
<img class="thumb" src="?img=<?= urlencode($f) ?>" alt="">
<?php else: ?>
<div class="nothumb"></div>
<?php endif; ?>
<input type="hidden" name="file" value="<?= htmlspecialchars($f) ?>">
<button type="submit"<?= $f === $active ? ' class="active"' : '' ?>><?= htmlspecialchars($f) ?></button>
</div>
</form>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<p class="none">No .bin files found in avj2305/</p>
<?php endif; ?>
</body></html>
<?php exit; }
// =============================================================================
// FILE SERVER (default route)
// =============================================================================
function serve_png(): never {
require_auth();
$f = $_GET['img'] ?? '';
if (!safe_filename($f)) {
http_response_code(400); exit('Invalid filename.');
}
$path = png_path($f);
if (!file_exists($path)) {
http_response_code(404); exit('No preview.');
}
header('Content-Type: image/png');
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
function serve_active_file(): never {
$active = get_active();
if ($active === '') {
http_response_code(404); exit('No active file set.');
}
if (!safe_filename($active)) {
http_response_code(500); exit('Invalid filename.');
}
$path = FILES_DIR . '/' . $active;
if (!file_exists($path)) {
http_response_code(404); exit('File not found.');
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: inline; filename="' . addslashes($active) . '"');
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
// =============================================================================
// ROUTES
// =============================================================================
//
// GET /live.php -> serve active .bin file
// GET /live.php?login -> login page
// POST /live.php?login -> process login
// GET /live.php?edit -> file picker [auth required]
// POST /live.php?edit -> set active file [auth required]
// GET /live.php?img=x.bin -> serve x.png preview [auth required]
//
// =============================================================================
$method = $_SERVER['REQUEST_METHOD'];
$route = array_key_first($_GET) ?? '';
match (true) {
$method === 'POST' && $route === 'login' => action_login(),
$method === 'POST' && $route === 'edit' => action_set_file(),
$method === 'GET' && $route === 'login' => render_login(),
$method === 'GET' && $route === 'edit' => render_edit(),
$method === 'GET' && $route === 'img' => serve_png(),
default => serve_active_file(),
};

@ -4,16 +4,16 @@
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {
"require": "./public/index.cjs", "require": "./dist/index.cjs",
"import": "./public/index.mjs", "import": "./dist/index.mjs",
"types": "./public/index.d.ts" "types": "./dist/index.d.ts"
} }
}, },
"scripts": { "scripts": {
"build": "bun run buildcjs && bun run buildmjs && bun run builddts", "build": "bun run buildcjs && bun run buildmjs && bun run builddts",
"buildcjs": "bun build src/index.ts --target browser --format cjs --outfile public/index.cjs", "buildcjs": "bun build src/index.ts --target browser --format cjs --outfile dist/index.cjs",
"buildmjs": "bun build src/index.ts --target browser --format esm --outfile public/index.mjs", "buildmjs": "bun build src/index.ts --target browser --format esm --outfile dist/index.mjs",
"builddts": "bun build src/index.ts --dts --outfile public/index.d.ts", "builddts": "bun run build-dts.ts",
"test": "bun test" "test": "bun test"
}, },
"devDependencies": { "devDependencies": {

@ -51,9 +51,16 @@
export * from "./types"; export * from "./types";
export { BinaryReader, packPixels, unpackPixels, packedSize, pad32 } from "./helper"; export type * from "./types";
export { MonoDisplayParser, MONOFORMAT_MAGIC_HEADER } from "./parser"; export * from "./helper";
export { MonoDisplayRenderer } from "./renderer"; export type * from "./helper";
export { type MonoDisplayDriverOptions, MonoDisplayDriver } from "./driver"; export * from "./parser";
export { MonoDisplayFile, loadBinFile, buildBinBuffer } from "./file"; export type * from "./parser";
export { cycleTheme } from "./themes" export * from "./renderer";
export type * from "./renderer";
export * from "./driver";
export type * from "./driver";
export * from "./file";
export type * from "./file";
export * from "./themes";
export type * from "./themes";

@ -1,682 +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: #1e2e1e;
--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
}
#demo-btns > div {
display: contents;
}
canvas.pixel-editor {
display: block;
cursor: crosshair;
image-rendering: pixelated;
max-width: 100%;
border: 1px solid var(--bd-inner);
}
.anim-editor {
display: flex;
flex-direction: column;
gap: 4px;
}
.anim-frame-tabs {
display: flex;
flex-wrap: wrap;
gap: 3px;
align-items: center;
}
.anim-frame-tab {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 6px;
border: 1px solid var(--bd-inner);
border-radius: 3px;
cursor: pointer;
font-size: 11px;
user-select: none;
}
.anim-frame-tab.active {
border-color: var(--accent, #EC0);
color: var(--accent, #EC0);
}
.x-btn-sm {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0 2px;
font-size: 13px;
line-height: 1;
opacity: 0.6;
}
.x-btn-sm:hover { opacity: 1; }
#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)
}
select.meta-val {
background: var(--bg-input);
color: var(--ac);
border: 1px solid var(--bd-inner);
border-radius: 3px;
padding: 1px 4px;
}
.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,
.sec-hdr.active {
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 input[type=datetime-local],
.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