@ -1,3 +1,6 @@ |
|||||||
build/ |
build/ |
||||||
*.o |
*.o |
||||||
*~ |
*~ |
||||||
|
cpp/flake.nix |
||||||
|
cpp/flake.lock |
||||||
|
cpp/.direnv/* |
||||||
|
|||||||
|
After Width: | Height: | Size: 403 B |
|
After Width: | Height: | Size: 408 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
@ -0,0 +1 @@ |
|||||||
|
use flake |
||||||
@ -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++ |
||||||
|
''; |
||||||
|
}; |
||||||
|
}); |
||||||
|
}; |
||||||
|
} |
||||||
@ -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=="], |
||||||
|
} |
||||||
|
} |
||||||
@ -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(); |
||||||
@ -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}`);
|
||||||
@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') } |
|
||||||
|
|
||||||
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); } |
|
||||||