diff --git a/.gitignore b/.gitignore index 27d943d..b8448b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ build/ *.o *~ +cpp/flake.nix +cpp/flake.lock +cpp/.direnv/* diff --git a/GPN24/120px-GPN24.png b/GPN24/120px-GPN24.png new file mode 100644 index 0000000..601a2b6 Binary files /dev/null and b/GPN24/120px-GPN24.png differ diff --git a/GPN24/120px-GPN24_.png b/GPN24/120px-GPN24_.png new file mode 100644 index 0000000..7960c47 Binary files /dev/null and b/GPN24/120px-GPN24_.png differ diff --git a/GPN24/120px-GPN24_wifi0.png b/GPN24/120px-GPN24_wifi0.png new file mode 100644 index 0000000..c6d3679 Binary files /dev/null and b/GPN24/120px-GPN24_wifi0.png differ diff --git a/GPN24/120px-GPN24_wifi1.png b/GPN24/120px-GPN24_wifi1.png new file mode 100644 index 0000000..3aae552 Binary files /dev/null and b/GPN24/120px-GPN24_wifi1.png differ diff --git a/GPN24/120px-GPN24_wifi2.png b/GPN24/120px-GPN24_wifi2.png new file mode 100644 index 0000000..fac9b2d Binary files /dev/null and b/GPN24/120px-GPN24_wifi2.png differ diff --git a/GPN24/120px-GPN24_wifi3.png b/GPN24/120px-GPN24_wifi3.png new file mode 100644 index 0000000..f9e58f4 Binary files /dev/null and b/GPN24/120px-GPN24_wifi3.png differ diff --git a/GPN24/300px-GPN24.png b/GPN24/300px-GPN24.png new file mode 100644 index 0000000..051db97 Binary files /dev/null and b/GPN24/300px-GPN24.png differ diff --git a/GPN24/300px-GPN24_g.png b/GPN24/300px-GPN24_g.png new file mode 100644 index 0000000..1a8bcf5 --- /dev/null +++ b/GPN24/300px-GPN24_g.png @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + diff --git a/GPN24/300px-GPN24_g.svg b/GPN24/300px-GPN24_g.svg new file mode 100644 index 0000000..219dc69 --- /dev/null +++ b/GPN24/300px-GPN24_g.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + diff --git a/GPN24/300px-GPN24_gray.png b/GPN24/300px-GPN24_gray.png new file mode 100644 index 0000000..5260033 Binary files /dev/null and b/GPN24/300px-GPN24_gray.png differ diff --git a/GPN24/300px-GPN24_gray2.png b/GPN24/300px-GPN24_gray2.png new file mode 100644 index 0000000..eeddc67 --- /dev/null +++ b/GPN24/300px-GPN24_gray2.png @@ -0,0 +1,71 @@ + + + + + + + + + + + 24 + + + diff --git a/GPN24/300px-GPN24_gray3.png b/GPN24/300px-GPN24_gray3.png new file mode 100644 index 0000000..c56ba50 --- /dev/null +++ b/GPN24/300px-GPN24_gray3.png @@ -0,0 +1,99 @@ + + + + + + + + + + + 24 + + + ...waiting for wifi + cttue + + diff --git a/GPN24/300px-GPN24_gray3.svg b/GPN24/300px-GPN24_gray3.svg new file mode 100644 index 0000000..3fdb958 --- /dev/null +++ b/GPN24/300px-GPN24_gray3.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + 24 + + + ...waiting for wifi + cttue + + diff --git a/GPN24/300px-GPN24_gray4.svg b/GPN24/300px-GPN24_gray4.svg new file mode 100644 index 0000000..c1983eb --- /dev/null +++ b/GPN24/300px-GPN24_gray4.svg @@ -0,0 +1,135 @@ + + + + + + + + + + + + + 24 + + + ...waiting for wifi + + cttue + + + + + + diff --git a/cpp/.envrc b/cpp/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/cpp/.envrc @@ -0,0 +1 @@ +use flake diff --git a/cpp/Makefile b/cpp/Makefile new file mode 100644 index 0000000..eb3e48f --- /dev/null +++ b/cpp/Makefile @@ -0,0 +1,23 @@ +BUILD_DIR := build + +.PHONY: all configure build clean run + +all: build + +configure: + cmake -S . -B $(BUILD_DIR) -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DCMAKE_C_COMPILER=clang \ + -DCMAKE_CXX_COMPILER=clang++ + -DBUILD_CLI_VIS=1 + +build: configure + cmake --build $(BUILD_DIR) + @# Symlink compile_commands.json to root so clangd picks it up + @ln -sf $(BUILD_DIR)/compile_commands.json compile_commands.json + +run: build + ./$(BUILD_DIR)/myapp + +clean: + rm -rf $(BUILD_DIR) compile_commands.json diff --git a/cpp/flake.nix b/cpp/flake.nix new file mode 100644 index 0000000..71ce971 --- /dev/null +++ b/cpp/flake.nix @@ -0,0 +1,28 @@ +{ + description = "C++ dev shell with CMake and clangd"; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { self, nixpkgs }: + let + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAll = f: nixpkgs.lib.genAttrs systems (s: f nixpkgs.legacyPackages.${s}); + in { + devShells = forAll (pkgs: { + default = pkgs.mkShell { + packages = with pkgs; [ + cmake + clang-tools # provides clangd, clang-format, clang-tidy + llvmPackages.clang + ninja # optional but pairs well with cmake + catch2 # testing framework + ]; + + shellHook = '' + export CC=clang + export CXX=clang++ + ''; + }; + }); + }; +} diff --git a/Specification.rst b/examples/v1/Specification.rst similarity index 100% rename from Specification.rst rename to examples/v1/Specification.rst diff --git a/examples/v1/franzwerk.bin b/examples/v1/franzwerk.bin new file mode 100644 index 0000000..f7d4a9b Binary files /dev/null and b/examples/v1/franzwerk.bin differ diff --git a/examples/v1/gpn24.bin b/examples/v1/gpn24.bin new file mode 100644 index 0000000..57117a0 Binary files /dev/null and b/examples/v1/gpn24.bin differ diff --git a/examples/v1/template.bin b/examples/v1/template.bin new file mode 100644 index 0000000..4f32e36 Binary files /dev/null and b/examples/v1/template.bin differ diff --git a/examples/v1/time.bin b/examples/v1/time.bin new file mode 100644 index 0000000..b038503 Binary files /dev/null and b/examples/v1/time.bin differ diff --git a/examples/v1/time_nice.bin b/examples/v1/time_nice.bin new file mode 100644 index 0000000..902709d Binary files /dev/null and b/examples/v1/time_nice.bin differ diff --git a/ts-editor/.gitignore b/ts-editor/.gitignore new file mode 100644 index 0000000..bceeac2 --- /dev/null +++ b/ts-editor/.gitignore @@ -0,0 +1,37 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +public +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store +.claude/ +.claude/settings.local.json diff --git a/ts-editor/CLAUDE.md b/ts-editor/CLAUDE.md new file mode 100644 index 0000000..764c1dd --- /dev/null +++ b/ts-editor/CLAUDE.md @@ -0,0 +1,106 @@ + +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun build ` instead of `webpack` or `esbuild` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + +``` + +With the following `frontend.tsx`: + +```tsx#frontend.tsx +import React from "react"; +import { createRoot } from "react-dom/client"; + +// import .css files directly and it works +import './index.css'; + +const root = createRoot(document.body); + +export default function Frontend() { + return

Hello, world!

; +} + +root.render(); +``` + +Then, run index.ts + +```sh +bun --hot ./index.ts +``` + +For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`. diff --git a/ts-editor/README.md b/ts-editor/README.md new file mode 100644 index 0000000..e5b9ae7 --- /dev/null +++ b/ts-editor/README.md @@ -0,0 +1,15 @@ +# libmonoformat-ts + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/ts-editor/bun.lock b/ts-editor/bun.lock new file mode 100644 index 0000000..029cd31 --- /dev/null +++ b/ts-editor/bun.lock @@ -0,0 +1,37 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "libmonoformat", + "dependencies": { + "@types/mithril": "^2.2.8", + "libmonoformat": "../ts", + "mithril": "^2.3.8", + }, + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/mithril": ["@types/mithril@2.2.8", "", {}, "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ=="], + + "@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=="], + + "libmonoformat": ["libmonoformat@file:../ts", { "dependencies": { "@types/mithril": "^2.2.8", "mithril": "^2.3.8" }, "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5" } }], + + "mithril": ["mithril@2.3.8", "", {}, "sha512-za/Yo7qXEckjm5syrSfaaI9Utf4tCUT3T1IOIYqH6Lrj7G0OZuYYLAY9SV4ygoaAf0+CNqU92MBt+7pmo53JVQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + } +} diff --git a/ts/index.html b/ts-editor/index.html similarity index 93% rename from ts/index.html rename to ts-editor/index.html index 8ed80f1..c91f6e3 100644 --- a/ts/index.html +++ b/ts-editor/index.html @@ -32,6 +32,14 @@ Export .bin
+ + Wrong password. + + + + + +Select File + + + +

Live file selector

+

Active: none' ?>

+ +
    + +
  • +
    +
    + + + +
    + + + +
    +
    +
  • + +
+ +

No .bin files found in avj2305/

+ + + 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(), +}; diff --git a/ts-editor/package.json b/ts-editor/package.json new file mode 100644 index 0000000..0205c92 --- /dev/null +++ b/ts-editor/package.json @@ -0,0 +1,20 @@ +{ + "name": "libmonoformat", + "module": "src/index.ts", + "type": "module", + "scripts": { + "build": "bun build src/browser.ts --target browser --outfile public/mono-display.js", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "libmonoformat": "../ts", + "@types/mithril": "^2.2.8", + "mithril": "^2.3.8" + } +} \ No newline at end of file diff --git a/ts-editor/src/browser.ts b/ts-editor/src/browser.ts new file mode 100644 index 0000000..685760a --- /dev/null +++ b/ts-editor/src/browser.ts @@ -0,0 +1,926 @@ +// browser.ts - Mithril entry point +// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js + +import m from "mithril"; +import { + 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"; + + +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: 10 }, + { key: "width", label: "Width", type: "number", default: 30 }, + { key: "height", label: "Height", type: "number", default: 10 }, + { 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: 10 }, + { key: "width", label: "Width", type: "number", default: 30 }, + { key: "height", label: "Height", type: "number", default: 10 }, + { 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: 30 }, + { key: "height", label: "Height", type: "number", default: 10 }, + { key: "scrollSpeed", label: "Scroll speed", type: "number", default: 50 }, + { key: "fontIndex", label: "Font", type: "font", default: 0 }, + ], + Line: [ + { key: "xOrigin", label: "X offset", type: "number", default: 0 }, + { key: "yOrigin", label: "Y offset", type: "number", default: 32 }, + { key: "xTarget", label: "X offset", type: "number", default: 0 }, + { key: "yTarget", label: "Y offset", type: "number", default: 32 }, + { key: "linestyle", label: "Lintylee S", type: "number", 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: 120 }, + { key: "fontIndex", label: "Font", type: "font", default: 0 }, + ], +}; + +const EL_FLAGS: Partial> = { + HScrollText: [ + { key: "endless", label: "Endless" }, + { key: "invertDirection", label: "Invert direction" }, + { key: "padBefore", label: "Pad Before" }, + { key: "padAfter", label: "Pad After" }, + ], + 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: ElementType[] = Object.keys(EL_FIELDS).map( + (x) => StringToElementType[x as ElementTypeName] +); + +const DEFAULT_FONTS = Object.keys(MonoDisplayRenderer.builtinFonts); + +// --- 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"); + }); +} + +// --- Persistence ------------------------------------------------------------- +const STORAGE_KEY = "monodisplay_autosave"; + +function saveToStorage() { + try { + const buf = file.toBuffer(); + const b64 = btoa(String.fromCharCode(...buf)); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ filename: currentFilename, data: b64 })); + } catch { } +} + +function loadFromStorage() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const { filename, data } = JSON.parse(raw); + const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0)); + const parsed = new MonoDisplayParser().parse(bytes.buffer); + file = new MonoDisplayFile(parsed.sections); + currentFilename = filename; + const filenameEl = document.getElementById("filename"); + if (filenameEl) filenameEl.textContent = filename; + isDirty = true; + document.getElementById("filename")?.classList.add("dirty"); + m.redraw(); + } catch { } +} + +// --- Dirty tracking ---------------------------------------------------------- +function markDirty() { + isDirty = true; + document.getElementById("filename")?.classList.add("dirty"); + saveToStorage(); +} +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(): MonoFormatElementsAlways { + return { + sectionType: SectionType.ElementsAlways, elements: [], flags: { + clearBuffer: true, + drawBack: true, + drawFront: true, + } + }; +} + +function createNewElement(type: ElementType): MonoFormatElement { + const fields: Record = {}; + (EL_FIELDS[ElementTypeToString[type]] as ElFieldMeta[] || []) + .forEach(f => (fields[f.key] = f.default)); + const flags: Record = {}; + (EL_FLAGS[ElementTypeToString[type]] as ElFlagMeta[] || []) + .forEach(f => (flags[f.key] = f.default ?? false)); + const el: any = { type, ...fields, flags }; + if (type === ElementType.Image2D) { + el.image = { pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height }; + } + if (type === ElementType.Animation) { + el.frames = [{ pixels: new Uint8Array(el.width * el.height), width: el.width, height: el.height }]; + } + return el as MonoFormatElement; +} + +function getSectionByIndex(i: number | null): MonoFormatSection | undefined { + return i !== null ? file.sections[i] : undefined; +} + +function getElementByIndex(si: number, ei: number): 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 === SectionType.CustomFont) + .map((_, i) => ({ fontname: `CustomFont ${i}`, index: 0x8000 + i })); +} + +function elSummary(el: MonoFormatElement): string { + switch (el.type) { + case ElementType.ClippedText: + case ElementType.HScrollText: + return el.text.slice(0, 24) + (el.text.length > 24 ? "..." : ""); + case ElementType.CurrentTime: + case ElementType.Image2D: + case ElementType.HorizontalScroll: + case 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 !== SectionType.CustomFont; + (sec as any).sectionType = StringToSectionType[sectionType as SectionTypeName]; + if ((sec as any).sectionType === 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 === 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(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: ElementTypeName) { + const el = getElementByIndex(si, ei); + if (!el) return; + const fresh = createNewElement(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: 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: 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: SectionType.ElementsAlways, + elements: [{ type: 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: 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: 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, 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, 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 as any)._mdDriver) + (window as any)._mdDriver = new MonoDisplayDriver("canvas_root", { onColor: "#EC0", offColor: "#000", fps: 25 }); + (window as any)._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 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: 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)), + ]); + case "list": + return m("span"); // rendered by dedicated editor + default: + return m("input[type=number]", { + value: val, + onchange: (e: Event) => setElField(si, ei, field.key, +(e.target as HTMLInputElement).value), + }); + } +} + +// --- Shared pixel canvas ------------------------------------------------------ + +const PIXEL_SCALE = 4; + +function getPixelIndex(img: MonoFormatPixelImage, x: number, y: number): number { + return y * img.width + x; +} + +function drawPixelCanvas(canvas: HTMLCanvasElement, img: MonoFormatPixelImage) { + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#EC0"; + for (let y = 0; y < img.height; y++) { + for (let x = 0; x < img.width; x++) { + if (img.pixels[getPixelIndex(img, x, y)]) { + ctx.fillRect(x * PIXEL_SCALE, y * PIXEL_SCALE, PIXEL_SCALE, PIXEL_SCALE); + } + } + } +} + +interface PixelCanvasState { drawing: boolean; drawValue: number; } + +const PixelCanvas: m.Component<{ img: MonoFormatPixelImage; onpaint: () => void }, PixelCanvasState> = { + oninit(vnode) { + vnode.state.drawing = false; + vnode.state.drawValue = 1; + }, + view({ attrs: { img, onpaint }, state }) { + function pixelFromEvent(e: MouseEvent): { x: number; y: number } | null { + const canvas = e.currentTarget as HTMLCanvasElement; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const x = Math.floor((e.clientX - rect.left) * scaleX / PIXEL_SCALE); + const y = Math.floor((e.clientY - rect.top) * scaleY / PIXEL_SCALE); + if (x < 0 || x >= img.width || y < 0 || y >= img.height) return null; + return { x, y }; + } + + function paint(e: MouseEvent) { + const p = pixelFromEvent(e); + if (!p) return; + img.pixels[getPixelIndex(img, p.x, p.y)] = state.drawValue; + onpaint(); + drawPixelCanvas(e.currentTarget as HTMLCanvasElement, img); + } + + return m("canvas.pixel-editor", { + width: img.width * PIXEL_SCALE, + height: img.height * PIXEL_SCALE, + oncreate: ({ dom }: m.VnodeDOM) => drawPixelCanvas(dom as HTMLCanvasElement, img), + onupdate: ({ dom }: m.VnodeDOM) => drawPixelCanvas(dom as HTMLCanvasElement, img), + onmousedown: (e: MouseEvent) => { + state.drawing = true; + const p = pixelFromEvent(e); + if (p) state.drawValue = img.pixels[getPixelIndex(img, p.x, p.y)] ? 0 : 1; + paint(e); + }, + onmousemove: (e: MouseEvent) => { if (state.drawing) paint(e); }, + onmouseup: () => { state.drawing = false; }, + onmouseleave: () => { state.drawing = false; }, + ondragover: (e: DragEvent) => { + e.preventDefault(); + e.dataTransfer!.dropEffect = "copy"; + }, + + ondrop: (e: DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer?.files[0]; + if (!file || !file.type.startsWith("image/")) return; + + const url = URL.createObjectURL(file); + const htmlImg = new Image(); + htmlImg.onload = () => { + // Draw the dropped image into an offscreen canvas to read pixel data + const offscreen = document.createElement("canvas"); + offscreen.width = htmlImg.width; + offscreen.height = htmlImg.height; + const ctx = offscreen.getContext("2d")!; + ctx.drawImage(htmlImg, 0, 0); + + const imageData = ctx.getImageData(0, 0, htmlImg.width, htmlImg.height); + + // Convert to your pixel format — adjust this to match your img structure + const scale = Math.min(1, 120 / htmlImg.width, 60 / htmlImg.height); + const w = Math.round(htmlImg.width * scale); + const h = Math.round(htmlImg.height * scale); + + offscreen.width = w; + offscreen.height = h; + ctx.drawImage(htmlImg, 0, 0, w, h); // scales the image down while drawing + img.width = w; + img.height = h; + // then read imageData from the scaled offscreen canvas as before + img.pixels = new Uint8Array(w * h).map((_, i) => { + const r = imageData.data[i * 4] || 0; + const g = imageData.data[i * 4 + 1] || 0; + const b = imageData.data[i * 4 + 2] || 0; + const a = imageData.data[i * 4 + 3] || 0; + // transparent = 0, dark pixels = 1, light pixels = 0 + return a > 128 && (r * 299 + g * 587 + b * 114) < 128_000 ? 0 : 1; + }); + + URL.revokeObjectURL(url); + m.redraw(); + }; + htmlImg.src = url; + }, + }); + }, +}; + +// --- Image2D editor ----------------------------------------------------------- + +const Image2DEditor: m.Component<{ si: number; ei: number; el: MonoFormatImage2D }> = { + view({ attrs: { si, ei, el } }) { + if (!el.image) { + const w = (el as any).width || W; + const h = (el as any).height || H; + el.image = { pixels: new Uint8Array(w * h), width: w, height: h }; + } + return m(PixelCanvas, { + img: el.image, + onpaint: () => { markDirty(); triggerPreview(); }, + }); + }, +}; + +// --- Animation editor --------------------------------------------------------- + +interface AnimationEditorState { activeFrame: number; } + +const AnimationEditor: m.Component<{ si: number; ei: number; el: MonoFormatAnimation }, AnimationEditorState> = { + oninit(vnode) { + vnode.state.activeFrame = 0; + }, + view({ attrs: { si, ei, el }, state }) { + const w = el.width || W; + const h = el.height || H; + if (!el.frames) el.frames = []; + if (!el.frames.length) { + el.frames.push({ pixels: new Uint8Array(w * h), width: w, height: h }); + } + state.activeFrame = Math.min(state.activeFrame, el.frames.length - 1); + const frame = el.frames[state.activeFrame]; + + function addFrame() { + el.frames.push({ pixels: new Uint8Array(w * h), width: w, height: h }); + state.activeFrame = el.frames.length - 1; + markDirty(); triggerPreview(); m.redraw(); + } + + function removeFrame(fi: number) { + el.frames.splice(fi, 1); + if (!el.frames.length) + el.frames.push({ pixels: new Uint8Array(w * h), width: w, height: h }); + state.activeFrame = Math.min(state.activeFrame, el.frames.length - 1); + markDirty(); triggerPreview(); + } + + return m(".anim-editor", + m(".anim-frame-tabs", { key: "tabs" }, + el.frames.map((_, fi) => + m(".anim-frame-tab", { + class: fi === state.activeFrame ? "active" : "", + onclick: () => { state.activeFrame = fi; }, + }, + m("span", fi + 1), + el.frames.length > 1 + ? m("button.x-btn-sm", { + onclick: (e: Event) => { e.stopPropagation(); removeFrame(fi); }, + }, "×") + : null, + ) + ), + m("button.add-el", { onclick: addFrame }, "+ frame"), + ), + m("div", { key: state.activeFrame }, + m(PixelCanvas as any, { + img: frame, + onpaint: () => { markDirty(); triggerPreview(); }, + }), + ), + ); + }, +}; + +const ElementItem: m.Component<{ si: number; ei: number; el: MonoFormatElement }> = { + view({ attrs: { si, ei, el } }) { + const isActive = activeSecIndex === si && activeElIndex === ei; + const typeStr = 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 ElementTypeName), + }, + EL_TYPES.map(t => + m("option", { value: ElementTypeToString[t], selected: t === el.type }, 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, + el.type === ElementType.Image2D + ? m(".field.full", + m("label", "Pixels"), + m(Image2DEditor, { si, ei, el: el as MonoFormatImage2D }), + ) + : el.type === ElementType.Animation + ? m(".field.full", + m("label", "Frames"), + m(AnimationEditor, { si, ei, el: el as MonoFormatAnimation }), + ) + : 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 = 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 === 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 MonoFormatElementsAlways | 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 === 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: SectionTypeToString[section.sectionType], + onchange: (e: Event) => + activeSecIndex !== null && setSectionType(activeSecIndex, (e.target as HTMLSelectElement).value), + }, + Object.values(SectionTypeToString).map(name => + m("option", { value: name, selected: name === SectionTypeToString[section.sectionType] }, 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); + +async function clearAll() { + if (isDirty && file.sections.length) { + const ok = await showConfirm("Clear all sections?", [ + { label: "Cancel", action: false }, + { label: "Clear", primary: true, action: true }, + ]); + if (!ok) return; + } + file = new MonoDisplayFile([]); + activeSecIndex = null; + activeElIndex = null; + currentFilename = "untitled"; + const filenameEl = document.getElementById("filename"); + if (filenameEl) filenameEl.textContent = "untitled"; + localStorage.removeItem(STORAGE_KEY); + markClean(); + triggerPreview(); + m.redraw(); +} + +// Expose globals referenced by topbar inline handlers +Object.assign(window, { loadBin, exportBin, addSection, clearAll, cycleTheme }); + +// window.addEventListener("beforeunload", (e) => { +// if (isDirty) e.preventDefault(); +// }); + +loadFromStorage(); +triggerPreview(); diff --git a/ts/style.css b/ts-editor/style.css similarity index 90% rename from ts/style.css rename to ts-editor/style.css index 65e6be8..49a023e 100644 --- a/ts/style.css +++ b/ts-editor/style.css @@ -6,7 +6,7 @@ --bg-hover2: #222; --bg-sunken: #141414; --bg-accent: #141e14; - --bg-active: #182018; + --bg-active: #1e2e1e; --bg-deep: #0f0f0f; --bg-input: #0a0a0a; --bg-canvas: #000; @@ -252,6 +252,54 @@ body { 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); @@ -283,6 +331,14 @@ body { 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; @@ -365,7 +421,8 @@ body { background: var(--bg-hover) } -.sec-card.active .sec-hdr { +.sec-card.active .sec-hdr, +.sec-hdr.active { background: var(--bg-active) } @@ -502,6 +559,7 @@ body { .field input[type=text], .field input[type=number], +.field input[type=datetime-local], .field select, .field textarea { background: var(--bg-input); diff --git a/ts-editor/tsconfig.json b/ts-editor/tsconfig.json new file mode 100644 index 0000000..be3d138 --- /dev/null +++ b/ts-editor/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/ts/build-dts.ts b/ts/build-dts.ts new file mode 100644 index 0000000..cec210c --- /dev/null +++ b/ts/build-dts.ts @@ -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(); // 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}`); \ No newline at end of file diff --git a/ts/bun.lock b/ts/bun.lock index 434fd0d..2fcf62b 100644 --- a/ts/bun.lock +++ b/ts/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "libmonoformat", + "dependencies": { + "@types/mithril": "^2.2.8", + "mithril": "^2.3.8", + }, "devDependencies": { "@types/bun": "latest", }, @@ -15,12 +19,16 @@ "packages": { "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], + "@types/mithril": ["@types/mithril@2.2.8", "", {}, "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ=="], + + "@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=="], + "mithril": ["mithril@2.3.8", "", {}, "sha512-za/Yo7qXEckjm5syrSfaaI9Utf4tCUT3T1IOIYqH6Lrj7G0OZuYYLAY9SV4ygoaAf0+CNqU92MBt+7pmo53JVQ=="], + "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=="], } } diff --git a/ts/package.json b/ts/package.json index 1b8399c..b9156a9 100644 --- a/ts/package.json +++ b/ts/package.json @@ -2,8 +2,18 @@ "name": "libmonoformat", "module": "src/index.ts", "type": "module", + "exports": { + ".": { + "require": "./dist/index.cjs", + "import": "./dist/index.mjs", + "types": "./dist/index.d.ts" + } + }, "scripts": { - "build": "bun build src/browser.ts --target browser --outfile public/mono-display.js", + "build": "bun run buildcjs && bun run buildmjs && bun run builddts", + "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 dist/index.mjs", + "builddts": "bun run build-dts.ts", "test": "bun test" }, "devDependencies": { @@ -11,5 +21,9 @@ }, "peerDependencies": { "typescript": "^5" + }, + "dependencies": { + "@types/mithril": "^2.2.8", + "mithril": "^2.3.8" } -} +} \ No newline at end of file diff --git a/ts/src/browser.ts b/ts/src/browser.ts deleted file mode 100644 index 9d5a4dd..0000000 --- a/ts/src/browser.ts +++ /dev/null @@ -1,631 +0,0 @@ -// browser.ts — IIFE-style browser entry point -// Built by: bun build src/browser.ts --target browser --outfile public/mono-display.js -// Attaches everything to window.MonoDisplay so inline