Compare commits

...

45 Commits

Author SHA1 Message Date
flop 6871748164 fix: bugfixes so its clean 1 week ago
flop a6fbd31320 fix: imports for browser 1 week ago
flop 96852c9105 fix: remove old files 1 week ago
flop aec5200f6f feat: fix exports 1 week ago
flop c3ab9cd1b8 feat: go for dist instead of public folder 1 week ago
flop d34b6f3035 feat: changes for library exports 1 week ago
flop 480b10f764 refactor(editor): split out web editor 1 week ago
flop 5071016ded gatest wifi 1 week ago
flop d1878f491d chore: gpn24 wifi update 1 week ago
flop 1cbaee1dd2 chore: gpn24 animated wifi default image 1 week ago
flop 86a51d3cf9 feat: drag and drop support 1 week ago
flop e44da7cb20 chore: GPN24 default screen 1 week ago
flop e992a4c8e4 chore: template updates for gpn24 1 week ago
flop f543016595 chore: updates for 0.text 1 week ago
flop 213ead59b2 feat: update line support 1 week ago
flop 9a3e435cfc chore: updated template for gpn 1 week ago
flop 3d7daf8b74 chore: updated template for gpn24 1 week ago
flop 97a1feb06e chore: more example bin files 1 week ago
flop 79fa6e2fc8 feat: updated .bin file 1 week ago
flop 211480d61f updates 1 week ago
flop 9cdd672342 fix(kulturanzeiger_avj): updated formats 1 week ago
flop 3e497f512b feat(browser): add two more flags to text scroll 1 week ago
flop 9a1d9c1d6a fix: top align fonts 1 week ago
flop 6e169930f7 original files 1 week ago
flop 28667774dd feat(php): updates to live 1 week ago
flop 178243a9cc feat(php): update live 1 week ago
flop 9e31555d6f feat(cpp): add nix build stuff 1 week ago
flop 1fb38e3e0d feat(php): live script 1 week ago
flop 1831ef6f95 feat: fix dark/light style 1 week ago
flop d6c712dfc3 feat: vibe color 1 week ago
flop f674459af9 fix: selection bug 1 week ago
flop cca5be7e23 fix: font rendering 1 week ago
flop ef63b862ae fix: default values 1 week ago
flop 5bce254f65 fix: redraw on reloads 1 week ago
flop de45362fe4 feat: clear all 1 week ago
flop c4bd427eff feat: session persistence 1 week ago
flop e8f5d5e451 fix: animation support 1 week ago
flop 71ade5290a feat: prevent easy reloading 1 week ago
flop 958ebdf4f0 feat: create nice image defaults 1 week ago
flop ef07183a66 feat: image draw editor support 1 week ago
flop 15b94d51f9 feat: image editor support 1 week ago
flop 7316a9d3a8 fix: sections change instead of deleteing 1 week ago
flop a20213b6e8 fix: timestamps for timespan sections 1 week ago
flop 4c90223474 refactor: move to mithril 1 week ago
flop 0db0e11815 fix: multiple adjustments to rendering and updating fields 1 week ago
  1. 3
      .gitignore
  2. BIN
      120px-GPN24.png
  3. BIN
      120px-GPN24_.png
  4. BIN
      120px-GPN24_wifi0.png
  5. BIN
      120px-GPN24_wifi1.png
  6. BIN
      120px-GPN24_wifi2.png
  7. BIN
      120px-GPN24_wifi3.png
  8. BIN
      300px-GPN24.png
  9. 62
      300px-GPN24_g.png
  10. 65
      300px-GPN24_g.svg
  11. BIN
      300px-GPN24_gray.png
  12. 71
      300px-GPN24_gray2.png
  13. 99
      300px-GPN24_gray3.png
  14. 102
      300px-GPN24_gray3.svg
  15. 135
      300px-GPN24_gray4.svg
  16. 1
      cpp/.envrc
  17. BIN
      cpp/Kulturanzeiger/Artikel1.bin
  18. BIN
      cpp/Kulturanzeiger/Artikel1.png
  19. BIN
      cpp/Kulturanzeiger/Artikel21.bin
  20. BIN
      cpp/Kulturanzeiger/Artikel21.png
  21. BIN
      cpp/Kulturanzeiger/Frosch161.bin
  22. BIN
      cpp/Kulturanzeiger/Frosch161.png
  23. BIN
      cpp/Kulturanzeiger/LaufSpruch1.bin
  24. BIN
      cpp/Kulturanzeiger/LaufSpruch1.png
  25. BIN
      cpp/Kulturanzeiger/LaufSpruch2.bin
  26. BIN
      cpp/Kulturanzeiger/LaufSpruch2.png
  27. BIN
      cpp/Kulturanzeiger/LaufSpruch3.bin
  28. BIN
      cpp/Kulturanzeiger/LaufSpruch3.png
  29. BIN
      cpp/Kulturanzeiger/MvAVJ.bin
  30. BIN
      cpp/Kulturanzeiger/MvAVJ.png
  31. BIN
      cpp/Kulturanzeiger/NotWidersetzen.bin
  32. BIN
      cpp/Kulturanzeiger/NotWidersetzen.png
  33. BIN
      cpp/Kulturanzeiger/Schildkroete.bin
  34. BIN
      cpp/Kulturanzeiger/Schildkroete.png
  35. BIN
      cpp/Kulturanzeiger/Widersetzen.bin
  36. BIN
      cpp/Kulturanzeiger/Widersetzen.png
  37. BIN
      cpp/Kulturanzeiger/Widersetzen1.bin
  38. BIN
      cpp/Kulturanzeiger/Widersetzen1.png
  39. BIN
      cpp/Kulturanzeiger/Zitat1.bin
  40. BIN
      cpp/Kulturanzeiger/Zitat1.png
  41. BIN
      cpp/Kulturanzeiger/Zitat2.bin
  42. BIN
      cpp/Kulturanzeiger/Zitat2.png
  43. BIN
      cpp/Kulturanzeiger/Zitat3.bin
  44. BIN
      cpp/Kulturanzeiger/Zitat3.png
  45. 23
      cpp/Makefile
  46. 28
      cpp/flake.nix
  47. BIN
      franzwerk.bin
  48. BIN
      gpn24.bin
  49. BIN
      template.bin
  50. BIN
      time.bin
  51. BIN
      time_nice.bin
  52. 37
      ts-editor/.gitignore
  53. 106
      ts-editor/CLAUDE.md
  54. 15
      ts-editor/README.md
  55. 37
      ts-editor/bun.lock
  56. 1042
      ts-editor/dist_old/index.html
  57. 8
      ts-editor/index.html
  58. 215
      ts-editor/live.php
  59. 20
      ts-editor/package.json
  60. 926
      ts-editor/src/browser.ts
  61. 62
      ts-editor/style.css
  62. 29
      ts-editor/tsconfig.json
  63. 156
      ts/build-dts.ts
  64. 12
      ts/bun.lock
  65. 1042
      ts/dist_old/index.html
  66. 18
      ts/package.json
  67. 631
      ts/src/browser.ts
  68. 23
      ts/src/file.ts
  69. 8
      ts/src/font/index.ts
  70. 33
      ts/src/font/u8g2.ts
  71. 18
      ts/src/index.ts
  72. 38
      ts/src/renderer.ts
  73. 1
      ts/tsconfig.json
  74. BIN
      u8g2_font_5x7_tf.u8g2font

3
.gitignore vendored

@ -1,3 +1,6 @@
build/
*.o
*~
cpp/flake.nix
cpp/flake.lock
cpp/.direnv/*

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

@ -0,0 +1 @@
use flake

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

@ -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

@ -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++
'';
};
});
};
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -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

@ -0,0 +1,106 @@
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
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 <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.

@ -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.

@ -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=="],
}
}

File diff suppressed because it is too large Load Diff

@ -32,6 +32,14 @@
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" />

@ -0,0 +1,215 @@
<?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(),
};

@ -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"
}
}

@ -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<Record<ElementTypeName, ElFieldMeta[]>> = {
Image2D: [
{ key: "xOffset", label: "X offset", type: "number", default: 0 },
{ key: "yOffset", label: "Y offset", type: "number", default: 0 },
{ key: "width", label: "Width", type: "number", default: W },
{ key: "height", label: "Height", type: "number", default: H },
],
Animation: [
{ key: "xOffset", label: "X offset", type: "number", default: 0 },
{ key: "yOffset", label: "Y offset", type: "number", default: 0 },
{ key: "width", label: "Width", type: "number", default: W },
{ key: "height", label: "Height", type: "number", default: H },
{ key: "updateInterval", label: "Update interval", type: "number", default: 12 },
{ key: "frames", label: "Frames", type: "list", default: [] },
],
ClippedText: [
{ key: "text", label: "Text", type: "text", default: "Hello, World!", full: true },
{ key: "xOffset", label: "X offset", type: "number", default: 0 },
{ key: "yOffset", label: "Y offset", type: "number", default: 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<Record<ElementTypeName, ElFlagMeta[]>> = {
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<boolean> {
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<boolean> {
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<string, any> = {};
(EL_FIELDS[ElementTypeToString[type]] as ElFieldMeta[] || [])
.forEach(f => (fields[f.key] = f.default));
const flags: Record<string, any> = {};
(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<string, () => 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<typeof setTimeout> | 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.<br/>Use <b>Add section</b> 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();

@ -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);

@ -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
}
}

@ -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}`);

@ -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=="],
}
}

File diff suppressed because it is too large Load Diff

@ -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"
}
}
}

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

@ -108,25 +108,26 @@ export class MonoDisplayFile implements MonoFormatFile {
(section.flags.clearBuffer ?? false ? 0x04 : 0);
const encodedEls = section.elements.map(el => this.#encodeElement(el));
const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0);
// sub-header: flags(2) + numElements(2) + startTimestamp(8) + endTimestamp(8) = 20 bytes
const sectionDataSize = 20 + encodedEls.reduce((s, e) => s + e.byteLength, 0);
// sectionSize (in header) = 4 (header) + sectionDataSize
const sectionSize = 4 + sectionDataSize;
const hdr = new Uint8Array(4 + 5*4); // section header (4) + section sub-header (4)
const hdr = new Uint8Array(4 + 20); // section header (4) + sub-header (20)
const v = new DataView(hdr.buffer);
v.setUint8(0, section.sectionType);
v.setUint8(1, sectionSize & 0xFF);
v.setUint8(2, (sectionSize >> 8) & 0xFF);
v.setUint8(3, (sectionSize >> 16) & 0xFF);
v.setUint16(4, flagBits, true); // flags
v.setUint16(6, section.elements.length, true); // numElements
// section.startTimestamp.valueOf()
const startTimestampLow = 0, startTimestampHigh = 0, endTimestampLow = 0, endTimestampHigh = 0;
v.setUint32(8, startTimestampLow, true); // flags
v.setUint32(12, startTimestampHigh, true); // numElements
v.setUint32(16, endTimestampLow, true); // numElements
v.setUint32(20, endTimestampHigh, true); // numElements
v.setUint16(4, flagBits, true); // flags
v.setUint16(6, section.elements.length, true); // numElements
const start = section.startTimestamp ?? 0n;
const end = section.endTimestamp ?? 0n;
v.setUint32( 8, Number(start & 0xFFFFFFFFn), true);
v.setUint32(12, Number((start >> 32n) & 0xFFFFFFFFn), true);
v.setUint32(16, Number(end & 0xFFFFFFFFn), true);
v.setUint32(20, Number((end >> 32n) & 0xFFFFFFFFn), true);
return this.#concat(hdr, ...encodedEls);
}

@ -1,7 +1,9 @@
import { u8g2_font_5x7_mf_u8g2font } from "./fonts/u8g2_font_5x7_mf_u8g2font";
import { u8g2_font_5x7_tf_u8g2font } from "./fonts/u8g2_font_5x7_tf.u8g2font";
import { u8g2_font_HelvetiPixel_tr_u8g2font } from "./fonts/u8g2_font_HelvetiPixel_tr_u8g2font";
import { u8g2_font_micropixel_tf_u8g2font } from "./fonts/u8g2_font_micropixel_tf_u8g2font";
import { u8g2_font_micropixel_tr_u8g2font } from "./fonts/u8g2_font_micropixel_tr_u8g2font";
import { u8g2_font_NokiaSmallPlain_tf_u8g2font } from "./fonts/u8g2_font_NokiaSmallPlain_tf_u8g2font";
import { u8g2_font_smolfont_tf_u8g2font } from "./fonts/u8g2_font_smolfont_tf_u8g2font";
import { u8g2_font_spleen12x24_me_u8g2font } from "./fonts/u8g2_font_spleen12x24_me_u8g2font";
import { MonoDisplayFont } from "./types";
@ -10,9 +12,13 @@ import { MonoDisplayFont } from "./types";
export * from "./types";
export * from "./u8g2";
export const u8g2_font_NokiaSmallPlain_tf = new MonoDisplayFont(u8g2_font_NokiaSmallPlain_tf_u8g2font);
export const u8g2_font_5x7_mf = new MonoDisplayFont(u8g2_font_5x7_mf_u8g2font);
export const u8g2_font_5x7_tf = new MonoDisplayFont(u8g2_font_5x7_tf_u8g2font);
export const u8g2_font_HelvetiPixel_tr = new MonoDisplayFont(u8g2_font_HelvetiPixel_tr_u8g2font);
export const u8g2_font_spleen12x24_me = new MonoDisplayFont(u8g2_font_spleen12x24_me_u8g2font);
export const u8g2_font_smolfont_tf = new MonoDisplayFont(u8g2_font_smolfont_tf_u8g2font);
export const u8g2_font_micropixel_tf = new MonoDisplayFont(u8g2_font_micropixel_tf_u8g2font);
export const u8g2_font_micropixel_tr = new MonoDisplayFont(u8g2_font_micropixel_tr_u8g2font);
export const u8g2_font_micropixel_tr = new MonoDisplayFont(u8g2_font_micropixel_tr_u8g2font);

@ -7,7 +7,7 @@
const HDR_SIZE = 23;
// ---------------------------------------------------------------------------
// BitReader LSB-first, matching u8g2_font_decode_get_unsigned_bits exactly:
// BitReader - LSB-first, matching u8g2_font_decode_get_unsigned_bits exactly:
// val = *ptr >> bit_pos
// if (bit_pos + cnt >= 8): val |= *(ptr+1) << (8 - bit_pos); ptr++
// val &= (1 << cnt) - 1
@ -67,7 +67,7 @@ function parseHeader(font: Uint8Array) {
}
// ---------------------------------------------------------------------------
// RLE decoder matches fd_decode / u8g2_font_decode_glyph in the C source.
// RLE decoder - matches fd_decode / u8g2_font_decode_glyph in the C source.
//
// The bitstream is a sequence of (zeros-run, ones-run) pairs, repeated.
// Each pair:
@ -106,7 +106,7 @@ function decodeRLE(
// Glyph result
// ---------------------------------------------------------------------------
export interface U8G2GlyphData {
cols: Uint8Array; // column bytes (bit 0 = top row), length = w
cols: Uint8Array | Uint32Array; // column words (bit 0 = top row), length = w
w: number;
h: number;
dx: number; // horizontal advance
@ -115,7 +115,7 @@ export interface U8G2GlyphData {
// ---------------------------------------------------------------------------
// Decode the glyph bitstream at byteOffset (after the record header bytes).
// Field order from C source (u8g2_font.c lines 590625):
// Field order from C source (u8g2_font.c lines 590-625):
// glyph_width, glyph_height, x (signed), y (signed), delta_x (signed)
// ---------------------------------------------------------------------------
function decodeGlyphAt(
@ -127,20 +127,21 @@ function decodeGlyphAt(
const w = br.read(hdr.bitsPerW);
const h = br.read(hdr.bitsPerH);
/* x */ br.readSigned(hdr.bitsPerX); // x bearing consumed but not needed
/* x */ br.readSigned(hdr.bitsPerX); // x bearing - consumed but not needed
const yB = br.readSigned(hdr.bitsPerY);
const dx = br.readSigned(hdr.bitsPerDx);
if (w === 0 || h === 0) {
// Blank glyph (e.g. space) advance is stored in dx
// Blank glyph (e.g. space) - advance is stored in dx
return { cols: new Uint8Array(1), w: 1, h: 0, dx: Math.max(dx, 1), yOff: 0 };
}
// Decode row-major horizontal bitmap
const bits = decodeRLE(br, w, h, hdr.m0, hdr.m1);
// Convert row-major → column bytes (bit 0 = top row) for rasterizeText
const cols = new Uint8Array(w);
// Convert row-major → column words (bit 0 = top row) for rasterizeText
// Must be Uint32Array - glyphs can be up to 32px tall; Uint8Array truncates rows 8+
const cols = new Uint32Array(w);
for (let row = 0; row < h; row++) {
for (let col = 0; col < w; col++) {
if (bits[row * w + col]) cols[col] |= (1 << row);
@ -161,12 +162,12 @@ function decodeGlyphAt(
// Record layout (cp < 0x0100):
// [1 byte] encoding
// [1 byte] jump = byte distance from START of this record to next record
// [bitstream ]
// [bitstream ...]
//
// Record layout (cp >= 0x0100):
// [2 bytes BE] encoding
// [1 byte] jump (same semantics)
// [bitstream ]
// [bitstream ...]
//
// Unicode section is preceded by a lookup table (added in v2.23):
// pairs of [uint16 BE delta | uint16 BE last-encoding], terminated by last==0xFFFF
@ -178,7 +179,7 @@ export function u8g2Glyph(cp: number, font: Uint8Array): U8G2GlyphData | null {
try {
if (cp >= 0x0100) {
// ── Unicode block ────────────────────────────────────────────────────
// -- Unicode block ----------------------------------------------------
const tableBase = hdr.startUnicode;
if (tableBase + 4 > font.length) return null;
@ -214,7 +215,7 @@ export function u8g2Glyph(cp: number, font: Uint8Array): U8G2GlyphData | null {
return null;
} else {
// ── ASCII block (cp < 0x0100) ────────────────────────────────────────
// -- ASCII block (cp < 0x0100) ----------------------------------------
// Jump-start from the nearest block offset to avoid scanning from byte 0.
let recPos: number;
if (cp >= 0x61) recPos = hdr.startLower;
@ -264,11 +265,11 @@ export function rasterizeText(
const pixels = new Uint8Array(totalW * cellHeight);
// Centre the full font (ascent + |descent|) vertically in the cell.
// header byte 14 is descent of 'g' stored as a negative signed value.
// Top-align the full font (ascent + |descent|) within the cell.
// header byte 14 is descent of 'g' - stored as a negative signed value.
const descent = font.FONT_DATA[14]! > 127 ? font.FONT_DATA[14]! - 256 : font.FONT_DATA[14]!;
const fontH = hdr.ascentA - descent; // total font height in pixels
const topPad = Math.floor((cellHeight - fontH) / 2);
const topPad = 0;
const baseline = topPad + hdr.ascentA;
let x = 0;
@ -290,4 +291,4 @@ export function rasterizeText(
}
return { pixels, width: totalW, height: cellHeight };
}
}

@ -51,8 +51,16 @@
export * from "./types";
export { BinaryReader, packPixels, unpackPixels, packedSize, pad32 } from "./helper";
export { MonoDisplayParser, MONOFORMAT_MAGIC_HEADER } from "./parser";
export { MonoDisplayRenderer } from "./renderer";
export { type MonoDisplayDriverOptions, MonoDisplayDriver } from "./driver";
export { MonoDisplayFile, loadBinFile, buildBinBuffer } from "./file";
export type * from "./types";
export * from "./helper";
export type * from "./helper";
export * from "./parser";
export type * from "./parser";
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";

@ -16,7 +16,17 @@ import {
type MonoFormatVScroll
} from "./types.js";
import { type MonoDisplayDriverOptions } from "./driver";
import { rasterizeText, MonoDisplayFont, u8g2_font_5x7_tf, u8g2_font_HelvetiPixel_tr, u8g2_font_spleen12x24_me, u8g2_font_smolfont_tf, u8g2_font_micropixel_tf, u8g2_font_micropixel_tr } from "./font";
import {
rasterizeText, MonoDisplayFont,
u8g2_font_5x7_tf,
u8g2_font_HelvetiPixel_tr,
u8g2_font_spleen12x24_me,
u8g2_font_smolfont_tf,
u8g2_font_micropixel_tf,
u8g2_font_micropixel_tr,
u8g2_font_NokiaSmallPlain_tf,
u8g2_font_5x7_mf
} from "./font";
/**
* Renders a MonoFormatFile onto an HTMLCanvasElement.
* Uses setInterval at 1000/fps ms per tick. All element state (animation
@ -38,21 +48,22 @@ export class MonoDisplayRenderer {
private vScrollPos: Map<number, number> = new Map();
private customFonts: MonoDisplayFont[] = [];
private textCache: Map<string, MonoFormatPixelImage> = new Map();
private builtinFonts: MonoDisplayFont[];
public static readonly builtinFonts: Record<string, MonoDisplayFont> = {
"NokiaSmallPlain_tf": u8g2_font_NokiaSmallPlain_tf,
"5x7_mf": u8g2_font_5x7_mf,
// "micropixel_tf": u8g2_font_micropixel_tf,
// "micropixel_tr": u8g2_font_micropixel_tr,
// "smolfont_tf": u8g2_font_smolfont_tf,
// "spleen12x24_me": u8g2_font_spleen12x24_me,
// "spleen12x24_me": u8g2_font_spleen12x24_me,
// "5x7_tf": u8g2_font_5x7_tf,
};
constructor(canvas: HTMLCanvasElement, opts: Required<MonoDisplayDriverOptions>) {
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Cannot get 2D canvas context");
this.ctx = ctx;
this.opts = opts;
this.builtinFonts = [
u8g2_font_micropixel_tf,
u8g2_font_micropixel_tr,
u8g2_font_smolfont_tf,
u8g2_font_spleen12x24_me,
u8g2_font_HelvetiPixel_tr,
u8g2_font_5x7_tf,
];
}
stop(): void {
@ -239,8 +250,11 @@ export class MonoDisplayRenderer {
const key = `${text}|${fontIndex}|${height}`;
const cached = this.textCache.get(key);
if (cached) return cached;
const customFont = fontIndex >= 0x8000 ? this.customFonts[fontIndex - 0x8000] : this.builtinFonts[fontIndex];
const img = rasterizeText(text, height, customFont);
fontIndex = Math.min(fontIndex, Object.keys(MonoDisplayRenderer.builtinFonts).length - 1);
const builtinFont: MonoDisplayFont = Object.values(MonoDisplayRenderer.builtinFonts).at(fontIndex) || u8g2_font_NokiaSmallPlain_tf;
const customFont: MonoDisplayFont | undefined = this.customFonts[fontIndex - 0x8000];
const selectedFont: MonoDisplayFont = fontIndex >= 0x8000 ? (customFont || builtinFont) : builtinFont;
const img = rasterizeText(text, height, selectedFont);
this.textCache.set(key, img);
return img;
}

@ -13,6 +13,7 @@
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"isolatedDeclarations": true,
// Best practices
"strict": true,

Binary file not shown.
Loading…
Cancel
Save