Compare commits

...

89 Commits

Author SHA1 Message Date
flop debce36336 feat(ts-editor): display change support 4 days ago
flop 395e5efa54 feat(ts-editor): resize display support 4 days ago
Christian Seiler faeb89fb42 PHP/schedule.php: additional improvements 4 days ago
Christian Seiler 2417aee259 PHP: fix bug in serialization code 4 days ago
flop 8efc44d1c2 feat: mobile support for drag/touch 4 days ago
flop 2b60c30f7f feat(ts-editor): add mobile support 4 days ago
flop e7e60d4de6 docs: rename Specification.rst from v1 folder 6 days ago
flop 59d683dbd2 Merge branch 'feat/typescript_changes' 1 week ago
flop 2dafdacd53 chore(ts): remove old index 1 week ago
flop 23662243be chore(ts-editor): clean up 1 week ago
flop 1d1a7577dc chore: remove font 1 week ago
flop 2f763d9355 chore: move to examples folders 1 week ago
flop d901725c17 chore: move GPN files to its own folder 1 week ago
flop 3c44b97400 chore: remove Kulturanzeiger folder (avj20260523 1 week ago
flop ed4456b7a5 fix: bugfixes so its clean 2 weeks ago
flop 6370191553 fix: imports for browser 2 weeks ago
flop 2a63573c1b fix: remove old files 2 weeks ago
flop c37b172991 feat: fix exports 2 weeks ago
Christian Seiler 4e03152676 C++, PHP: various bugfixes and improvements 2 weeks ago
flop 23d99d6a41 feat: go for dist instead of public folder 2 weeks ago
flop a1e9110c14 feat: changes for library exports 2 weeks ago
flop 0d759e8315 refactor(editor): split out web editor 2 weeks ago
flop 2817d4a845 gatest wifi 2 weeks ago
flop 82d5ade615 chore: gpn24 wifi update 2 weeks ago
flop 4fc827e8d8 chore: gpn24 animated wifi default image 2 weeks ago
flop 79edde4319 feat: drag and drop support 2 weeks ago
flop 176e787a09 chore: GPN24 default screen 2 weeks ago
Christian Seiler 3e87cb8dbe PHP: add support for automatic word wrap 2 weeks ago
Christian Seiler 7c371b04e3 C++, Spec: add support for automatic word wrap 2 weeks ago
Christian Seiler 7cbac666b8 C++: fix font positioning 2 weeks ago
Christian Seiler 02b4b8b4cc C++, font renderer: add support for explicit newlines 2 weeks ago
Christian Seiler 4b4febb3ea C++: fix parser bug in structured logic 2 weeks ago
flop d8371d77cf chore: template updates for gpn24 2 weeks ago
flop 847b2c832b chore: updates for 0.text 2 weeks ago
flop 66c453fb58 feat: update line support 2 weeks ago
flop f5ba0ff0f6 chore: updated template for gpn 2 weeks ago
flop f7c02d7426 chore: updated template for gpn24 2 weeks ago
flop 47b210b2ec chore: more example bin files 2 weeks ago
flop a902c172dd feat: updated .bin file 2 weeks ago
Christian Seiler 2a96abf2a1 Specification: better document the differences between v1 and v2 2 weeks ago
Christian Seiler 603a469896 PHP: add full serialization / unserialization (binary & JSON) implementation 2 weeks ago
Christian Seiler 2c69d3a685 C++: fix bug in serialization code (doesn't request correct version for CurrentTimeElement) 2 weeks ago
Christian Seiler c83b4425be C++/Specification: introduce new format version 2 with some additions 2 weeks ago
Christian Seiler 8557f7a401 C++: fix various bugs related to different section types 2 weeks ago
Christian Seiler 5a633ed791 C++/font rendering: properly handle spaces at the end of strings 2 weeks ago
Christian Seiler 5e66961854 C++: add JSON serialization and parsing support 2 weeks ago
Christian Seiler ecf835175c C++: serialization: add version-dependent serialization support 2 weeks ago
Christian Seiler efc4ed8de4 C++: parser code: propagate format version into individual parsing methods 2 weeks ago
Christian Seiler c77efa8461 C++: refactor flags into their own structs (for better code readability) 2 weeks ago
Christian Seiler 6602bec421 C++: refactor bit manipulation into discrete functions 2 weeks ago
flop 5d200a1dde updates 3 weeks ago
flop fc5abc0c23 fix(kulturanzeiger_avj): updated formats 3 weeks ago
flop 147945c751 feat(browser): add two more flags to text scroll 3 weeks ago
flop b45f51126e fix: top align fonts 3 weeks ago
flop 2f708a0ddc original files 3 weeks ago
flop 0abba3d954 feat(php): updates to live 3 weeks ago
flop 7d7ebf88c6 feat(php): update live 3 weeks ago
flop 3e34bd8404 feat(cpp): add nix build stuff 3 weeks ago
flop 82458df080 feat(php): live script 3 weeks ago
flop 3d73eca6f7 feat: fix dark/light style 3 weeks ago
flop c4775d7c23 feat: vibe color 3 weeks ago
flop 8a743736fe fix: selection bug 3 weeks ago
flop 495f76aaad fix: font rendering 3 weeks ago
flop 02c03d3f1f fix: default values 3 weeks ago
flop eefda87709 fix: redraw on reloads 3 weeks ago
flop 941ffd0f2c feat: clear all 3 weeks ago
flop db865a7666 feat: session persistence 3 weeks ago
flop dd4d7d25c7 fix: animation support 3 weeks ago
flop f300393122 feat: prevent easy reloading 3 weeks ago
flop 8dc2a5c3d8 feat: create nice image defaults 3 weeks ago
flop d50e17b1cc feat: image draw editor support 3 weeks ago
flop a61927ef1d feat: image editor support 3 weeks ago
flop 82ac1d56e0 fix: sections change instead of deleteing 3 weeks ago
flop 000f479b2f fix: timestamps for timespan sections 3 weeks ago
flop 555541e84f refactor: move to mithril 3 weeks ago
flop d74eed81b3 fix: multiple adjustments to rendering and updating fields 3 weeks ago
flop 96d9123f19 fix: default background is rendered all the time 3 weeks ago
flop 85ca7dd318 fix: rework most of the UI 3 weeks ago
flop 5c6a665b45 fix: move all to browser.ts again 3 weeks ago
flop 4edc71a611 fix: export all methods 3 weeks ago
flop a7d0700022 fix!: move to web.ts 3 weeks ago
flop 656342a30f fix: move stylesheet 3 weeks ago
flop 3b3a4e0cfe fix(demo): smaller display width 3 weeks ago
flop 256fde9214 fix: load sections too 3 weeks ago
flop d1c042922b feat(theme): switch between light and dark 3 weeks ago
Christian Seiler 45617022f7 PHP: send application/octet-stream MIME type 4 weeks ago
Christian Seiler 857e9bb6a9 C++/Embedded: don't use std::format 4 weeks ago
Christian Seiler 1130186c9e C++/CMake: don't build structured code in embedded systems 4 weeks ago
flop 6167d127ba Merge pull request 'feat/typescript' (#1) from feat/typescript into main 4 weeks ago
  1. 3
      .gitignore
  2. BIN
      GPN24/120px-GPN24.png
  3. BIN
      GPN24/120px-GPN24_.png
  4. BIN
      GPN24/120px-GPN24_wifi0.png
  5. BIN
      GPN24/120px-GPN24_wifi1.png
  6. BIN
      GPN24/120px-GPN24_wifi2.png
  7. BIN
      GPN24/120px-GPN24_wifi3.png
  8. BIN
      GPN24/300px-GPN24.png
  9. 62
      GPN24/300px-GPN24_g.png
  10. 65
      GPN24/300px-GPN24_g.svg
  11. BIN
      GPN24/300px-GPN24_gray.png
  12. 71
      GPN24/300px-GPN24_gray2.png
  13. 99
      GPN24/300px-GPN24_gray3.png
  14. 102
      GPN24/300px-GPN24_gray3.svg
  15. 135
      GPN24/300px-GPN24_gray4.svg
  16. 190
      Specification.rst
  17. 1
      cpp/.envrc
  18. 23
      cpp/Makefile
  19. 25
      cpp/clivis/main.cpp
  20. 28
      cpp/flake.nix
  21. 19
      cpp/src/CMakeLists.txt
  22. 96
      cpp/src/monoformat_bithelpers.hpp
  23. 28
      cpp/src/monoformat_fontreader.hpp
  24. 633
      cpp/src/monoformat_parseonly.cpp
  25. 2
      cpp/src/monoformat_parseonly.hpp
  26. 11
      cpp/src/monoformat_schema.cpp
  27. 116
      cpp/src/monoformat_schema.hpp
  28. 1617
      cpp/src/monoformat_structured.cpp
  29. 214
      cpp/src/monoformat_structured.hpp
  30. BIN
      examples/v1/franzwerk.bin
  31. BIN
      examples/v1/gpn24.bin
  32. BIN
      examples/v1/template.bin
  33. BIN
      examples/v1/time.bin
  34. BIN
      examples/v1/time_nice.bin
  35. 201
      php/monoformat.php
  36. 43
      php/monoformat_bithelpers.php
  37. 213
      php/monoformat_schema.php
  38. 1925
      php/monoformat_structured.php
  39. 203
      php/schedule.php
  40. 37
      ts-editor/.gitignore
  41. 106
      ts-editor/CLAUDE.md
  42. 15
      ts-editor/README.md
  43. 37
      ts-editor/bun.lock
  44. 117
      ts-editor/index.html
  45. 215
      ts-editor/live.php
  46. 68
      ts-editor/mobile.css
  47. 20
      ts-editor/package.json
  48. 952
      ts-editor/src/browser.ts
  49. 713
      ts-editor/style.css
  50. 29
      ts-editor/tsconfig.json
  51. 156
      ts/build-dts.ts
  52. 12
      ts/bun.lock
  53. 1042
      ts/index.html
  54. 18
      ts/package.json
  55. 9
      ts/src/browser.ts
  56. 7
      ts/src/driver.ts
  57. 167
      ts/src/file.ts
  58. 8
      ts/src/font/index.ts
  59. 33
      ts/src/font/u8g2.ts
  60. 18
      ts/src/index.ts
  61. 41
      ts/src/renderer.ts
  62. 26
      ts/src/themes.ts
  63. 219
      ts/src/types.ts
  64. 1
      ts/tsconfig.json

3
.gitignore vendored

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

@ -77,9 +77,11 @@ The following possible values for the version field exist:
+===========+============================================+
| ``0`` | Illegal |
+-----------+--------------------------------------------+
| ``1`` | The current version of this specification. |
| ``1`` | A previous version of this specification. |
+-----------+--------------------------------------------+
| ``2`` .. | Reserved for future use |
| ``2`` | The current version of this specification. |
+-----------+--------------------------------------------+
| ``3`` .. | Reserved for future use |
+-----------+--------------------------------------------+
A value of ``0`` is illegal for the version field and indivates a problem with the file. The current specification version is ``1``. Writers that conform to this specification **MUST** use this value.
@ -118,8 +120,12 @@ The section type specifies what kind of section this is. The following section t
+-----------+---------------------------------------------------+
| ``2`` | List of drawn elements (timespan) |
+-----------+---------------------------------------------------+
| ``3`` .. | Reserved for future use |
| ``31`` | |
| ``3`` | List of drawn elements (list of timespans) |
+-----------+---------------------------------------------------+
| ``4`` .. | Reserved for future use |
| ``30`` | |
+-----------+---------------------------------------------------+
| ``31`` | Expiry Date |
+-----------+---------------------------------------------------+
| ``32`` | Custom Font |
+-----------+---------------------------------------------------+
@ -190,6 +196,56 @@ This section is similar to the "List of drawn elements (always)" section, but th
The additional fields are two 64bit POSIX timestamps containing the start and end time during which this section is to be drawn. If the current timestamp is larger than or equal to the start timestamp, and the current timestamp is smaller than the end timestamp, the section will be shown, otherwise it will be skipped.
List of drawn elements (list of timespans)
------------------------------------------
This section is similar to the "List of drawn elements (timespan)" section, but the header contains a list of timestamp ranges, not just a single range:
0 1 2 3
+------------------------+------------------------+------------------------+------------------------+
0 | Flags | Number of Elements |
+------------------------+------------------------+------------------------+------------------------+
4 | Number of Timestamps | Reserved |
+------------------------+------------------------+------------------------+------------------------+
8 | First Start Timestamp (Low) |
+------------------------+------------------------+------------------------+------------------------+
12 | First Start Timestamp (High) |
+------------------------+------------------------+------------------------+------------------------+
16 | First End Timestamp (Low) |
+------------------------+------------------------+------------------------+------------------------+
20 | First End Timestamp (High) |
+------------------------+------------------------+------------------------+------------------------+
24 | Second Start Timestamp (Low) |
+------------------------+------------------------+------------------------+------------------------+
28 | Second Start Timestamp (High) |
+------------------------+------------------------+------------------------+------------------------+
32 | Second End Timestamp (Low) |
+------------------------+------------------------+------------------------+------------------------+
36 | Second End Timestamp (High) |
+------------------------+------------------------+------------------------+------------------------+
40.. | Additional timestamps .... |
+------------------------+------------------------+------------------------+------------------------+
After the last timestamp the list of elements follows (as with the "List of drawn elements (always)" and "List of drawn elements (timespan)" elements).
This was added in version 2 of the format.
File Global Expiry Date
-----------------------
If this section is present it contains the timestamp that describes the date after which the file is no longer valid. This allows the user to generate
files that will be discarded if the current date is already past the expiry timestamp, and will automatically stop the file from being displayed
once the expiry timestamp is reached.
0 1 2 3
+------------------------+------------------------+------------------------+------------------------+
0 | Expiry Date Timestamp (Low) |
+------------------------+------------------------+------------------------+------------------------+
4 | Expiry Date Timestamp (High) |
+------------------------+------------------------+------------------------+------------------------+
This was added in version 2 of the format.
Custom Font
-----------
@ -331,13 +387,61 @@ Line
8 | Y Target | Line Style | Flags |
+------------------------+------------------------+-------------------------------------------------+
Line Style
``````````
+-----------+---------------------------------------------------+
| Bit | Description |
+===========+===================================================+
| ``0`` | Solid line |
+-----------+---------------------------------------------------+
| ``1`` .. | Reserved for future use |
| ``255`` | |
+-----------+---------------------------------------------------+
Flags
`````
+-----------+---------------------------------------------------+
| Bit | Description |
+===========+===================================================+
| ``0`` | Line color is dark, not white (added in v2) |
+-----------+---------------------------------------------------+
| ``1`` .. | Reserved for future use |
| ``7`` | |
+-----------+---------------------------------------------------+
Box
---
0 1 2 3
+------------------------+------------------------+------------------------+------------------------+
0 | Type: 6 | X |
+------------------------+------------------------+------------------------+------------------------+
4 | Y | Width |
+------------------------+------------------------+------------------------+------------------------+
8 | Height | Fill Pattern | Flags |
+------------------------+------------------------+-------------------------------------------------+
Fill Pattern
````````````
+-----------+---------------------------------------------------+
| Bit | Description |
+===========+===================================================+
| ``0`` | Solid |
+-----------+---------------------------------------------------+
| ``1`` .. | Reserved for future use |
| ``255`` | |
+-----------+---------------------------------------------------+
Flags
`````
+-----------+---------------------------------------------------+
| Bit | Description |
+===========+===================================================+
| ``0`` | Invert Pixel Values |
| ``0`` | Fill color is dark, not white |
+-----------+---------------------------------------------------+
| ``1`` .. | Reserved for future use |
| ``7`` | |
@ -352,6 +456,24 @@ Clipped Text
+------------------------+------------------------+------------------------+------------------------+
4 | Y Offset | Width |
+------------------------+------------------------+------------------------+------------------------+
8 | Height | Text Flags | Reserved |
+------------------------+------------------------+------------------------+------------------------+
12 | Font Index | Text Length |
+------------------------+------------------------+------------------------+------------------------+
16.. | Text ... | Padding (if required) |
+------------------------+------------------------+------------------------+------------------------+
Version 1 Format
````````````````
This was changed in version 2, and the old format is like this:
0 1 2 3
+------------------------+------------------------+------------------------+------------------------+
0 | Type: 16 | X Offset |
+------------------------+------------------------+------------------------+------------------------+
4 | Y Offset | Width |
+------------------------+------------------------+------------------------+------------------------+
8 | Height | Font Index |
+------------------------+------------------------+------------------------+------------------------+
12 | Text Length | Text ... |
@ -359,6 +481,22 @@ Clipped Text
... | ... Text | Padding (if required) |
+------------------------+------------------------+------------------------+------------------------+
(There are no text flags present.)
Text Flags
``````````
+-----------+---------------------------------------------------+
| Bit | Description |
+===========+===================================================+
| ``0`` | Text is dark, background is white |
+-----------+---------------------------------------------------+
| ``1`` | Enable automatic word wrap |
+-----------+---------------------------------------------------+
| ``1`` .. | Reserved for future use |
| ``7`` | |
+-----------+---------------------------------------------------+
Font Index
``````````
@ -377,6 +515,26 @@ Horizontally Scrolling Text
+------------------------+------------------------+------------------------+------------------------+
4 | Y Offset | Width |
+------------------------+------------------------+------------------------+------------------------+
8 | Height | Text Flags | Reserved |
+------------------------+------------------------+------------------------+------------------------+
12 | Flags | Scroll Speed | Reserved |
+------------------------+------------------------+------------------------+------------------------+
16 | Font Index | Text Length |
+------------------------+------------------------+------------------------+------------------------+
20.. | ... Text | Padding (if required) |
+------------------------+------------------------+------------------------+------------------------+
Version 1 Format
````````````````
This was changed in version 2, and the old format is like this:
0 1 2 3
+------------------------+------------------------+------------------------+------------------------+
0 | Type: 17 | X Offset |
+------------------------+------------------------+------------------------+------------------------+
4 | Y Offset | Width |
+------------------------+------------------------+------------------------+------------------------+
8 | Height | Flags | Scroll Speed |
+------------------------+------------------------+------------------------+------------------------+
12 | Font Index | Text Length |
@ -384,6 +542,8 @@ Horizontally Scrolling Text
... | ... Text | Padding (if required) |
+------------------------+------------------------+------------------------+------------------------+
(There are no text flags present.)
Current Time
------------
@ -393,11 +553,31 @@ Current Time
+------------------------+------------------------+------------------------+------------------------+
4 | Y Offset | Width |
+------------------------+------------------------+------------------------+------------------------+
8 | Height | Text Flags | Reserved |
+------------------------+------------------------+------------------------+------------------------+
12 | Font Index | UTC Offset (Minutes) |
+------------------------+------------------------+------------------------+------------------------+
16 | Flags | Reserved |
+------------------------+------------------------+------------------------+------------------------+
Version 1 Format
````````````````
This was changed in version 2, and the old format is like this:
0 1 2 3
+------------------------+------------------------+------------------------+------------------------+
0 | Type: 32 | X Offset |
+------------------------+------------------------+------------------------+------------------------+
4 | Y Offset | Width |
+------------------------+------------------------+------------------------+------------------------+
8 | Height | Font Index |
+------------------------+------------------------+------------------------+------------------------+
12 | UTC Offset (Minutes) | Flags |
+------------------------+------------------------+------------------------+------------------------+
(There are no text flags present.)
UTC Offset
``````````

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

@ -126,19 +126,31 @@ static void showContents2(std::span<std::byte const> data, std::size_t tick, std
}
int main(int argc, char** argv) {
if (argc != 2 || !argv[1]) {
if (argc < 2 || !argv[1]) {
std::ostringstream buf;
buf << "Usage: " << argv[0] << " filename\n";
buf << "Usage: " << argv[0] << " filename [timestamp]\n";
std::cerr << buf.str();
return 1;
}
if (std::string_view{argv[1]} == "--help") {
std::ostringstream buf;
buf << "Usage: " << argv[0] << " filename\n";
buf << "Usage: " << argv[0] << " filename [timestamp]\n";
std::cout << buf.str() << std::flush;
return 0;
}
std::int64_t timestamp = -1;
if (argc > 2) {
char* endptr = nullptr;
timestamp = strtoll(argv[2], &endptr, 10);
if (!endptr || *endptr) {
std::ostringstream buf;
buf << "Usage: " << argv[0] << " filename [timestamp]\n";
std::cerr << buf.str();
return 1;
}
}
try {
auto contents = readEntireFile<std::vector<std::byte>>(argv[1]);
auto file = monoformat::parseFile(contents);
@ -159,11 +171,12 @@ int main(int argc, char** argv) {
std::signal(SIGTERM, &handleCtrlC);
std::size_t tick = 0;
while (!g_ctrlCPressed.load(std::memory_order_relaxed)) {
std::int64_t currentTime = timestamp == -1 ? time(nullptr) : timestamp;
clearScreen();
//showContents(*file, tick, time(nullptr), customFonts);
showContents2(contents, tick, time(nullptr));
//showContents(*file, tick, currentTime, customFonts);
showContents2(contents, tick, currentTime);
std::ostringstream buf;
buf << "Animation Tick: " << tick << "\n";
buf << "Animation Tick: " << tick << ", Timestamp = " << currentTime << "\n";
std::cout << buf.str() << std::flush;
std::this_thread::sleep_for(std::chrono::milliseconds{40});
++tick;

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

@ -1,12 +1,10 @@
set(monoformat_SOURCES
monoformat_schema.cpp
monoformat_structured.cpp
monoformat_parseonly.cpp
monoformat_fontreader.cpp
)
set(monoformat_HEADERS
monoformat_schema.hpp
monoformat_structured.hpp
monoformat_parsehelpers.hpp
monoformat_parseonly.hpp
monoformat_fontreader.hpp
@ -14,10 +12,27 @@ set(monoformat_HEADERS
monoformat_utf8.hpp
)
option(MONOFORMAT_EMBEDDED "Only build for embedded devices" FALSE)
if(NOT MONOFORMAT_EMBEDDED)
find_package(nlohmann_json REQUIRED)
list(APPEND monoformat_SOURCES
monoformat_structured.cpp
)
list(APPEND monoformat_HEADERS
monoformat_structured.hpp
)
endif()
add_library(monoformat ${monoformat_SOURCES} ${monoformat_HEADERS})
target_compile_features(monoformat PUBLIC cxx_std_23)
target_include_directories(monoformat PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
if(NOT MONOFORMAT_EMBEDDED)
target_link_libraries(monoformat PUBLIC nlohmann_json::nlohmann_json)
endif()
install(TARGETS monoformat
RUNTIME DESTINATION bin

@ -0,0 +1,96 @@
#ifndef MONOFORMAT_BITHELPERS_HPP
#define MONOFORMAT_BITHELPERS_HPP
#include <cstddef>
#include <cstdint>
#include <climits>
#include <concepts>
namespace monoformat {
template<std::integral Int, std::integral OtherInt>
constexpr inline bool isBitSet(Int value, OtherInt bit) {
if (bit < 0 || bit >= static_cast<Int>(sizeof(Int) * CHAR_BIT)) [[unlikely]] {
return false;
}
Int mask = Int{1} << static_cast<Int>(bit);
return (value & mask) == mask;
}
template<std::integral OtherInt>
constexpr inline bool isBitSet(std::byte value, OtherInt bit) {
return isBitSet(static_cast<std::uint8_t>(value), bit);
}
template<std::integral Int, std::integral OtherInt>
constexpr inline void setBit(Int& value, OtherInt bit) {
if (bit < 0 || bit >= static_cast<Int>(sizeof(Int) * CHAR_BIT)) [[unlikely]] {
return;
}
Int mask = Int{1} << static_cast<Int>(bit);
value |= mask;
}
template<std::integral OtherInt>
constexpr inline void setBit(std::byte& value, OtherInt bit) {
if (bit < 0 || bit >= CHAR_BIT) [[unlikely]] {
return;
}
std::uint8_t mask = std::uint8_t{1} << static_cast<std::uint8_t>(bit);
value = static_cast<std::byte>(static_cast<std::uint8_t>(value) | mask);
}
template<std::integral Int, std::integral OtherInt>
constexpr inline void unsetBit(Int& value, OtherInt bit) {
if (bit < 0 || bit >= static_cast<Int>(sizeof(Int) * CHAR_BIT)) [[unlikely]] {
return;
}
Int mask = Int{1} << static_cast<Int>(bit);
value &= ~mask;
}
template<std::integral OtherInt>
constexpr inline void unsetBit(std::byte& value, OtherInt bit) {
if (bit < 0 || bit >= CHAR_BIT) [[unlikely]] {
return;
}
std::uint8_t mask = std::uint8_t{1} << static_cast<std::uint8_t>(bit);
value = static_cast<std::byte>(static_cast<std::uint8_t>(value) & ~mask);
}
template<std::integral Int, std::integral OtherInt>
constexpr inline void maybeSetBit(Int& value, OtherInt bit, bool set) {
if (bit < 0 || bit >= static_cast<Int>(sizeof(Int) * CHAR_BIT)) [[unlikely]] {
return;
}
Int mask = Int{1} << static_cast<Int>(bit);
if (set) {
value |= mask;
} else {
value &= ~mask;
}
}
template<std::integral OtherInt>
constexpr inline void maybeSetBit(std::byte& value, OtherInt bit, bool set) {
if (bit < 0 || bit >= CHAR_BIT) [[unlikely]] {
return;
}
std::uint8_t mask = std::uint8_t{1} << static_cast<std::uint8_t>(bit);
if (set) {
value = static_cast<std::byte>(static_cast<std::uint8_t>(value) | mask);
} else {
value = static_cast<std::byte>(static_cast<std::uint8_t>(value) & ~mask);
}
}
} // namespace monoformat
#endif // MONOFORMAT_BITHELPERS_HPP

@ -527,9 +527,10 @@ public:
continue;
}
// FIXME: newline support
if (cp == '\n') {
cp = ' ';
advance.x = 0;
advance.y += m_font.lineHeight();
continue;
}
auto dims = renderGlyph(cp, position + advance, m_font, display, fontColor, bgColor);
@ -557,9 +558,11 @@ public:
continue;
}
// FIXME: newline support
if (cp == '\n') {
cp = ' ';
ensureAdvance(boundingBox, advance);
advance.x = 0;
advance.y += m_font.lineHeight();
continue;
}
auto dims = computeGlyphDimensions(cp, position + advance, m_font);
@ -570,6 +573,8 @@ public:
boundingBox = combineBoundingBoxes(boundingBox, dims->boundingBox);
}
boundingBox = ensureAdvance(boundingBox, advance);
return RenderedDimensions{advance, boundingBox};
}
@ -619,6 +624,21 @@ private:
}
}
static std::optional<Rectangle> ensureAdvance(std::optional<Rectangle> a, Point advance) {
if (!a) {
return std::nullopt;
}
auto bottomRight = a->getBottomRight();
if ((bottomRight.x + 1) < advance.x) {
bottomRight.x = advance.x - 1;
}
if ((bottomRight.y + 1) < advance.y) {
bottomRight.y = advance.y - 1;
}
return Rectangle::fromCorners(a->topLeft, bottomRight);
}
template<typename Display>
static std::expected<RenderedDimensions, LookupError> renderGlyph(std::uint32_t cp, Point position, FontReader const& font, Display& display, typename Display::Color fontColor, std::optional<typename Display::Color> bgColor) {
auto glyph_ = font.tryRetrieveGlyphData(cp);

@ -1,10 +1,11 @@
#include "monoformat_parseonly.hpp"
#include "monoformat_parsehelpers.hpp"
#include "monoformat_fontreader.hpp"
#include "monoformat_bithelpers.hpp"
#include <string_view>
#include <string>
#include <format>
#include <cstdio>
#include <iostream>
namespace monoformat {
@ -22,16 +23,25 @@ struct TimeBasedDrawnSectionMetaData {
std::int64_t endTimestamp{};
};
static std::expected<void, ParseError> handleImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts);
static std::expected<void, ParseError> handleAnimation(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts);
static std::expected<void, ParseError> handleHScrollImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts);
static std::expected<void, ParseError> handleVScrollImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts);
static std::expected<void, ParseError> handleLine(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts);
static std::expected<void, ParseError> handleClippedText(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts);
static std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts);
static std::expected<void, ParseError> handleCurrentTime(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts);
struct MultiTimeBasedDrawnSectionMetaData {
AlwaysDrawnSectionMetaData base;
std::size_t rangeCount{};
std::span<std::byte const> rangeData;
};
static std::expected<void, ParseError> handleImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion);
static std::expected<void, ParseError> handleAnimation(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion);
static std::expected<void, ParseError> handleHScrollImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion);
static std::expected<void, ParseError> handleVScrollImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion);
static std::expected<void, ParseError> handleLine(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion);
static std::expected<void, ParseError> handleBox(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion);
static std::expected<void, ParseError> handleClippedText(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion);
static std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion);
static std::expected<void, ParseError> handleCurrentTime(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion);
static std::expected<AlwaysDrawnSectionMetaData, ParseError> parseAlwaysDrawnSection(std::span<std::byte const>& data, std::uint32_t formatVersion) {
std::ignore = formatVersion;
static std::expected<AlwaysDrawnSectionMetaData, ParseError> parseAlwaysDrawnSection(std::span<std::byte const>& data) {
auto type = readU8LE(data);
if (!type) {
return std::unexpected(type.error());
@ -58,26 +68,33 @@ static std::expected<AlwaysDrawnSectionMetaData, ParseError> parseAlwaysDrawnSec
data = data.subspan(*size - 8);
AlwaysDrawnSectionMetaData result;
result.drawOnFront = (((*flags) >> 0u) & 0x01u) == 0x01u;
result.drawOnBack = (((*flags) >> 0u) & 0x02u) == 0x02u;
result.clearBeforeDrawing = (((*flags) >> 0u) & 0x04u) == 0x04u;
result.drawOnFront = isBitSet(*flags, 0);
result.drawOnBack = isBitSet(*flags, 1);
result.clearBeforeDrawing = isBitSet(*flags, 2);
result.elementData = sectionData;
#if defined(DEBUG)
std::cerr << "[Debug] AlwaysDrawnSection, drawOnFront = " << result.drawOnFront << ", drawOnBack = " << result.drawOnBack << ", clearBeforeDrawing = " << result.clearBeforeDrawing << ", element data size = " << result.elementData.size() << " bytes" << std::endl;
#endif
return result;
}
static std::expected<TimeBasedDrawnSectionMetaData, ParseError> parseTimeBasedDrawnSection(std::span<std::byte const>& data) {
static std::expected<TimeBasedDrawnSectionMetaData, ParseError> parseTimeBasedDrawnSection(std::span<std::byte const>& data, std::uint32_t formatVersion) {
std::ignore = formatVersion;
auto type = readU8LE(data);
if (!type) {
return std::unexpected(type.error());
}
if (*type != static_cast<std::uint8_t>(SectionType::AlwaysDrawn)) {
if (*type != static_cast<std::uint8_t>(SectionType::TimeBasedDrawn)) {
return std::unexpected(ParseError::InvalidValue);
}
auto size = readU24LE(data);
if (!size) {
return std::unexpected(size.error());
}
if (*size < 8) {
if (*size < 24) {
return std::unexpected(ParseError::InvalidValue);
}
auto flags = readU16LE(data);
@ -100,16 +117,79 @@ static std::expected<TimeBasedDrawnSectionMetaData, ParseError> parseTimeBasedDr
data = data.subspan(*size - 24);
TimeBasedDrawnSectionMetaData result;
result.base.drawOnFront = (((*flags) >> 0u) & 0x01u) == 0x01u;
result.base.drawOnBack = (((*flags) >> 0u) & 0x02u) == 0x02u;
result.base.clearBeforeDrawing = (((*flags) >> 0u) & 0x04u) == 0x04u;
result.base.drawOnFront = isBitSet(*flags, 0);
result.base.drawOnBack = isBitSet(*flags, 1);
result.base.clearBeforeDrawing = isBitSet(*flags, 2);
result.base.elementData = sectionData;
result.startTimestamp = std::bit_cast<std::int64_t>(*startTimestamp);
result.endTimestamp = std::bit_cast<std::int64_t>(*endTimestamp);
#if defined(DEBUG)
std::cerr << "[Debug] TimeBasedDrawnSection, drawOnFront = " << result.base.drawOnFront << ", drawOnBack = " << result.base.drawOnBack << ", clearBeforeDrawing = " << result.base.clearBeforeDrawing << ", element data size = " << result.base.elementData.size() << " bytes, timestamp between " << result.startTimestamp << " and " << result.endTimestamp << std::endl;
#endif
return result;
}
static std::expected<MultiTimeBasedDrawnSectionMetaData, ParseError> parseMultiTimeBasedDrawnSection(std::span<std::byte const>& data, std::uint32_t formatVersion) {
std::ignore = formatVersion;
auto type = readU8LE(data);
if (!type) {
return std::unexpected(type.error());
}
if (*type != static_cast<std::uint8_t>(SectionType::MultiTimeBasedDrawn)) {
return std::unexpected(ParseError::InvalidValue);
}
auto size = readU24LE(data);
if (!size) {
return std::unexpected(size.error());
}
if (*size < 12) {
return std::unexpected(ParseError::InvalidValue);
}
auto flags = readU16LE(data);
if (!flags) {
return std::unexpected(flags.error());
}
auto elementCount = readU16LE(data);
if (!elementCount) {
return std::unexpected(elementCount.error());
}
auto rangeCount = readU16LE(data);
if (!rangeCount) {
return std::unexpected(rangeCount.error());
}
if (((*rangeCount) * 16u + 12u) > *size) {
return std::unexpected(ParseError::InvalidValue);
}
auto reserved1 = readU16LE(data);
if (!reserved1) {
return std::unexpected(reserved1.error());
}
auto sectionData = data.subspan(0, *size - 12 - 16 * (*rangeCount));
auto rangeData = data.subspan(12, 16 * (*rangeCount));
data = data.subspan(*size - 12 - 16 * (*rangeCount));
MultiTimeBasedDrawnSectionMetaData result;
result.base.drawOnFront = isBitSet(*flags, 0);
result.base.drawOnBack = isBitSet(*flags, 1);
result.base.clearBeforeDrawing = isBitSet(*flags, 2);
result.base.elementData = sectionData;
result.rangeCount = *rangeCount;
result.rangeData = rangeData;
#if defined(DEBUG)
std::cerr << "[Debug] MultiTimeBasedDrawnSection, drawOnFront = " << result.base.drawOnFront << ", drawOnBack = " << result.base.drawOnBack << ", clearBeforeDrawing = " << result.base.clearBeforeDrawing << ", element data size = " << result.base.elementData.size() << " bytes, # timestamps = " << result.rangeCount << std::endl;
#endif
return result;
}
static std::expected<std::span<std::byte const>, ParseError> parseCustomFontSection(std::span<std::byte const>& data) {
static std::expected<std::span<std::byte const>, ParseError> parseCustomFontSection(std::span<std::byte const>& data, std::uint32_t formatVersion) {
std::ignore = formatVersion;
auto type = readU8LE(data);
if (!type) {
return std::unexpected(type.error());
@ -135,6 +215,10 @@ static std::expected<std::span<std::byte const>, ParseError> parseCustomFontSect
auto fontData = data.subspan(0, *size - 8);
data = data.subspan(*size - 8);
#if defined(DEBUG)
std::cerr << "[Debug] CustomFontSection, font data size = " << *actualSize << " bytes" << std::endl;
#endif
return readBuffer(fontData, *actualSize);
}
@ -155,7 +239,7 @@ std::expected<void, ParseError> parseAndApply(std::span<std::byte const> data, O
if (!version) {
return std::unexpected(version.error());
}
if (*version != 1) {
if (*version < 1 || *version > 2) {
return std::unexpected(ParseError::InvalidValue);
}
auto sectionCount = readU16LE(data);
@ -182,7 +266,7 @@ std::expected<void, ParseError> parseAndApply(std::span<std::byte const> data, O
}
if (static_cast<SectionType>(*sectionType) == SectionType::CustomFont) {
auto fontData = parseCustomFontSection(*rawSectionData);
auto fontData = parseCustomFontSection(*rawSectionData, *version);
if (!fontData) {
return std::unexpected(fontData.error());
}
@ -190,17 +274,20 @@ std::expected<void, ParseError> parseAndApply(std::span<std::byte const> data, O
customFonts[customFontIndex++] = *fontData;
}
continue;
} else if (static_cast<SectionType>(*sectionType) == SectionType::ExpiryDate) {
// Ignore expiry date sections here
continue;
}
AlwaysDrawnSectionMetaData section;
if (static_cast<SectionType>(*sectionType) == SectionType::AlwaysDrawn) {
auto metaData = parseAlwaysDrawnSection(*rawSectionData);
auto metaData = parseAlwaysDrawnSection(*rawSectionData, *version);
if (!metaData) {
return std::unexpected(metaData.error());
}
section = *metaData;
} else if (static_cast<SectionType>(*sectionType) == SectionType::TimeBasedDrawn) {
auto metaData = parseTimeBasedDrawnSection(*rawSectionData);
auto metaData = parseTimeBasedDrawnSection(*rawSectionData, *version);
if (!metaData) {
return std::unexpected(metaData.error());
}
@ -208,6 +295,30 @@ std::expected<void, ParseError> parseAndApply(std::span<std::byte const> data, O
continue;
}
section = metaData->base;
} else if (static_cast<SectionType>(*sectionType) == SectionType::MultiTimeBasedDrawn) {
auto metaData = parseMultiTimeBasedDrawnSection(*rawSectionData, *version);
if (!metaData) {
return std::unexpected(metaData.error());
}
bool inRange = false;
for (std::size_t i = 0; i < metaData->rangeCount; ++i) {
auto startTimestamp = readU64LE(metaData->rangeData);
if (!startTimestamp) {
return std::unexpected(startTimestamp.error());
}
auto endTimestamp = readU64LE(metaData->rangeData);
if (!endTimestamp) {
return std::unexpected(endTimestamp.error());
}
if (currentTimestamp >= std::bit_cast<std::int64_t>(*startTimestamp) && currentTimestamp < std::bit_cast<std::int64_t>(*endTimestamp)) {
inRange = true;
break;
}
}
if (!inRange) {
continue;
}
section = metaData->base;
} else {
continue;
}
@ -234,14 +345,15 @@ std::expected<void, ParseError> parseAndApply(std::span<std::byte const> data, O
auto eType = static_cast<ElementType>(*type);
std::expected<void, ParseError> parseResult;
switch (eType) {
case ElementType::Image: parseResult = handleImage(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex)); break;
case ElementType::Animation: parseResult = handleAnimation(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex)); break;
case ElementType::HScrollImage: parseResult = handleHScrollImage(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex)); break;
case ElementType::VScrollImage: parseResult = handleVScrollImage(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex)); break;
case ElementType::Line: parseResult = handleLine(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex)); break;
case ElementType::ClippedText: parseResult = handleClippedText(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex)); break;
case ElementType::HScrollText: parseResult = handleHScrollText(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex)); break;
case ElementType::CurrentTime: parseResult = handleCurrentTime(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex)); break;
case ElementType::Image: parseResult = handleImage(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex), *version); break;
case ElementType::Animation: parseResult = handleAnimation(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex), *version); break;
case ElementType::HScrollImage: parseResult = handleHScrollImage(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex), *version); break;
case ElementType::VScrollImage: parseResult = handleVScrollImage(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex), *version); break;
case ElementType::Line: parseResult = handleLine(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex), *version); break;
case ElementType::Box: parseResult = handleBox(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex), *version); break;
case ElementType::ClippedText: parseResult = handleClippedText(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex), *version); break;
case ElementType::HScrollText: parseResult = handleHScrollText(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex), *version); break;
case ElementType::CurrentTime: parseResult = handleCurrentTime(section.elementData, screen, animationTick, currentTimestamp, std::span{customFonts}.subspan(0, customFontIndex), *version); break;
default: parseResult = std::unexpected(ParseError::InvalidValue);
}
@ -254,7 +366,73 @@ std::expected<void, ParseError> parseAndApply(std::span<std::byte const> data, O
return {};
}
std::expected<void, ParseError> handleImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts) {
std::expected<bool, ParseError> parseAndCheckExpiry(std::span<std::byte const> data, std::int64_t currentTimestamp) {
auto magic = readU32LE(data);
if (!magic) {
return std::unexpected(magic.error());
}
if (*magic != UINT32_C(0x632B7EAF)) {
return std::unexpected(ParseError::InvalidValue);
}
auto version = readU32LE(data);
if (!version) {
return std::unexpected(version.error());
}
if (*version == 1) {
// Version 1 doesn't ever expire
return false;
}
if (*version < 1 || *version > 2) {
return std::unexpected(ParseError::InvalidValue);
}
auto sectionCount = readU16LE(data);
if (!sectionCount) {
return std::unexpected(sectionCount.error());
}
auto reserved_ = readU16LE(data);
if (!reserved_) {
return std::unexpected(reserved_.error());
}
for (std::size_t i = 0; i < *sectionCount; ++i) {
auto sectionType = peekU8LE(data);
if (!sectionType) {
return std::unexpected(sectionType.error());
}
auto sectionSize = peekU24LE(data.subspan(1));
if (!sectionSize) {
return std::unexpected(sectionSize.error());
}
auto rawSectionData = readBuffer(data, *sectionSize);
if (!rawSectionData) {
return std::unexpected(rawSectionData.error());
}
if (static_cast<SectionType>(*sectionType) != SectionType::ExpiryDate) {
// Ignore expiry date sections here
continue;
}
auto headerData = rawSectionData->subspan(4);
auto expiryDate = readU64LE(headerData);
if (!expiryDate) {
return std::unexpected(expiryDate.error());
}
if (currentTimestamp >= std::bit_cast<std::int64_t>(*expiryDate)) {
return true;
}
// Don't abort here, there could be multiple expiry dates in the file
}
// The file isn't expired
return false;
}
std::expected<void, ParseError> handleImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion) {
std::ignore = formatVersion;
auto type = readU16LE(buffer);
if (!type) {
return std::unexpected(type.error());
@ -292,6 +470,10 @@ std::expected<void, ParseError> handleImage(std::span<std::byte const>& buffer,
buffer = buffer.subspan(rounded - imageSize);
}
#if defined(DEBUG)
std::cerr << "[Debug] - Image, x = " << (*x) << ", y = " << (*y) << ", width = " << (*width) << ", height = " << (*height) << std::endl;
#endif
std::ignore = animationTick;
std::ignore = currentTimestamp;
std::ignore = customFonts;
@ -305,7 +487,8 @@ std::expected<void, ParseError> handleImage(std::span<std::byte const>& buffer,
return {};
}
std::expected<void, ParseError> handleAnimation(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts) {
std::expected<void, ParseError> handleAnimation(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion) {
std::ignore = formatVersion;
std::ignore = currentTimestamp;
std::ignore = customFonts;
@ -345,15 +528,22 @@ std::expected<void, ParseError> handleAnimation(std::span<std::byte const>& buff
return std::unexpected(reserved_.error());
}
std::size_t imageSize = ((*width) * (*height) + 8 - 1) / 8;
auto imageData = readBuffer(buffer, imageSize);
auto imageData = readBuffer(buffer, imageSize * (*numberOfFrames));
if (!imageData) {
return std::unexpected(reserved_.error());
}
std::size_t rounded = (imageSize + 4 - 1) / 4 * 4;
if (imageSize < rounded) {
buffer = buffer.subspan(rounded - imageSize);
std::size_t rounded = (imageSize * (*numberOfFrames) + 4 - 1) / 4 * 4;
if (imageSize * (*numberOfFrames) < rounded) {
buffer = buffer.subspan(rounded - imageSize * (*numberOfFrames));
}
#if defined(DEBUG)
std::cerr << "[Debug] - Animation, x = " << (*x) << ", y = " << (*y) << ", width = " << (*width) << ", height = " << (*height)
<< ", numberOfFrames = " << (*numberOfFrames)
<< ", updateInterval = " << (*updateInterval)
<< std::endl;
#endif
if (*numberOfFrames == 0) {
return {};
}
@ -370,7 +560,8 @@ std::expected<void, ParseError> handleAnimation(std::span<std::byte const>& buff
return {};
}
std::expected<void, ParseError> handleHScrollImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts) {
std::expected<void, ParseError> handleHScrollImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion) {
std::ignore = formatVersion;
std::ignore = currentTimestamp;
std::ignore = customFonts;
@ -426,14 +617,24 @@ std::expected<void, ParseError> handleHScrollImage(std::span<std::byte const>& b
auto ownImage = ConstMemoryOneBitBuffer{*imageData, *contentWidth, *height};
ClippedImage target{screen, *x, *y, *width, *height};
#if defined(DEBUG)
std::cerr << "[Debug] - HScrollImage, x = " << (*x) << ", y = " << (*y) << ", width = " << (*width) << ", height = " << (*height)
<< ", contentWidth = " << (*contentWidth)
<< ", scrollSpeed = " << int(*scrollSpeed)
<< ", flags = " << int(*flags)
<< std::endl;
#endif
if (*contentWidth == 0) {
return {};
}
bool restarting = ((*flags) & 0x01) == 0x00;
bool invert = ((*flags) & 0x02) == 0x02;
bool padBefore = ((*flags) & 0x04) == 0x04;
bool padAfter = ((*flags) & 0x08) == 0x08;
ScrollFlags scrollFlags{*flags};
bool restarting = !scrollFlags.endless;
bool invert = scrollFlags.invertDirection;
bool padBefore = scrollFlags.padBefore;
bool padAfter = scrollFlags.padAfter;
if (!padBefore && !padAfter && *contentWidth < *width) {
std::int16_t offset = invert ? (*width - *contentWidth) : 0;
@ -525,7 +726,8 @@ std::expected<void, ParseError> handleHScrollImage(std::span<std::byte const>& b
return {};
}
std::expected<void, ParseError> handleVScrollImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts) {
std::expected<void, ParseError> handleVScrollImage(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion) {
std::ignore = formatVersion;
std::ignore = currentTimestamp;
std::ignore = customFonts;
@ -581,14 +783,24 @@ std::expected<void, ParseError> handleVScrollImage(std::span<std::byte const>& b
auto ownImage = ConstMemoryOneBitBuffer{*imageData, *width, *contentHeight};
ClippedImage target{screen, *x, *y, *width, *height};
#if defined(DEBUG)
std::cerr << "[Debug] - VScrollImage, x = " << (*x) << ", y = " << (*y) << ", width = " << (*width) << ", height = " << (*height)
<< ", contentHeight = " << (*contentHeight)
<< ", scrollSpeed = " << int(*scrollSpeed)
<< ", flags = " << int(*flags)
<< std::endl;
#endif
if (*contentHeight == 0) {
return {};
}
bool restarting = ((*flags) & 0x01) == 0x00;
bool invert = ((*flags) & 0x02) == 0x02;
bool padBefore = ((*flags) & 0x04) == 0x04;
bool padAfter = ((*flags) & 0x08) == 0x08;
ScrollFlags scrollFlags{*flags};
bool restarting = !scrollFlags.endless;
bool invert = scrollFlags.invertDirection;
bool padBefore = scrollFlags.padBefore;
bool padAfter = scrollFlags.padAfter;
if (!padBefore && !padAfter && *contentHeight < *height) {
std::int16_t offset = invert ? (*height - *contentHeight) : 0;
@ -680,7 +892,8 @@ std::expected<void, ParseError> handleVScrollImage(std::span<std::byte const>& b
return {};
}
std::expected<void, ParseError> handleLine(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts) {
std::expected<void, ParseError> handleLine(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion) {
std::ignore = formatVersion;
std::ignore = animationTick;
std::ignore = currentTimestamp;
std::ignore = customFonts;
@ -720,9 +933,17 @@ std::expected<void, ParseError> handleLine(std::span<std::byte const>& buffer, O
return std::unexpected(flags.error());
}
#if defined(DEBUG)
std::cerr << "[Debug] - Line, originX = " << (*originX) << ", originY = " << (*originY) << ", targetX = " << (*targetX) << ", targetY = " << (*targetY)
<< ", lineStyle = " << int(*lineStyle)
<< std::endl;
#endif
LineFlags lineFlags{*flags};
std::int32_t dx = static_cast<std::int32_t>(*targetX) - static_cast<std::int32_t>(*originX);
std::int32_t dy = static_cast<std::int32_t>(*targetY) - static_cast<std::int32_t>(*originY);
bool value = true;
bool value = !lineFlags.dark;
if (dx == 0) {
for (std::int32_t i = 0; i <= dy; ++i) {
@ -743,7 +964,68 @@ std::expected<void, ParseError> handleLine(std::span<std::byte const>& buffer, O
return {};
}
std::expected<void, ParseError> handleClippedText(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts) {
std::expected<void, ParseError> handleBox(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion) {
std::ignore = formatVersion;
std::ignore = animationTick;
std::ignore = currentTimestamp;
std::ignore = customFonts;
auto type = readU16LE(buffer);
if (!type) {
return std::unexpected(type.error());
}
if (*type != static_cast<std::uint16_t>(ElementType::Box)) {
return std::unexpected(ParseError::InvalidValue);
}
auto x = readU16LE(buffer);
if (!x) {
return std::unexpected(x.error());
}
auto y = readU16LE(buffer);
if (!y) {
return std::unexpected(y.error());
}
auto width = readU16LE(buffer);
if (!width) {
return std::unexpected(width.error());
}
auto height = readU16LE(buffer);
if (!height) {
return std::unexpected(height.error());
}
auto fillPattern = readU8LE(buffer);
if (!fillPattern) {
return std::unexpected(fillPattern.error());
}
if (*fillPattern != static_cast<std::uint8_t>(FillPattern::Solid)) {
return std::unexpected(ParseError::InvalidValue);
}
auto flags = readU8LE(buffer);
if (!flags) {
return std::unexpected(flags.error());
}
#if defined(DEBUG)
std::cerr << "[Debug] - Box, x = " << (*x) << ", y = " << (*y) << ", width = " << (*width) << ", height = " << (*height)
<< ", fillPattern = " << int(*fillPattern)
<< std::endl;
#endif
FillFlags fillFlags{*flags};
bool value = !fillFlags.dark;
for (std::int32_t dy = 0; dy < *height; ++dy) {
for (std::int32_t dx = 0; dx < *width; ++dx) {
screen->setPixel(dx + *x, dy + *y, value);
}
}
return {};
}
std::expected<void, ParseError> handleClippedText(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion) {
std::ignore = formatVersion;
std::ignore = animationTick;
std::ignore = currentTimestamp;
@ -770,6 +1052,18 @@ std::expected<void, ParseError> handleClippedText(std::span<std::byte const>& bu
if (!height) {
return std::unexpected(height.error());
}
std::expected<std::uint8_t, ParseError> textFlags_ = 0;
if (formatVersion >= 2) {
textFlags_ = readU8LE(buffer);
if (!textFlags_) {
return std::unexpected(textFlags_.error());
}
auto reserved1 = readU8LE(buffer);
if (!reserved1) {
return std::unexpected(reserved1.error());
}
}
TextFlags textFlags{*textFlags_};
auto fontIndex = readU16LE(buffer);
if (!fontIndex) {
return std::unexpected(fontIndex.error());
@ -779,15 +1073,25 @@ std::expected<void, ParseError> handleClippedText(std::span<std::byte const>& bu
return std::unexpected(textLength.error());
}
std::size_t alignedRoundedUp = (*textLength + 2 + 4 - 1) / 4 * 4 - 2;
if (formatVersion >= 2) {
alignedRoundedUp = (*textLength + 4 - 1) / 4 * 4;
}
auto textData = readBuffer(buffer, alignedRoundedUp);
if (!textData) {
return std::unexpected(textData.error());
}
std::string_view text{reinterpret_cast<char const*>(textData->data()), *textLength};
#if defined(DEBUG)
std::cerr << "[Debug] - ClippedText, x = " << (*x) << ", y = " << (*y) << ", width = " << (*width) << ", height = " << (*height)
<< ", fontIndex = " << int(*fontIndex)
<< ", text = \"" << text << "\""
<< std::endl;
#endif
std::span<std::byte const> fontData;
if ((*fontIndex) & 0x8000u) {
fontIndex = (*fontIndex) & ~0x8000u;
if (isBitSet(*fontIndex, 15)) {
unsetBit(*fontIndex, 15);
if (*fontIndex >= customFonts.size()) {
return {};
}
@ -798,14 +1102,74 @@ std::expected<void, ParseError> handleClippedText(std::span<std::byte const>& bu
if (fontData.size() < 23) {
return {};
}
std::optional<bool> bgColor;
bgColor = textFlags.dark;
bool fgColor = !textFlags.dark;
ClippedImage target{screen, *x, *y, *width, *height};
auto renderer = FontRenderer{fontData}.withIgnoreUnknownChars(true);
renderer.render(text, Point{0, static_cast<std::int32_t>(renderer.lineHeight() - 1)}, target, true, false);
if (textFlags.autoWordWrap) {
auto dimensions = renderer.getRenderedDimensions(text, Point{0, 0});
if (!dimensions) {
return {};
}
std::int32_t y = 0;
while (dimensions->boundingBox && dimensions->boundingBox->size.width > (*width)) {
std::size_t pos = text.find('\n');
if (pos == std::string_view::npos) {
pos = text.size();
}
std::string_view currentLine = text.substr(0, pos);
if (pos < text.size()) {
text = text.substr(pos + 1);
} else {
text = text.substr(pos);
}
while (currentLine.size() > 0) {
dimensions = renderer.getRenderedDimensions(currentLine, Point{0, 0});
if (!dimensions) {
return {};
}
std::size_t pos = currentLine.size();
while (pos > 0 && dimensions->boundingBox && dimensions->boundingBox->size.width > (*width)) {
std::size_t prevPos = pos;
pos = currentLine.rfind(' ', prevPos - 1);
if (pos == std::string_view::npos) {
pos = prevPos;
break;
}
dimensions = renderer.getRenderedDimensions(currentLine.substr(0, pos), Point{0, 0});
if (!dimensions) {
return {};
}
}
renderer.render(currentLine.substr(0, pos), Point{0, y + static_cast<std::int32_t>(renderer.lineHeight() - 2)}, target, fgColor, bgColor);
y += renderer.lineHeight();
if (pos < currentLine.size()) {
currentLine = currentLine.substr(pos + 1);
} else {
currentLine = currentLine.substr(pos);
}
}
dimensions = renderer.getRenderedDimensions(text, Point{0, 0});
if (!dimensions) {
return {};
}
}
if (text.size() > 0) {
renderer.render(text, Point{0, y + static_cast<std::int32_t>(renderer.lineHeight() - 2)}, target, fgColor, bgColor);
y += renderer.lineHeight();
}
} else {
renderer.render(text, Point{0, static_cast<std::int32_t>(renderer.lineHeight() - 2)}, target, fgColor, bgColor);
}
return {};
}
std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts) {
std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion) {
std::ignore = formatVersion;
std::ignore = currentTimestamp;
auto type = readU16LE(buffer);
@ -831,6 +1195,18 @@ std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& bu
if (!height) {
return std::unexpected(height.error());
}
std::expected<std::uint8_t, ParseError> textFlags_ = 0;
if (formatVersion >= 2) {
textFlags_ = readU8LE(buffer);
if (!textFlags_) {
return std::unexpected(textFlags_.error());
}
auto reserved1 = readU8LE(buffer);
if (!reserved1) {
return std::unexpected(reserved1.error());
}
}
TextFlags textFlags{*textFlags_};
auto flags = readU8LE(buffer);
if (!flags) {
return std::unexpected(flags.error());
@ -839,6 +1215,12 @@ std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& bu
if (!scrollSpeed) {
return std::unexpected(scrollSpeed.error());
}
if (formatVersion >= 2) {
auto reserved2 = readU16LE(buffer);
if (!reserved2) {
return std::unexpected(reserved2.error());
}
}
auto fontIndex = readU16LE(buffer);
if (!fontIndex) {
return std::unexpected(fontIndex.error());
@ -854,9 +1236,18 @@ std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& bu
}
std::string_view text{reinterpret_cast<char const*>(textData->data()), *textLength};
#if defined(DEBUG)
std::cerr << "[Debug] - HScrollText, x = " << (*x) << ", y = " << (*y) << ", width = " << (*width) << ", height = " << (*height)
<< ", scrollSpeed = " << int(*scrollSpeed)
<< ", flags = " << int(*flags)
<< ", fontIndex = " << int(*fontIndex)
<< ", text = \"" << text << "\""
<< std::endl;
#endif
std::span<std::byte const> fontData;
if ((*fontIndex) & 0x8000u) {
fontIndex = (*fontIndex) & ~0x8000u;
if (isBitSet(*fontIndex, 15)) {
unsetBit(*fontIndex, 15);
if (*fontIndex >= customFonts.size()) {
return {};
}
@ -869,7 +1260,7 @@ std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& bu
}
ClippedImage target{screen, *x, *y, *width, *height};
auto renderer = FontRenderer{fontData}.withIgnoreUnknownChars(true);
auto dimensions = renderer.getRenderedDimensions(text, {0, static_cast<std::int32_t>(renderer.lineHeight() - 1)});
auto dimensions = renderer.getRenderedDimensions(text, {0, static_cast<std::int32_t>(renderer.lineHeight() - 2)});
if (!dimensions || !dimensions->boundingBox) {
return {};
}
@ -880,14 +1271,20 @@ std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& bu
return {};
}
bool restarting = ((*flags) & 0x01) == 0x00;
bool invert = ((*flags) & 0x02) == 0x02;
bool padBefore = ((*flags) & 0x04) == 0x04;
bool padAfter = ((*flags) & 0x08) == 0x08;
ScrollFlags scrollFlags{*flags};
std::optional<bool> bgColor;
bgColor = textFlags.dark;
bool fgColor = !textFlags.dark;
bool restarting = !scrollFlags.endless;
bool invert = scrollFlags.invertDirection;
bool padBefore = scrollFlags.padBefore;
bool padAfter = scrollFlags.padAfter;
if (!padBefore && !padAfter && contentWidth < *width) {
std::uint16_t offset = invert ? (*width - contentWidth) : 0;
renderer.render(text, Point{offset, static_cast<std::int32_t>(renderer.lineHeight() - 1)}, target, true, false);
renderer.render(text, Point{offset, static_cast<std::int32_t>(renderer.lineHeight() - 2)}, target, fgColor, bgColor);
return {};
}
@ -911,51 +1308,58 @@ std::expected<void, ParseError> handleHScrollText(std::span<std::byte const>& bu
std::int32_t partOffset = 0;
if (padBefore) {
for (std::int32_t dy = 0; dy < *height; ++dy) {
for (std::int32_t dx = 0; dx < *width; ++dx) {
std::int32_t ddx = dx + partOffset;
if ((ddx - offset) < 0) {
continue;
if (bgColor) {
for (std::int32_t dy = 0; dy < *height; ++dy) {
for (std::int32_t dx = 0; dx < *width; ++dx) {
std::int32_t ddx = dx + partOffset;
if ((ddx - offset) < 0) {
continue;
}
target.setPixel(ddx - offset, dy, *bgColor);
}
target.setPixel(ddx - offset, dy, false);
}
}
partOffset += *width;
}
renderer.render(text, Point{partOffset - offset, static_cast<std::int32_t>(renderer.lineHeight() - 1)}, target, true, false);
renderer.render(text, Point{partOffset - offset, static_cast<std::int32_t>(renderer.lineHeight() - 2)}, target, fgColor, bgColor);
partOffset += contentWidth;
if (padAfter) {
for (std::int32_t dy = 0; dy < *height; ++dy) {
for (std::int32_t dx = 0; dx < *width; ++dx) {
std::int32_t ddx = dx + partOffset;
if ((ddx - offset) < 0) {
continue;
if (bgColor) {
for (std::int32_t dy = 0; dy < *height; ++dy) {
for (std::int32_t dx = 0; dx < *width; ++dx) {
std::int32_t ddx = dx + partOffset;
if ((ddx - offset) < 0) {
continue;
}
target.setPixel(ddx - offset, dy, *bgColor);
}
target.setPixel(ddx - offset, dy, false);
}
}
partOffset += *width;
}
if (!restarting) {
if (padBefore) {
for (std::int32_t dy = 0; dy < *height; ++dy) {
for (std::int32_t dx = 0; dx < *width; ++dx) {
std::int32_t ddx = dx + partOffset;
if ((ddx - offset) < 0) {
continue;
if (bgColor) {
for (std::int32_t dy = 0; dy < *height; ++dy) {
for (std::int32_t dx = 0; dx < *width; ++dx) {
std::int32_t ddx = dx + partOffset;
if ((ddx - offset) < 0) {
continue;
}
target.setPixel(ddx - offset, dy, *bgColor);
}
target.setPixel(ddx - offset, dy, false);
}
}
partOffset += *width;
}
renderer.render(text, Point{partOffset - offset, static_cast<std::int32_t>(renderer.lineHeight() - 1)}, target, true, false);
renderer.render(text, Point{partOffset - offset, static_cast<std::int32_t>(renderer.lineHeight() - 2)}, target, fgColor, bgColor);
}
return {};
}
std::expected<void, ParseError> handleCurrentTime(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts) {
std::expected<void, ParseError> handleCurrentTime(std::span<std::byte const>& buffer, OneBitBufferInterface* screen, std::size_t animationTick, std::int64_t currentTimestamp, std::span<std::span<std::byte const>> customFonts, std::uint32_t formatVersion) {
std::ignore = formatVersion;
std::ignore = animationTick;
auto type = readU16LE(buffer);
@ -981,6 +1385,18 @@ std::expected<void, ParseError> handleCurrentTime(std::span<std::byte const>& bu
if (!height) {
return std::unexpected(height.error());
}
std::expected<std::uint8_t, ParseError> textFlags_ = 0;
if (formatVersion >= 2) {
textFlags_ = readU8LE(buffer);
if (!textFlags_) {
return std::unexpected(textFlags_.error());
}
auto reserved1 = readU8LE(buffer);
if (!reserved1) {
return std::unexpected(reserved1.error());
}
}
TextFlags textFlags{*textFlags_};
auto fontIndex = readU16LE(buffer);
if (!fontIndex) {
return std::unexpected(fontIndex.error());
@ -993,10 +1409,24 @@ std::expected<void, ParseError> handleCurrentTime(std::span<std::byte const>& bu
if (!flags) {
return std::unexpected(flags.error());
}
if (formatVersion >= 2) {
auto reserved2 = readU16LE(buffer);
if (!reserved2) {
return std::unexpected(reserved2.error());
}
}
#if defined(DEBUG)
std::cerr << "[Debug] - CurrentTime, x = " << (*x) << ", y = " << (*y) << ", width = " << (*width) << ", height = " << (*height)
<< ", fontIndex = " << int(*fontIndex)
<< ", utcOffset = " << int(*utcOffset)
<< ", flags = " << int(*flags)
<< std::endl;
#endif
std::span<std::byte const> fontData;
if ((*fontIndex) & 0x8000u) {
fontIndex = (*fontIndex) & ~0x8000u;
if (isBitSet(*fontIndex, 15)) {
unsetBit(*fontIndex, 15);
if (*fontIndex >= customFonts.size()) {
return {};
}
@ -1007,12 +1437,19 @@ std::expected<void, ParseError> handleCurrentTime(std::span<std::byte const>& bu
if (fontData.size() < 23) {
return {};
}
std::optional<bool> bgColor;
bgColor = textFlags.dark;
bool fgColor = !textFlags.dark;
ClippedImage target{screen, *x, *y, *width, *height};
bool use12h = ((*flags) & 0x01u) == 0x01u;
bool showHours = ((*flags) & 0x02u) == 0x02u;
bool showMinutes = ((*flags) & 0x04u) == 0x04u;
bool showSeconds = ((*flags) & 0x08u) == 0x08u;
TimeDisplayFlags timeDisplayFlags{*flags};
bool use12h = timeDisplayFlags.use12h;
bool showHours = timeDisplayFlags.showHours;
bool showMinutes = timeDisplayFlags.showMinutes;
bool showSeconds = timeDisplayFlags.showSeconds;
if (showHours && showSeconds) {
showMinutes = true;
}
@ -1029,22 +1466,24 @@ std::expected<void, ParseError> handleCurrentTime(std::span<std::byte const>& bu
}
}
std::string text;
char text[32] = {};
if (showHours && showMinutes && showSeconds) {
text = std::format("{:02d}:{:02d}:{:02d}", hours, minutes, seconds);
std::snprintf(text, sizeof(text), "%02d:%02d:%02d", int(hours), int(minutes), int(seconds));
} else if (showHours && showMinutes) {
text = std::format("{:02d}:{:02d}", hours, minutes);
std::snprintf(text, sizeof(text), "%02d:%02d", int(hours), int(minutes));
} else if (showHours) {
text = std::format("{:02d}", hours);
std::snprintf(text, sizeof(text), "%02d", int(hours));
} else if (showMinutes && showSeconds) {
text = std::format("{:02d}:{:02d}", minutes, seconds);
std::snprintf(text, sizeof(text), "%02d:%02d", int(minutes), int(seconds));
} else if (showSeconds) {
text = std::format("{:02d}", seconds);
std::snprintf(text, sizeof(text), "%02d", int(seconds));
} else if (showMinutes) {
std::snprintf(text, sizeof(text), "%02d", int(minutes));
} else {
return {};
}
renderer.render(text, Point{0, static_cast<std::int32_t>(renderer.lineHeight() - 1)}, target, true, false);
renderer.render(text, Point{0, static_cast<std::int32_t>(renderer.lineHeight() - 2)}, target, fgColor, bgColor);
return {};
}

@ -10,6 +10,8 @@ std::expected<void, ParseError> parseAndApply(std::span<std::byte const> data, O
std::uint16_t screenWidth, std::uint16_t screenHeight,
bool isFront, std::size_t animationTick, std::int64_t currentTimestamp);
std::expected<bool, ParseError> parseAndCheckExpiry(std::span<std::byte const> data, std::int64_t currentTimestamp);
} // namespace monoformat
#endif // MONOFORMAT_PARSEONLY_HPP

@ -1,4 +1,5 @@
#include "monoformat_schema.hpp"
#include "monoformat_bithelpers.hpp"
#include <tuple>
@ -26,7 +27,7 @@ bool MemoryOneBitBuffer::isPixelSet(std::uint16_t x, std::uint16_t y) const {
std::size_t pxIndex = static_cast<std::size_t>(y) * m_width + x;
std::size_t byteIndex = pxIndex / 8;
std::size_t bitIndex = pxIndex % 8;
return ((static_cast<std::uint8_t>(m_buffer[byteIndex]) >> bitIndex) & 0x01) == 0x01;
return isBitSet(m_buffer[byteIndex], bitIndex);
}
void MemoryOneBitBuffer::setPixel(std::uint16_t x, std::uint16_t y, bool value) {
@ -36,11 +37,7 @@ void MemoryOneBitBuffer::setPixel(std::uint16_t x, std::uint16_t y, bool value)
std::size_t pxIndex = static_cast<std::size_t>(y) * m_width + x;
std::size_t byteIndex = pxIndex / 8;
std::size_t bitIndex = pxIndex % 8;
if (value) {
m_buffer[byteIndex] = static_cast<std::byte>(static_cast<std::uint8_t>(m_buffer[byteIndex]) | (1u << bitIndex));
} else {
m_buffer[byteIndex] = static_cast<std::byte>(static_cast<std::uint8_t>(m_buffer[byteIndex]) & ~(1u << bitIndex));
}
maybeSetBit(m_buffer[byteIndex], bitIndex, value);
}
ConstMemoryOneBitBuffer::ConstMemoryOneBitBuffer(std::span<std::byte const> buffer, std::uint16_t width, std::uint16_t height)
@ -65,7 +62,7 @@ bool ConstMemoryOneBitBuffer::isPixelSet(std::uint16_t x, std::uint16_t y) const
std::size_t pxIndex = static_cast<std::size_t>(y) * m_width + x;
std::size_t byteIndex = pxIndex / 8;
std::size_t bitIndex = pxIndex % 8;
return ((static_cast<std::uint8_t>(m_buffer[byteIndex]) >> bitIndex) & 0x01) == 0x01;
return isBitSet(m_buffer[byteIndex], bitIndex);
}
void ConstMemoryOneBitBuffer::setPixel(std::uint16_t x, std::uint16_t y, bool value) {

@ -1,15 +1,20 @@
#ifndef MONOFORMAT_SCHEMA_HPP
#define MONOFORMAT_SCHEMA_HPP
#include "monoformat_bithelpers.hpp"
#include <cstddef>
#include <cstdint>
#include <span>
#include <tuple>
namespace monoformat {
enum class SectionType {
AlwaysDrawn = 1,
TimeBasedDrawn = 2,
MultiTimeBasedDrawn = 3,
ExpiryDate = 31,
CustomFont = 32,
};
@ -19,6 +24,7 @@ enum class ElementType {
HScrollImage = 3,
VScrollImage = 4,
Line = 5,
Box = 6,
ClippedText = 16,
HScrollText = 17,
//VScrollText = 18,
@ -29,6 +35,116 @@ enum class LineStyle : std::uint8_t {
Solid = 0,
};
struct ScrollFlags {
bool endless{};
bool invertDirection{};
bool padBefore{};
bool padAfter{};
ScrollFlags() = default;
explicit ScrollFlags(std::uint8_t flags) noexcept
: endless{isBitSet(flags, 0)}
, invertDirection{isBitSet(flags, 1)}
, padBefore{isBitSet(flags, 2)}
, padAfter{isBitSet(flags, 3)}
{
}
explicit operator std::uint8_t() const noexcept {
std::uint8_t result{};
maybeSetBit(result, 0, endless);
maybeSetBit(result, 1, invertDirection);
maybeSetBit(result, 2, padBefore);
maybeSetBit(result, 3, padAfter);
return result;
}
};
struct LineFlags {
bool dark{};
LineFlags() = default;
explicit LineFlags(std::uint8_t flags) noexcept
: dark{isBitSet(flags, 0)}
{
}
explicit operator std::uint8_t() const noexcept {
std::uint8_t result{};
maybeSetBit(result, 0, dark);
return result;
}
};
enum class FillPattern : std::uint8_t {
Solid = 0,
};
struct FillFlags {
bool dark{};
FillFlags() = default;
explicit FillFlags(std::uint8_t flags) noexcept
: dark{isBitSet(flags, 0)}
{
}
explicit operator std::uint8_t() const noexcept {
std::uint8_t result{};
maybeSetBit(result, 0, dark);
return result;
}
};
struct TextFlags {
bool dark{};
bool autoWordWrap{};
TextFlags() = default;
explicit TextFlags(std::uint8_t flags) noexcept
: dark{isBitSet(flags, 0)}
, autoWordWrap{isBitSet(flags, 1)}
{
}
explicit operator std::uint8_t() const noexcept {
std::uint8_t result{};
maybeSetBit(result, 0, dark);
maybeSetBit(result, 1, autoWordWrap);
return result;
}
};
struct TimeDisplayFlags {
bool use12h{};
bool showHours{};
bool showMinutes{};
bool showSeconds{};
TimeDisplayFlags() = default;
explicit TimeDisplayFlags(std::uint16_t flags) noexcept
: use12h{isBitSet(flags, 0)}
, showHours{isBitSet(flags, 1)}
, showMinutes{isBitSet(flags, 2)}
, showSeconds{isBitSet(flags, 3)}
{
}
explicit operator std::uint16_t() const noexcept {
std::uint16_t result{};
maybeSetBit(result, 0, use12h);
maybeSetBit(result, 1, showHours);
maybeSetBit(result, 2, showMinutes);
maybeSetBit(result, 3, showSeconds);
return result;
}
};
struct OneBitBufferInterface {
using Color = bool;

File diff suppressed because it is too large Load Diff

@ -4,6 +4,8 @@
#include "monoformat_parsehelpers.hpp"
#include "monoformat_schema.hpp"
#include <nlohmann/json.hpp>
#include <cstddef>
#include <cstdint>
#include <memory>
@ -15,7 +17,9 @@ namespace monoformat {
struct Section {
virtual ~Section() = default;
virtual SectionType sectionType() const = 0;
virtual std::size_t serializeTo(std::span<std::byte> target) const = 0;
virtual std::uint32_t minimumFormatVersion() const = 0;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const = 0;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const = 0;
protected:
Section() = default;
@ -26,7 +30,9 @@ class CustomFontSection;
struct Element {
virtual ~Element() = default;
virtual ElementType elementType() const = 0;
virtual std::size_t serializeTo(std::span<std::byte> target) const = 0;
virtual std::uint32_t minimumFormatVersion() const = 0;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const = 0;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const = 0;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) = 0;
protected:
@ -48,10 +54,13 @@ struct ImageElement : public Element {
virtual ~ImageElement() override;
virtual ElementType elementType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) override;
static std::expected<std::unique_ptr<ImageElement>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<ImageElement>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<ImageElement> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::uint16_t m_x{};
@ -80,10 +89,13 @@ struct AnimationElement : public Element {
virtual ~AnimationElement() override;
virtual ElementType elementType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) override;
static std::expected<std::unique_ptr<AnimationElement>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<AnimationElement>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<AnimationElement> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::uint16_t m_x{};
@ -96,14 +108,14 @@ private:
};
struct HScrollImageElement : public Element {
HScrollImageElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, std::uint16_t contentWidth, std::uint8_t flags, std::uint8_t scrollSpeed);
HScrollImageElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, std::uint16_t contentWidth, ScrollFlags flags, std::uint8_t scrollSpeed);
std::uint16_t x() const noexcept;
std::uint16_t y() const noexcept;
std::uint16_t width() const noexcept;
std::uint16_t height() const noexcept;
std::uint16_t contentWidth() const noexcept;
std::uint8_t flags() const noexcept;
ScrollFlags flags() const noexcept;
std::uint8_t scrollSpeed() const noexcept;
std::span<std::byte> buffer() noexcept;
std::span<std::byte const> buffer() const noexcept;
@ -113,10 +125,13 @@ struct HScrollImageElement : public Element {
virtual ~HScrollImageElement() override;
virtual ElementType elementType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) override;
static std::expected<std::unique_ptr<HScrollImageElement>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<HScrollImageElement>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<HScrollImageElement> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::uint16_t m_x{};
@ -124,20 +139,20 @@ private:
std::uint16_t m_width{};
std::uint16_t m_height{};
std::uint16_t m_contentWidth{};
std::uint8_t m_flags{};
ScrollFlags m_flags;
std::uint8_t m_scrollSpeed{};
std::vector<std::byte> m_buffer;
};
struct VScrollImageElement : public Element {
VScrollImageElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, std::uint16_t contentHeight, std::uint8_t flags, std::uint8_t scrollSpeed);
VScrollImageElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, std::uint16_t contentHeight, ScrollFlags flags, std::uint8_t scrollSpeed);
std::uint16_t x() const noexcept;
std::uint16_t y() const noexcept;
std::uint16_t width() const noexcept;
std::uint16_t height() const noexcept;
std::uint16_t contentHeight() const noexcept;
std::uint8_t flags() const noexcept;
ScrollFlags flags() const noexcept;
std::uint8_t scrollSpeed() const noexcept;
std::span<std::byte> buffer() noexcept;
std::span<std::byte const> buffer() const noexcept;
@ -147,10 +162,13 @@ struct VScrollImageElement : public Element {
virtual ~VScrollImageElement() override;
virtual ElementType elementType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) override;
static std::expected<std::unique_ptr<VScrollImageElement>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<VScrollImageElement>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<VScrollImageElement> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::uint16_t m_x{};
@ -158,27 +176,30 @@ private:
std::uint16_t m_width{};
std::uint16_t m_height{};
std::uint16_t m_contentHeight{};
std::uint8_t m_flags{};
ScrollFlags m_flags;
std::uint8_t m_scrollSpeed{};
std::vector<std::byte> m_buffer;
};
struct LineElement : public Element {
LineElement(std::uint16_t originX, std::uint16_t originY, std::uint16_t targetX, std::uint16_t targetY, LineStyle lineStyle, std::uint8_t flags);
LineElement(std::uint16_t originX, std::uint16_t originY, std::uint16_t targetX, std::uint16_t targetY, LineStyle lineStyle, LineFlags flags);
std::uint16_t originX() const noexcept;
std::uint16_t originY() const noexcept;
std::uint16_t targetX() const noexcept;
std::uint16_t targetY() const noexcept;
LineStyle lineStyle() const noexcept;
std::uint8_t flags() const noexcept;
LineFlags flags() const noexcept;
virtual ~LineElement() override;
virtual ElementType elementType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) override;
static std::expected<std::unique_ptr<LineElement>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<LineElement>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<LineElement> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::uint16_t m_originX{};
@ -186,91 +207,135 @@ private:
std::uint16_t m_targetX{};
std::uint16_t m_targetY{};
LineStyle m_lineStyle{};
std::uint8_t m_flags{};
LineFlags m_flags;
};
struct BoxElement : public Element {
BoxElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, FillPattern fillPattern, FillFlags flags);
std::uint16_t x() const noexcept;
std::uint16_t y() const noexcept;
std::uint16_t width() const noexcept;
std::uint16_t height() const noexcept;
FillPattern fillPattern() const noexcept;
FillFlags flags() const noexcept;
virtual ~BoxElement() override;
virtual ElementType elementType() const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) override;
static std::expected<std::unique_ptr<BoxElement>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<BoxElement> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::uint16_t m_x{};
std::uint16_t m_y{};
std::uint16_t m_width{};
std::uint16_t m_height{};
FillPattern m_fillPattern{};
FillFlags m_flags;
};
struct ClippedTextElement : public Element {
ClippedTextElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, std::uint16_t fontIndex, std::string text);
ClippedTextElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, TextFlags textFlags, std::uint16_t fontIndex, std::string text);
std::uint16_t x() const noexcept;
std::uint16_t y() const noexcept;
std::uint16_t width() const noexcept;
std::uint16_t height() const noexcept;
TextFlags textFlags() const noexcept;
std::uint16_t fontIndex() const noexcept;
std::string text() const;
virtual ~ClippedTextElement() override;
virtual ElementType elementType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) override;
static std::expected<std::unique_ptr<ClippedTextElement>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<ClippedTextElement>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<ClippedTextElement> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::uint16_t m_x{};
std::uint16_t m_y{};
std::uint16_t m_width{};
std::uint16_t m_height{};
TextFlags m_textFlags;
std::uint16_t m_fontIndex{};
std::string m_text;
};
struct HScrollTextElement : public Element {
HScrollTextElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, std::uint8_t flags, std::uint8_t scrollSpeed, std::uint16_t fontIndex, std::string text);
HScrollTextElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, TextFlags textFlags, ScrollFlags flags, std::uint8_t scrollSpeed, std::uint16_t fontIndex, std::string text);
std::uint16_t x() const noexcept;
std::uint16_t y() const noexcept;
std::uint16_t width() const noexcept;
std::uint16_t height() const noexcept;
std::uint8_t flags() const noexcept;
TextFlags textFlags() const noexcept;
ScrollFlags flags() const noexcept;
std::uint8_t scrollSpeed() const noexcept;
std::uint16_t fontIndex() const noexcept;
std::string text() const;
virtual ~HScrollTextElement() override;
virtual ElementType elementType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) override;
static std::expected<std::unique_ptr<HScrollTextElement>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<HScrollTextElement>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<HScrollTextElement> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::uint16_t m_x{};
std::uint16_t m_y{};
std::uint16_t m_width{};
std::uint16_t m_height{};
std::uint8_t m_flags{};
TextFlags m_textFlags;
ScrollFlags m_flags;
std::uint8_t m_scrollSpeed{};
std::uint16_t m_fontIndex{};
std::string m_text;
};
struct CurrentTimeElement : public Element {
CurrentTimeElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, std::uint16_t fontIndex, std::uint16_t utcOffset, std::uint16_t flags);
CurrentTimeElement(std::uint16_t x, std::uint16_t y, std::uint16_t width, std::uint16_t height, TextFlags textFlags, std::uint16_t fontIndex, std::uint16_t utcOffset, TimeDisplayFlags flags);
std::uint16_t x() const noexcept;
std::uint16_t y() const noexcept;
std::uint16_t width() const noexcept;
std::uint16_t height() const noexcept;
TextFlags textFlags() const noexcept;
std::uint16_t fontIndex() const noexcept;
std::uint16_t utcOffset() const noexcept;
std::uint16_t flags() const noexcept;
TimeDisplayFlags flags() const noexcept;
virtual ~CurrentTimeElement() override;
virtual ElementType elementType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
virtual void drawTo(OneBitBufferInterface* imageBuffer, std::size_t animationTick, std::int64_t currentTimestamp, std::span<CustomFontSection const*> customFonts) override;
static std::expected<std::unique_ptr<CurrentTimeElement>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<CurrentTimeElement>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<CurrentTimeElement> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::uint16_t m_x{};
std::uint16_t m_y{};
std::uint16_t m_width{};
std::uint16_t m_height{};
TextFlags m_textFlags{};
std::uint16_t m_fontIndex{};
std::uint16_t m_utcOffset{};
std::uint16_t m_flags{};
TimeDisplayFlags m_flags;
};
struct AlwaysDrawnSection : public Section {
@ -294,9 +359,12 @@ struct AlwaysDrawnSection : public Section {
std::unique_ptr<Element> eraseElement(std::size_t index);
virtual SectionType sectionType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
static std::expected<std::unique_ptr<AlwaysDrawnSection>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<AlwaysDrawnSection>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<AlwaysDrawnSection> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::vector<std::unique_ptr<Element>> m_elements;
@ -331,9 +399,12 @@ struct TimeBasedDrawnSection : public Section {
void setEndTimestamp(std::int64_t value);
virtual SectionType sectionType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
static std::expected<std::unique_ptr<TimeBasedDrawnSection>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<TimeBasedDrawnSection>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<TimeBasedDrawnSection> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::vector<std::unique_ptr<Element>> m_elements;
@ -344,6 +415,64 @@ private:
bool m_clearBeforeDrawing{true};
};
struct MultiTimeBasedDrawnSection : public Section {
MultiTimeBasedDrawnSection() = default;
virtual ~MultiTimeBasedDrawnSection() override;
bool doesDrawOnFront() const noexcept;
bool doesDrawOnBack() const noexcept;
bool doesClearBeforeDrawing() const noexcept;
void setDrawOnFront(bool value) noexcept;
void setDrawOnBack(bool value) noexcept;
void setClearBeforeDrawing(bool value) noexcept;
std::size_t elementCount() const noexcept;
Element* elementAt(std::size_t index) const;
void appendElement(std::unique_ptr<Element> element);
void insertElement(std::size_t index, std::unique_ptr<Element> element);
std::unique_ptr<Element> replaceElement(std::size_t index, std::unique_ptr<Element> element);
std::unique_ptr<Element> eraseElement(std::size_t index);
std::vector<std::pair<std::int64_t, std::int64_t>> timeRanges() const;
void setTimeRanges(std::vector<std::pair<std::int64_t, std::int64_t>> ranges);
virtual SectionType sectionType() const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
static std::expected<std::unique_ptr<MultiTimeBasedDrawnSection>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<MultiTimeBasedDrawnSection> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::vector<std::unique_ptr<Element>> m_elements;
std::vector<std::pair<std::int64_t, std::int64_t>> m_timeRanges;
bool m_drawOnFront{true};
bool m_drawOnBack{true};
bool m_clearBeforeDrawing{true};
};
struct ExpiryDateSection : public Section {
ExpiryDateSection() = default;
virtual ~ExpiryDateSection() override;
std::int64_t expiryDate() const noexcept;
void setExpiryDate(std::int64_t value) noexcept;
virtual SectionType sectionType() const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
static std::expected<std::unique_ptr<ExpiryDateSection>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<ExpiryDateSection> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::int64_t m_expiryDate{};
};
struct CustomFontSection : public Section {
CustomFontSection() = default;
virtual ~CustomFontSection() override;
@ -352,9 +481,12 @@ struct CustomFontSection : public Section {
void setFontData(std::span<std::byte const> fontData);
virtual SectionType sectionType() const override;
virtual std::size_t serializeTo(std::span<std::byte> target) const override;
virtual std::uint32_t minimumFormatVersion() const override;
virtual std::size_t serializeTo(std::span<std::byte> target, std::uint32_t formatVersion) const override;
virtual nlohmann::json serializeJSON(std::uint32_t formatVersion) const override;
static std::expected<std::unique_ptr<CustomFontSection>, ParseError> parse(std::span<std::byte const>& buffer);
static std::expected<std::unique_ptr<CustomFontSection>, ParseError> parse(std::span<std::byte const>& buffer, std::uint32_t formatVersion);
static std::unique_ptr<CustomFontSection> parseJSON(nlohmann::json const& j, std::uint32_t formatVersion);
private:
std::vector<std::byte> m_fontData;
@ -365,7 +497,9 @@ struct File {
};
extern std::size_t serializeFile(std::span<std::byte>& buffer, File const& file);
extern nlohmann::json serializeFileJSON(File const& file);
extern std::expected<File, ParseError> parseFile(std::span<std::byte const> data);
extern File parseFileJSON(nlohmann::json const& j);
} // namespace monoformat

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -1,201 +0,0 @@
<?php
namespace monoformat {
enum SectionType : int {
case AlwaysDrawn = 1;
case TimeBasedDrawn = 2;
case CustomFont = 32;
}
enum ElementType : int {
case Image = 1;
case Animation = 2;
case HScrollImage = 3;
case VScrollImage = 4;
case Line = 5;
case ClippedText = 16;
case HScrollText = 17;
case CurrentTime = 32;
}
enum LineStyle : int {
case Solid = 0;
}
class Element {
public function serialize() {
return "";
}
}
class HScrollTextElement extends Element {
public $x = 0;
public $y = 0;
public $width = 0;
public $height = 0;
public $flags = 0;
public $scrollSpeed = 0;
public $fontIndex = 0;
public $text = "";
public function serialize() {
$len = strlen($this->text);
$result = pack("vvvvvCCvv",
17,
$this->x,
$this->y,
$this->width,
$this->height,
$this->flags,
$this->scrollSpeed,
$this->fontIndex,
$len);
$result .= (string) $this->text;
if ($len % 4 != 0) {
$n = 4 - ($len % 4);
for ($i = 0; $i < $n; ++$i) {
$result .= chr(0);
}
}
return $result;
}
}
class CurrentTimeElement extends Element {
public $x = 0;
public $y = 0;
public $width = 0;
public $height = 0;
public $fontIndex = 0;
public $utcOffset = 0;
public $flags = 0;
public function serialize() {
$result = pack("vvvvvvvv",
32,
$this->x,
$this->y,
$this->width,
$this->height,
$this->fontIndex,
$this->utcOffset,
$this->flags);
return $result;
}
}
class Section {
public function serialize() {
return "";
}
}
class AlwaysDrawnSection extends Section {
public $drawOnFront = false;
public $drawOnBack = false;
public $clearBeforeDrawing = false;
public $elements = [];
public function serialize() {
$flags = 0;
if ($this->drawOnFront) {
$flags |= 0x01;
}
if ($this->drawOnBack) {
$flags |= 0x02;
}
if ($this->clearBeforeDrawing) {
$flags |= 0x04;
}
$inner = pack("vv", $flags, count($this->elements));
foreach ($this->elements as $element) {
$inner .= $element->serialize();
}
$len = strlen($inner) + 4;
$len = substr(pack("V", $len), 0, 3);
return pack("C", 1) . $len . $inner;
}
}
class File {
public $sections = [];
public function serialize() {
$nSections = count($this->sections);
$result = "\xAF\x7E\x2B\x63";
$result .= pack("Vvv", 1, $nSections, 0);
foreach ($this->sections as $section) {
$result .= $section->serialize();
}
return $result;
}
}
} // namespace monoformat
namespace {
$roomName= "BOOL";
$data = json_decode(file_get_contents("https://cfp.cttue.de/tdf5/schedule/export/schedule.json"), true);
$talks = [];
$now = new DateTimeImmutable("now");
foreach ($data["schedule"]["conference"]["days"] as $day) {
foreach ($day["rooms"][$roomName] as $t) {
[$h, $m] = explode(":", $t["duration"]);
$duration = $h * 3600 + $m * 60;
$duration = DateInterval::createFromDateString("$duration sec");
$talk = [
"date" => new DateTimeImmutable($t["date"]),
"duration" => $duration,
"title" => $t["title"],
];
$talkEnd = $talk["date"]->add($talk["duration"]);
if ($talkEnd < $now) {
continue;
}
if ($talk["date"] > $now && count($talks) > 2) {
break;
}
array_push($talks, $talk);
}
}
$elements = [];
function putText($x, $y, $width, $height, $text) {
global $elements;
$e = new monoformat\HScrollTextElement();
$e->x = $x;
$e->y = $y;
$e->width = $width;
$e->height = $height;
$e->flags = 0;
$e->scrollSpeed = 15;
$e->fontIndex = 0;
$e->text = $text;
array_push($elements, $e);
}
$i = 0;
foreach ($talks as $talk) {
putText(10, $i * 20, 25, 20, $talk["date"]->format("H:i"));
putText(35, $i * 20, 85, 20, $talk["title"]);
++$i;
}
$section = new monoformat\AlwaysDrawnSection();
$section->drawOnFront = true;
$section->drawOnBack = true;
$section->clearBeforeDrawing = true;
$section->elements = $elements;
$file = new monoformat\File();
array_push($file->sections, $section);
print($file->serialize());
} // global
?>

@ -0,0 +1,43 @@
<?php
namespace monoformat {
function isBitSet(int $value, int $bit) {
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) {
return false;
}
$mask = 1 << $bit;
return ($value & $mask) == $mask;
}
function setBit(int &$value, int $bit) {
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) {
return;
}
$mask = 1 << $bit;
$value |= $mask;
}
function unsetBit(int &$value, int $bit) {
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) {
return;
}
$mask = 1 << $bit;
$value &= ~$mask;
}
function maybeSetBit(int &$value, int $bit, int $set) {
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) {
return;
}
$mask = 1 << $bit;
if ($set) {
$value |= $mask;
} else {
$value &= ~$mask;
}
}
} // namespace monoformat
?>

@ -0,0 +1,213 @@
<?php
namespace monoformat {
include_once("monoformat_bithelpers.php");
trait FlagStruct {
public static function fromJSON(array $json) {
$myClass = static::class;
$reflect = new \ReflectionClass($myClass);
$properties = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC);
$result = new $myClass();
foreach ($properties as $property) {
$name = $property->getName();
if (array_key_exists($name, $json)) {
$result->$name = (bool) $json[$name];
}
}
return $result;
}
public function toJSON(): array {
$result = [];
$myClass = static::class;
$reflect = new \ReflectionClass($myClass);
$properties = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC);
foreach ($properties as $property) {
$name = $property->getName();
$result[$name] = $this->$name;
}
return $result;
}
}
enum SectionType : int {
case AlwaysDrawn = 1;
case TimeBasedDrawn = 2;
case MultiTimeBasedDrawn = 3;
case ExpiryDate = 31;
case CustomFont = 32;
public static function fromName(string $name): SectionType {
foreach (self::cases() as $case) {
if ($case->name === $name) {
return $case;
}
}
throw new \ValueError("$name is not a valid backing value for enum " . self::class);
}
}
enum ElementType : int {
case Image = 1;
case Animation = 2;
case HScrollImage = 3;
case VScrollImage = 4;
case Line = 5;
case Box = 6;
case ClippedText = 16;
case HScrollText = 17;
//case VScrollText = 18;
case CurrentTime = 32;
public static function fromName(string $name): SectionType {
foreach (self::cases() as $case) {
if ($case->name === $name) {
return $case;
}
}
throw new \ValueError("$name is not a valid backing value for enum " . self::class);
}
}
class ScrollFlags {
use FlagStruct;
public bool $endless = false;
public bool $invertDirection = false;
public bool $padBefore = false;
public bool $padAfter = false;
public static function fromBitField(int $bitfield): ScrollFlags {
$result = new ScrollFlags();
$result->endless = isBitSet($bitfield, 0);
$result->invertDirection = isBitSet($bitfield, 1);
$result->padBefore = isBitSet($bitfield, 2);
$result->padAfter = isBitSet($bitfield, 3);
return $result;
}
public function toBitField(): int {
$result = 0;
maybeSetBit($result, 0, $this->endless);
maybeSetBit($result, 1, $this->invertDirection);
maybeSetBit($result, 2, $this->padBefore);
maybeSetBit($result, 3, $this->padAfter);
return $result;
}
}
enum LineStyle : int {
case Solid = 0;
public static function fromName(string $name): SectionType {
foreach (self::cases() as $case) {
if ($case->name === $name) {
return $case;
}
}
throw new \ValueError("$name is not a valid backing value for enum " . self::class);
}
}
class LineFlags {
use FlagStruct;
public bool $dark = false;
public static function fromBitField(int $bitfield): LineFlags {
$result = new LineFlags();
$result->dark = isBitSet($bitfield, 0);
return $result;
}
public function toBitField(): int {
$result = 0;
maybeSetBit($result, 0, $this->dark);
return $result;
}
}
enum FillPatternStyle : int {
case Solid = 0;
public static function fromName(string $name): SectionType {
foreach (self::cases() as $case) {
if ($case->name === $name) {
return $case;
}
}
throw new \ValueError("$name is not a valid backing value for enum " . self::class);
}
}
class FillFlags {
use FlagStruct;
public bool $dark = false;
public static function fromBitField(int $bitfield): FillFlags {
$result = new FillFlags();
$result->dark = isBitSet($bitfield, 0);
return $result;
}
public function toBitField(): int {
$result = 0;
maybeSetBit($result, 0, $this->dark);
return $result;
}
}
class TextFlags {
use FlagStruct;
public bool $dark = false;
public bool $autoWordWrap = false;
public static function fromBitField(int $bitfield): TextFlags {
$result = new TextFlags();
$result->dark = isBitSet($bitfield, 0);
$result->autoWordWrap = isBitSet($bitfield, 1);
return $result;
}
public function toBitField(): int {
$result = 0;
maybeSetBit($result, 0, $this->dark);
maybeSetBit($result, 1, $this->autoWordWrap);
return $result;
}
}
class TimeDisplayFlags {
use FlagStruct;
public bool $use12h = false;
public bool $showHours = false;
public bool $showMinutes = false;
public bool $showSeconds = false;
public static function fromBitField(int $bitfield): TimeDisplayFlags {
$result = new TimeDisplayFlags();
$result->use12h = isBitSet($bitfield, 0);
$result->showHours = isBitSet($bitfield, 1);
$result->showMinutes = isBitSet($bitfield, 2);
$result->showSeconds = isBitSet($bitfield, 3);
return $result;
}
public function toBitField(): int {
$result = 0;
maybeSetBit($result, 0, $this->use12h);
maybeSetBit($result, 1, $this->showHours);
maybeSetBit($result, 2, $this->showMinutes);
maybeSetBit($result, 3, $this->showSeconds);
return $result;
}
}
} // namespace monoformat
?>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,203 @@
<?php
$roomMapping = [
];
$passThroughMapping = [
];
include_once("monoformat_schema.php");
include_once("monoformat_structured.php");
function replaceTalkInfo($str, $talks) {
return preg_replace_callback("/\\$\\{([^}]*)\\}/", function ($matches) use ($talks) {
if (str_contains($matches[1], ".")) {
[$id, $what] = explode(".", $matches[1], 2);
$id = (int) $id;
if ($id < 0 || $id >= count($talks)) {
return "";
}
$talk = $talks[$id];
if (array_key_exists($what, $talk)) {
return $talk[$what];
} else {
return "";
}
} else {
return "";
}
}, $str);
}
if (isset($_GET["mac"]) and array_key_exists($_GET["mac"], $passThroughMapping)) {
$fn = $passThroughMapping[$_GET["mac"]];
if (is_dir($fn)) {
$d = opendir($fn);
$files = [];
while (($e = readdir($d)) !== false) {
if (str_ends_with($e, ".bin")) {
array_push($files, $fn . "/" . $e);
}
}
sort($files);
if (!count($files)) {
die("No files found");
}
$n = (time() / 60) % count($files);
$fn = $files[$n];
}
Header("Content-Type: application/octet-stream");
readfile($fn);
exit(0);
}
if (!isset($_GET["mac"]) or !array_key_exists($_GET["mac"], $roomMapping)) {
/* We didn't find this device in the mapping, so let's just
* send a default file to the user.
*/
Header("Content-Type: application/octet-stream");
readfile(dirname(__FILE__) . "/noroom.bin");
exit(0);
}
$roomGUID = strtolower($roomMapping[$_GET["mac"]]);
$pretalx = json_decode(file_get_contents("schedule.json"), true);
$roomName = null;
foreach ($pretalx["schedule"]["conference"]["rooms"] as $room) {
$thisRoomGUID = strtolower($room["guid"]);
if ($thisRoomGUID === $roomGUID) {
$roomName = $room["name"];
break;
}
}
if ($roomName === null) {
die("Room not found!");
}
$talks = [];
$now = new DateTimeImmutable("now");
foreach ($pretalx["schedule"]["conference"]["days"] as $day) {
if (!array_key_exists($roomName, $day["rooms"])) {
continue;
}
$roomTalks = $day["rooms"][$roomName];
foreach ($roomTalks as $t) {
[$h, $m] = explode(":", $t["duration"]);
$duration = $h * 3600 + $m * 60;
$duration = DateInterval::createFromDateString("$duration sec");
$speakers = [];
foreach ($t["persons"] as $person) {
array_push($speakers, $person["name"]);
}
$talkBegin = new DateTimeImmutable($t["date"]);
$talkEnd = $talkBegin->add($duration);
if ($talkEnd < $now) {
continue;
}
$talk = [
"Time" => $talkBegin->format("H:i"),
"Duration" => $duration->format("H:i"),
"Persons" => implode(", ", $speakers),
"Title" => $t["title"],
"_start" => $talkBegin,
"_end" => $talkEnd,
];
array_push($talks, $talk);
}
}
$template = file_get_contents(dirname(__FILE__) . "/template.bin");
$template = monoformat\parseFile($template);
if (count($template->sections) != 1 or $template->sections[0]->sectionType() != monoformat\SectionType::AlwaysDrawn) {
die("Invalid section in template file");
}
$elements = [];
for ($i = 0; $i < $template->sections[0]->elementCount(); ++$i) {
array_push($elements, $template->sections[0]->elementAt($i));
}
$end_template = file_get_contents(dirname(__FILE__) . "/endscreen.bin");
$end_template = monoformat\parseFile($end_template);
if (count($end_template->sections) != 1 or $end_template->sections[0]->sectionType() != monoformat\SectionType::AlwaysDrawn) {
die("Invalid section in end screen template file");
}
$end_elements = [];
for ($i = 0; $i < $end_template->sections[0]->elementCount(); ++$i) {
array_push($end_elements, $end_template->sections[0]->elementAt($i));
}
$n = 42;
$last = false;
if ($n > count($talks)) {
$n = count($talks);
$last = true;
}
$lastEnd = 0;
$outputFile = new monoformat\File();
if (!$last) {
$section = new monoformat\ExpiryDateSection();
$section->setExpiryDate($talks[$n - 1]["_end"]->getTimestamp());
array_push($outputFile->sections, $section);
}
for ($i = 0; $i < $n; ++$i) {
$subTalks = array_slice($talks, $i);
$end = $subTalks[0]["_end"]->getTimestamp();
$section = new monoformat\TimeBasedDrawnSection();
$section->setDrawOnFront(true);
$section->setDrawOnBack(true);
$section->setClearBeforeDrawing(true);
$section->setStartTimestamp($lastEnd);
$section->setEndTimestamp($end);
foreach ($elements as $element) {
if ($element->elementType() == monoformat\ElementType::ClippedText) {
$x = $element->x();
$y = $element->y();
$width = $element->width();
$height = $element->height();
$textFlags = $element->textFlags();
$fontIndex = $element->fontIndex();
$text = replaceTalkInfo($element->text(), $subTalks);
$element = new monoformat\ClippedTextElement($x, $y, $width, $height, $textFlags, $fontIndex, $text);
} else if ($element->elementType() == monoformat\ElementType::HScrollText) {
$x = $element->x();
$y = $element->y();
$width = $element->width();
$height = $element->height();
$textFlags = $element->textFlags();
$flags = $element->flags();
$scrollSpeed = $element->scrollSpeed();
$fontIndex = $element->fontIndex();
$text = replaceTalkInfo($element->text(), $subTalks) . " ";
$element = new monoformat\HScrollTextElement($x, $y, $width, $height, $textFlags, $flags, $scrollSpeed, $fontIndex, $text);
}
$section->appendElement($element);
}
array_push($outputFile->sections, $section);
$lastEnd = $end;
}
if ($last) {
$section = new monoformat\TimeBasedDrawnSection();
$section->setDrawOnFront(true);
$section->setDrawOnBack(true);
$section->setClearBeforeDrawing(true);
$section->setStartTimestamp($lastEnd);
// Set this far enough in the future that it doesn't matter
$section->setEndTimestamp($lastEnd + 86400 * 300);
foreach ($end_elements as $element) {
$section->appendElement($element);
}
array_push($outputFile->sections, $section);
}
Header("Content-Type: application/octet-stream");
print(monoformat\serializeFile($outputFile));
?>

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

@ -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,68 @@
@media (max-width: 640px) {
#topbar {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
gap: 6px;
padding: 0 8px;
}
#topbar::-webkit-scrollbar {
display: none;
}
#topbar>* {
flex-shrink: 0;
}
#topbar .tb-btn {
font-size: 0;
gap: 0;
padding: 3px 8px;
}
#topbar .tb-btn svg {
width: 15px;
height: 15px;
}
#topbar #theme-toggle {
font-size: 11px;
}
#topbar button[onclick="addSection()"] {
display: none;
}
#mob-add-section {
display: flex;
}
#topbar [style*="flex:1"] {
display: none;
}
#main {
flex-direction: column;
overflow-y: auto;
}
#left,
#right {
width: 100%;
border-right: none;
flex-shrink: 0;
}
#left {
border-bottom: 1px solid var(--bd-inner);
padding: 10px;
gap: 8px;
}
#display-box {
aspect-ratio: 2/1;
width: 100%;
}
}

@ -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,952 @@
// 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";
let 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 }]
);
}
function changeDisplayLayout() {
const sel = document.querySelector('.pv-label select') as HTMLSelectElement;
const [w, h] = sel.value.split('×').map(s => parseInt(s.trim()));
W = w as number;
H = h as number;
((window as any)._mdDriver as MonoDisplayDriver).setSize(W, H);
((window as any)._mdDriver as MonoDisplayDriver).load(() => Promise.resolve(file.toBuffer()));
}
// --- 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 });
changeDisplayLayout();
}
// --- 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: s }) {
function pixelFromCoords(canvas: HTMLCanvasElement, clientX: number, clientY: number) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = Math.floor((clientX - rect.left) * scaleX / PIXEL_SCALE);
const y = Math.floor((clientY - rect.top) * scaleY / PIXEL_SCALE);
if (x < 0 || x >= img.width || y < 0 || y >= img.height) return null;
return { x, y };
}
function paintAt(canvas: HTMLCanvasElement, clientX: number, clientY: number) {
const p = pixelFromCoords(canvas, clientX, clientY);
if (!p) return;
img.pixels[getPixelIndex(img, p.x, p.y)] = s.drawValue;
onpaint();
drawPixelCanvas(canvas, img);
}
function paint(e: MouseEvent) {
paintAt(e.currentTarget as HTMLCanvasElement, e.clientX, e.clientY);
}
function attachTouch(canvas: HTMLCanvasElement) {
canvas.addEventListener("touchstart", (e: TouchEvent) => {
e.preventDefault();
s.drawing = true;
const touch = e.changedTouches[0];
const p = pixelFromCoords(canvas, touch.clientX, touch.clientY);
if (p) s.drawValue = img.pixels[getPixelIndex(img, p.x, p.y)] ? 0 : 1;
paintAt(canvas, touch.clientX, touch.clientY);
}, { passive: false });
canvas.addEventListener("touchmove", (e: TouchEvent) => {
e.preventDefault();
if (!s.drawing) return;
const touch = e.changedTouches[0];
paintAt(canvas, touch.clientX, touch.clientY);
}, { passive: false });
canvas.addEventListener("touchend", (e) => { e.preventDefault(); s.drawing = false; }, { passive: false });
canvas.addEventListener("touchcancel", (e) => { e.preventDefault(); s.drawing = false; }, { passive: false });
}
return m("canvas.pixel-editor", {
width: img.width * PIXEL_SCALE,
height: img.height * PIXEL_SCALE,
oncreate: ({ dom }: m.VnodeDOM) => {
const canvas = dom as HTMLCanvasElement;
drawPixelCanvas(canvas, img);
attachTouch(canvas);
},
onupdate: ({ dom }: m.VnodeDOM) => drawPixelCanvas(dom as HTMLCanvasElement, img),
onmousedown: (e: MouseEvent) => {
s.drawing = true;
const p = pixelFromCoords(e.currentTarget as HTMLCanvasElement, e.clientX, e.clientY);
if (p) s.drawValue = img.pixels[getPixelIndex(img, p.x, p.y)] ? 0 : 1;
paint(e);
},
onmousemove: (e: MouseEvent) => { if (s.drawing) paint(e); },
onmouseup: () => { s.drawing = false; },
onmouseleave: () => { s.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 = () => {
const offscreen = document.createElement("canvas");
const ctx = offscreen.getContext("2d")!;
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);
const imageData = ctx.getImageData(0, 0, w, h);
img.width = w;
img.height = h;
img.pixels = new Uint8Array(w * h).map((_, i) => {
const r = imageData.data[i * 4];
const g = imageData.data[i * 4 + 1];
const b = imageData.data[i * 4 + 2];
const a = imageData.data[i * 4 + 3];
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, changeDisplayLayout });
// window.addEventListener("beforeunload", (e) => {
// if (isDirty) e.preventDefault();
// });
loadFromStorage();
triggerPreview();

@ -0,0 +1,713 @@
:root {
--bg: #111;
--bg-bar: #1a1a1a;
--bg-raised: #161616;
--bg-hover: #1b1b1b;
--bg-hover2: #222;
--bg-sunken: #141414;
--bg-accent: #141e14;
--bg-active: #1e2e1e;
--bg-deep: #0f0f0f;
--bg-input: #0a0a0a;
--bg-canvas: #000;
--bg-xhover: #1e1010;
--bg-overlay: rgba(0, 0, 0, .6);
--bd: #2a2a2a;
--bd-inner: #1e1e1e;
--bd-deep: #1c1c1c;
--bd-card: #222;
--bd-btn: #2d2d2d;
--bd-type: #252525;
--bd-active: #2b3d2b;
--bd-hover: #444;
--bd-strong: #333;
--tx: #ccc;
--tx-hover: #bbb;
--tx-sub: #888;
--tx-label: #777;
--tx-meta: #666;
--tx-dim: #555;
--tx-faint: #444;
--tx-ghost: #333;
--tx-file: #3a3a3a;
--tx-empty: #2e2e2e;
--ac: #33ff66;
--dirty: #886622
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f5f5f5;
--bg-bar: #e8e8e8;
--bg-raised: #efefef;
--bg-hover: #e5e5e5;
--bg-hover2: #e0e0e0;
--bg-sunken: #ebebeb;
--bg-accent: #e8f5e8;
--bg-active: #dff0df;
--bg-deep: #f0f0f0;
--bg-input: #fff;
--bg-canvas: #fff;
--bg-xhover: #ffe8e8;
--bg-overlay: rgba(0, 0, 0, .4);
--bd: #d0d0d0;
--bd-inner: #ddd;
--bd-deep: #e0e0e0;
--bd-card: #ddd;
--bd-btn: #ccc;
--bd-type: #d8d8d8;
--bd-active: #7dc87d;
--bd-hover: #bbb;
--bd-strong: #ccc;
--tx: #222;
--tx-hover: #333;
--tx-sub: #555;
--tx-label: #666;
--tx-meta: #777;
--tx-dim: #888;
--tx-faint: #999;
--tx-ghost: #aaa;
--tx-file: #aaa;
--tx-empty: #bbb;
--ac: #1a9940;
--dirty: #b37a00
}
}
[data-theme="light"] {
--bg: #f5f5f5;
--bg-bar: #e8e8e8;
--bg-raised: #efefef;
--bg-hover: #e5e5e5;
--bg-hover2: #e0e0e0;
--bg-sunken: #ebebeb;
--bg-accent: #e8f5e8;
--bg-active: #dff0df;
--bg-deep: #f0f0f0;
--bg-input: #fff;
--bg-canvas: #fff;
--bg-xhover: #ffe8e8;
--bg-overlay: rgba(0, 0, 0, .4);
--bd: #d0d0d0;
--bd-inner: #ddd;
--bd-deep: #e0e0e0;
--bd-card: #ddd;
--bd-btn: #ccc;
--bd-type: #d8d8d8;
--bd-active: #7dc87d;
--bd-hover: #bbb;
--bd-strong: #ccc;
--tx: #222;
--tx-hover: #333;
--tx-sub: #555;
--tx-label: #666;
--tx-meta: #777;
--tx-dim: #888;
--tx-faint: #999;
--tx-ghost: #aaa;
--tx-file: #aaa;
--tx-empty: #bbb;
--ac: #1a9940;
--dirty: #b37a00
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0
}
body {
background: var(--bg);
color: var(--tx);
font-family: monospace;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 12px
}
#topbar {
height: 36px;
background: var(--bg-bar);
border-bottom: 1px solid var(--bd);
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
flex-shrink: 0
}
#topbar .app-name {
color: var(--tx-dim);
font-size: 11px;
letter-spacing: .12em;
text-transform: uppercase;
margin-right: 4px
}
.tb-btn {
background: var(--bg-raised);
color: var(--tx-sub);
border: 1px solid var(--bd-btn);
border-radius: 3px;
padding: 3px 10px;
font-family: monospace;
font-size: 11px;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap
}
.tb-btn:hover {
background: var(--bg-hover2);
color: var(--tx-hover);
border-color: var(--bd-hover)
}
.tb-btn svg {
width: 13px;
height: 13px;
stroke: currentColor;
fill: none;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0
}
#file-input {
display: none
}
#filename {
color: var(--tx-file);
font-size: 11px;
margin-left: 2px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
.tb-sep {
width: 1px;
height: 18px;
background: var(--bd)
}
.dirty {
color: var(--dirty) !important
}
#main {
flex: 1;
display: flex;
min-height: 0
}
#left {
width: 48%;
border-right: 1px solid var(--bd-inner);
display: flex;
flex-direction: column;
padding: 14px;
gap: 10px;
overflow-y: auto;
flex-shrink: 0
}
#display-box {
width: 100%;
aspect-ratio: 2/1;
background: var(--bg-canvas);
border: 1px solid var(--bd);
border-radius: 3px;
overflow: hidden
}
#canvas_root {
width: 100%;
height: 100%;
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges
}
.pv-label {
font-size: 10px;
color: var(--tx-ghost);
letter-spacing: .1em;
text-transform: uppercase
}
#demo-btns {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px
}
#demo-btns>div {
display: contents;
}
canvas.pixel-editor {
display: block;
cursor: crosshair;
image-rendering: pixelated;
max-width: 100%;
border: 1px solid var(--bd-inner);
}
.anim-editor {
display: flex;
flex-direction: column;
gap: 4px;
}
.anim-frame-tabs {
display: flex;
flex-wrap: wrap;
gap: 3px;
align-items: center;
}
.anim-frame-tab {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 6px;
border: 1px solid var(--bd-inner);
border-radius: 3px;
cursor: pointer;
font-size: 11px;
user-select: none;
}
.anim-frame-tab.active {
border-color: var(--accent, #EC0);
color: var(--accent, #EC0);
}
.x-btn-sm {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0 2px;
font-size: 13px;
line-height: 1;
opacity: 0.6;
}
.x-btn-sm:hover {
opacity: 1;
}
#sec-meta {
background: var(--bg-sunken);
border: 1px solid var(--bd-inner);
border-radius: 3px;
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px
}
.meta-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px
}
.meta-row label {
color: var(--tx-faint);
min-width: 90px
}
.meta-val {
color: var(--tx-meta)
}
.green {
color: var(--ac)
}
select.meta-val {
background: var(--bg-input);
color: var(--ac);
border: 1px solid var(--bd-inner);
border-radius: 3px;
padding: 1px 4px;
}
.flag-check {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: var(--tx-dim);
cursor: pointer
}
.flag-check input {
accent-color: var(--ac);
cursor: pointer
}
#right {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden
}
#right-hdr {
height: 36px;
background: var(--bg-sunken);
border-bottom: 1px solid var(--bd-inner);
display: flex;
align-items: center;
padding: 0 10px;
gap: 6px;
flex-shrink: 0
}
#right-hdr .rh-title {
flex: 1;
color: var(--tx-faint);
font-size: 11px;
letter-spacing: .08em;
text-transform: uppercase
}
#sections-wrap {
flex: 1;
overflow-y: auto;
padding: 8px
}
.empty-state {
color: var(--tx-empty);
font-size: 11px;
padding: 28px 16px;
text-align: center;
line-height: 2
}
/* section card */
.sec-card {
border: 1px solid var(--bd-card);
border-radius: 3px;
margin-bottom: 5px;
overflow: hidden
}
.sec-card.active {
border-color: var(--bd-active)
}
.sec-hdr {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 8px;
cursor: pointer;
user-select: none;
background: var(--bg-raised);
position: relative
}
.sec-hdr:hover {
background: var(--bg-hover)
}
.sec-card.active .sec-hdr,
.sec-hdr.active {
background: var(--bg-active)
}
.sec-arrow {
color: var(--tx-ghost);
font-size: 13px;
line-height: 1;
transition: transform .12s;
flex-shrink: 0
}
.sec-card.open .sec-arrow {
transform: rotate(90deg)
}
.sec-label {
flex: 1;
color: var(--tx-label);
font-size: 11px
}
.sec-badge {
font-size: 10px;
color: var(--ac);
border: 1px solid var(--bd-active);
background: var(--bg-accent);
border-radius: 2px;
padding: 1px 6px;
flex-shrink: 0
}
/* red × - section & element */
.x-btn {
background: none;
border: none;
color: var(--bd);
cursor: pointer;
font-size: 15px;
line-height: 1;
padding: 2px 5px;
border-radius: 2px;
flex-shrink: 0;
transition: color .1s, background .1s
}
.x-btn:hover {
color: #ff4444;
background: var(--bg-xhover)
}
/* element */
.el-list {
background: var(--bg-deep);
border-top: 1px solid var(--bd-deep);
padding: 6px
}
.el-item {
border: 1px solid var(--bd-inner);
border-radius: 3px;
background: var(--bg-sunken);
margin-bottom: 4px;
overflow: hidden
}
.el-item.active {
border-color: var(--ac)
}
.el-item-hdr {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
cursor: pointer
}
.el-item-hdr:hover {
background: var(--bg-hover)
}
.el-type {
font-size: 10px;
color: var(--tx-meta);
border: 1px solid var(--bd-type);
border-radius: 2px;
padding: 1px 6px;
flex-shrink: 0
}
.el-item.active .el-type {
color: var(--ac);
border-color: var(--bd-active)
}
.el-name {
flex: 1;
color: var(--tx-dim);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px
}
.el-fields {
padding: 6px 8px 8px;
border-top: 1px solid var(--bg-bar);
display: flex;
flex-direction: column;
gap: 5px
}
.fields-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 10px
}
.field {
display: flex;
flex-direction: column;
gap: 2px
}
.field.full {
grid-column: 1/-1
}
.field>label {
font-size: 10px;
color: var(--tx-faint)
}
.field input[type=text],
.field input[type=number],
.field input[type=datetime-local],
.field select,
.field textarea {
background: var(--bg-input);
color: var(--tx-sub);
border: 1px solid var(--bd-card);
border-radius: 2px;
padding: 3px 6px;
font-family: monospace;
font-size: 11px;
width: 100%;
outline: none
}
.field input:focus,
.field select:focus,
.field textarea:focus {
border-color: var(--ac);
color: var(--tx)
}
.field textarea {
resize: vertical;
min-height: 44px;
line-height: 1.4
}
.field select option {
background: var(--bg-bar);
color: var(--tx-ghost)
}
.flags-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 2px 0
}
.add-el {
background: transparent;
color: var(--tx-ghost);
border: 1px dashed var(--bd-card);
border-radius: 3px;
padding: 5px 8px;
font-family: monospace;
font-size: 11px;
cursor: pointer;
width: 100%;
text-align: center;
margin-top: 2px
}
.add-el:hover {
color: var(--tx-sub);
border-color: var(--bd-hover)
}
/* confirm dialog */
#confirm-overlay {
display: none;
position: fixed;
inset: 0;
background: var(--bg-overlay);
z-index: 100;
align-items: center;
justify-content: center
}
#confirm-overlay.show {
display: flex
}
#confirm-box {
background: var(--bg-bar);
border: 1px solid var(--bd-strong);
border-radius: 4px;
padding: 20px 24px;
max-width: 340px;
width: 90%;
display: flex;
flex-direction: column;
gap: 14px
}
#confirm-msg {
color: var(--tx-ghost);
font-size: 12px;
line-height: 1.6
}
#confirm-btns {
display: flex;
gap: 8px;
justify-content: flex-end
}
.cb {
background: var(--bg-raised);
color: var(--tx-sub);
border: 1px solid var(--bd-btn);
border-radius: 3px;
padding: 4px 14px;
font-family: monospace;
font-size: 11px;
cursor: pointer
}
.cb:hover {
background: var(--bg-hover2);
color: var(--tx-hover)
}
.cb.primary {
border-color: var(--bd-active);
color: var(--ac)
}
.cb.primary:hover {
background: var(--bg-active)
}
#mob-add-section {
display: none;
}
canvas.pixel-editor {
touch-action: none;
/* belt-and-suspenders alongside preventDefault */
-webkit-user-select: none;
user-select: none;
}
.pv-label select {
background: var(--bg-input);
color: var(--ac);
border: 1px solid var(--bd-inner);
border-radius: 3px;
padding: 1px 14px;
font-family: monospace;
font-size: 10px;
letter-spacing: .1em;
}

@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

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

@ -4,6 +4,10 @@
"workspaces": {
"": {
"name": "libmonoformat",
"dependencies": {
"@types/mithril": "^2.2.8",
"mithril": "^2.3.8",
},
"devDependencies": {
"@types/bun": "latest",
},
@ -15,12 +19,16 @@
"packages": {
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="],
"@types/mithril": ["@types/mithril@2.2.8", "", {}, "sha512-FN9Tv1+Nlr0LNPGnIL/xOxLJfu5WW2n8HAFeo4yxF+/O0per/8g080xlXoo+xj8baowAcfsNI3k80DxyLY34gQ=="],
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"mithril": ["mithril@2.3.8", "", {}, "sha512-za/Yo7qXEckjm5syrSfaaI9Utf4tCUT3T1IOIYqH6Lrj7G0OZuYYLAY9SV4ygoaAf0+CNqU92MBt+7pmo53JVQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
}
}

File diff suppressed because it is too large Load Diff

@ -2,8 +2,18 @@
"name": "libmonoformat",
"module": "src/index.ts",
"type": "module",
"exports": {
".": {
"require": "./dist/index.cjs",
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "bun build src/browser.ts --target browser --outfile public/mono-display.js",
"build": "bun run buildcjs && bun run buildmjs && bun run builddts",
"buildcjs": "bun build src/index.ts --target browser --format cjs --outfile dist/index.cjs",
"buildmjs": "bun build src/index.ts --target browser --format esm --outfile dist/index.mjs",
"builddts": "bun run build-dts.ts",
"test": "bun test"
},
"devDependencies": {
@ -11,5 +21,9 @@
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@types/mithril": "^2.2.8",
"mithril": "^2.3.8"
}
}
}

@ -1,9 +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";
// Expose on globalThis (= window in browser, globalThis elsewhere)
(globalThis as typeof globalThis & { MonoDisplay: typeof MonoDisplay }).MonoDisplay = MonoDisplay;

@ -64,7 +64,7 @@ export class MonoDisplayDriver {
if (!el || el.tagName !== "CANVAS") throw new Error(`#${canvasId} is not a <canvas>`);
this.canvas = el as HTMLCanvasElement;
this.opts = {
onColor: options.onColor ?? "#ffffff",
onColor: options.onColor ?? "#EC0",
offColor: options.offColor ?? "#000000",
scale: options.scale ?? 1,
displayWidth: options.displayWidth ?? 120,
@ -88,6 +88,11 @@ export class MonoDisplayDriver {
}
}
setSize(w: number, h: number) {
this.opts.displayHeight = h;
this.opts.displayWidth = w;
}
stop(): void { this.renderer?.stop(); }
}

@ -4,41 +4,68 @@
import { packedSize, packPixels, pad32 } from "./helper";
import { MONOFORMAT_MAGIC_HEADER } from "./parser";
import { ElementType, SectionType, type AnimationElement, type ClippedTextElement, type CurrentTimeElement, type DrawElement, type ElementsAlwaysDescriptor, type HScrollElement, type HScrollTextElement, type Image2DElement, type LineElement, type MonoDisplayFileDescriptor, type ScrollElementFlags, type SectionFlags, type VScrollElement } from "./types";
import {
ElementType,
SectionType,
type MonoFormatAnimation,
type MonoFormatClippedText,
type MonoFormatCurrentTime,
type MonoFormatCustomFont,
type MonoFormatElement,
type MonoFormatElementsAlways,
type MonoFormatElementsTimespan,
type MonoFormatFile,
type MonoFormatHScroll,
type MonoFormatHScrollText,
type MonoFormatImage2D,
type MonoFormatLine,
type MonoFormatScrollElementFlags,
type MonoFormatSection,
type MonoFormatVScroll,
} from "./types";
/**
* Encodes a MonoDisplayFileDescriptor to a valid .bin ArrayBuffer.
*
* elements_always Section type 1 (ElementsAlways).
* elements_always -> Section type 1 (ElementsAlways).
* Pass a DrawElement[] or { flags, elements }.
* Default flags: drawFront=true, drawBack=false, clearBuffer=false.
*
* PLAN: encode ElementsTimespan sections, CustomFont sections.
*/
export class MonoDisplayFile {
private desc: MonoDisplayFileDescriptor;
export class MonoDisplayFile implements MonoFormatFile {
constructor(public sections: MonoFormatSection[], public version: number = 1) {
constructor(descriptor: MonoDisplayFileDescriptor) {
this.desc = descriptor;
}
toBuffer(): Uint8Array<ArrayBufferLike> {
const sections: Uint8Array[] = [];
// Section 1: elements always
const alwaysDesc = this.desc.elements_always;
if (alwaysDesc) {
const isArray = Array.isArray(alwaysDesc);
const elements = isArray ? alwaysDesc as DrawElement[] : (alwaysDesc as ElementsAlwaysDescriptor).elements;
const flags = isArray ? undefined : (alwaysDesc as ElementsAlwaysDescriptor).flags;
sections.push(this.#encodeElementsSection(SectionType.ElementsAlways, flags, elements));
for (const section of this.sections) {
switch (section.sectionType) {
case SectionType.ElementsAlways:
{
sections.push(this.#encodeElementsAlwaysSection(section));
break;
}
case SectionType.ElementsTimespan:
{
sections.push(this.#encodeElementsTimespanSection(section));
break;
}
case SectionType.CustomFont: {
sections.push(this.#encodeCustomFont(section));
break;
}
}
}
// File header (12 bytes)
const hdrBuf = new ArrayBuffer(12);
const hdrView = new DataView(hdrBuf);
hdrView.setUint32(0, MONOFORMAT_MAGIC_HEADER, true);
hdrView.setUint32(4, 1, true); // version
hdrView.setUint32(4, this.version, true); // version
hdrView.setUint16(8, sections.length, true);
hdrView.setUint16(10, 0, true); // reserved
@ -47,36 +74,86 @@ export class MonoDisplayFile {
// --- section encoders ---
#encodeElementsSection(
type: SectionType.ElementsAlways | SectionType.ElementsTimespan,
flags: SectionFlags | undefined,
elements: DrawElement[],
#encodeElementsAlwaysSection(
section: MonoFormatElementsAlways,
): Uint8Array {
const flagBits =
(flags?.drawFront ?? true ? 0x01 : 0) |
(flags?.drawBack ?? false ? 0x02 : 0) |
(flags?.clearBuffer ?? false ? 0x04 : 0);
(section.flags.drawFront ?? true ? 0x01 : 0) |
(section.flags.drawBack ?? false ? 0x02 : 0) |
(section.flags.clearBuffer ?? false ? 0x04 : 0);
const encodedEls = elements.map(el => this.#encodeElement(el));
const encodedEls = section.elements.map(el => this.#encodeElement(el));
const sectionDataSize = 4 + encodedEls.reduce((s, e) => s + e.byteLength, 0);
// sectionSize (in header) = 4 (header) + sectionDataSize
const sectionSize = 4 + sectionDataSize;
const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4)
const v = new DataView(hdr.buffer);
v.setUint8(0, type);
v.setUint8(0, section.sectionType);
v.setUint8(1, sectionSize & 0xFF);
v.setUint8(2, (sectionSize >> 8) & 0xFF);
v.setUint8(3, (sectionSize >> 16) & 0xFF);
v.setUint16(4, flagBits, true); // flags
v.setUint16(6, elements.length, true); // numElements
v.setUint16(6, section.elements.length, true); // numElements
return this.#concat(hdr, ...encodedEls);
}
#encodeElementsTimespanSection(
section: MonoFormatElementsTimespan,
): Uint8Array {
const flagBits =
(section.flags.drawFront ?? true ? 0x01 : 0) |
(section.flags.drawBack ?? false ? 0x02 : 0) |
(section.flags.clearBuffer ?? false ? 0x04 : 0);
const encodedEls = section.elements.map(el => this.#encodeElement(el));
// sub-header: flags(2) + numElements(2) + startTimestamp(8) + endTimestamp(8) = 20 bytes
const sectionDataSize = 20 + encodedEls.reduce((s, e) => s + e.byteLength, 0);
// sectionSize (in header) = 4 (header) + sectionDataSize
const sectionSize = 4 + sectionDataSize;
const hdr = new Uint8Array(4 + 20); // section header (4) + sub-header (20)
const v = new DataView(hdr.buffer);
v.setUint8(0, section.sectionType);
v.setUint8(1, sectionSize & 0xFF);
v.setUint8(2, (sectionSize >> 8) & 0xFF);
v.setUint8(3, (sectionSize >> 16) & 0xFF);
v.setUint16(4, flagBits, true); // flags
v.setUint16(6, section.elements.length, true); // numElements
const start = section.startTimestamp ?? 0n;
const end = section.endTimestamp ?? 0n;
v.setUint32( 8, Number(start & 0xFFFFFFFFn), true);
v.setUint32(12, Number((start >> 32n) & 0xFFFFFFFFn), true);
v.setUint32(16, Number(end & 0xFFFFFFFFn), true);
v.setUint32(20, Number((end >> 32n) & 0xFFFFFFFFn), true);
return this.#concat(hdr, ...encodedEls);
}
#encodeCustomFont(
section: MonoFormatCustomFont,
): Uint8Array {
const sectionSize = 4 + section.fontData.length;
const hdr = new Uint8Array(4 + 4); // section header (4) + section sub-header (4)
const v = new DataView(hdr.buffer);
v.setUint8(0, section.sectionType);
v.setUint8(1, sectionSize & 0xFF);
v.setUint8(2, (sectionSize >> 8) & 0xFF);
v.setUint8(3, (sectionSize >> 16) & 0xFF);
v.setUint8(4, section.fontData.length & 0xFF);
v.setUint8(5, (section.fontData.length >> 8) & 0xFF);
v.setUint8(6, (section.fontData.length >> 16) & 0xFF);
v.setUint8(7, 0);
return this.#concat(hdr, section.fontData);
}
// --- element encoders ---
#encodeElement(el: DrawElement): Uint8Array {
#encodeElement(el: MonoFormatElement): Uint8Array {
switch (el.type) {
case ElementType.Image2D: return this.#encodeImage2D(el);
case ElementType.Animation: return this.#encodeAnimation(el);
@ -90,8 +167,8 @@ export class MonoDisplayFile {
}
}
#encodeImage2D(el: Image2DElement): Uint8Array {
const packed = packPixels(el.pixels, el.width, el.height);
#encodeImage2D(el: MonoFormatImage2D): Uint8Array {
const packed = packPixels(el.image.pixels, el.image.width, el.image.height);
const fixed = 12; // bytes before pixel data
const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize);
@ -100,14 +177,14 @@ export class MonoDisplayFile {
v.setUint16(0, ElementType.Image2D, true);
v.setUint16(2, el.xOffset ?? 0, true);
v.setUint16(4, el.yOffset ?? 0, true);
v.setUint16(6, el.width, true);
v.setUint16(8, el.height, true);
v.setUint16(6, el.image.width, true);
v.setUint16(8, el.image.height, true);
v.setUint16(10, 0, true); // reserved
out.set(packed, 12);
return out;
}
#encodeAnimation(el: AnimationElement): Uint8Array {
#encodeAnimation(el: MonoFormatAnimation): Uint8Array {
const frameBytes = packedSize(el.width, el.height);
const fixed = 16;
const rawSize = fixed + el.frames.length * frameBytes;
@ -130,7 +207,7 @@ export class MonoDisplayFile {
return out;
}
#encodeScrollFlags(f: ScrollElementFlags | undefined): number {
#encodeScrollFlags(f: MonoFormatScrollElementFlags | undefined): number {
return (
(f?.endless ? 0x01 : 0) |
(f?.invertDirection ? 0x02 : 0) |
@ -139,8 +216,8 @@ export class MonoDisplayFile {
);
}
#encodeHScroll(el: HScrollElement): Uint8Array {
const packed = packPixels(el.pixels, el.contentWidth, el.height);
#encodeHScroll(el: MonoFormatHScroll): Uint8Array {
const packed = packPixels(el.content.pixels, el.contentWidth, el.height);
const fixed = 16;
const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize);
@ -159,8 +236,8 @@ export class MonoDisplayFile {
return out;
}
#encodeVScroll(el: VScrollElement): Uint8Array {
const packed = packPixels(el.pixels, el.width, el.contentHeight);
#encodeVScroll(el: MonoFormatVScroll): Uint8Array {
const packed = packPixels(el.content.pixels, el.width, el.contentHeight);
const fixed = 16;
const rawSize = fixed + packed.byteLength;
const total = rawSize + pad32(rawSize);
@ -179,7 +256,7 @@ export class MonoDisplayFile {
return out;
}
#encodeLine(el: LineElement): Uint8Array {
#encodeLine(el: MonoFormatLine): Uint8Array {
// 12 bytes, already 32-bit aligned
const out = new Uint8Array(12);
const v = new DataView(out.buffer);
@ -193,7 +270,7 @@ export class MonoDisplayFile {
return out;
}
#encodeClippedText(el: ClippedTextElement): Uint8Array {
#encodeClippedText(el: MonoFormatClippedText): Uint8Array {
const enc = new TextEncoder().encode(el.text);
const fixed = 14; // 2+2+2+2+2+2+2
const rawSize = fixed + enc.byteLength;
@ -211,7 +288,7 @@ export class MonoDisplayFile {
return out;
}
#encodeHScrollText(el: HScrollTextElement): Uint8Array {
#encodeHScrollText(el: MonoFormatHScrollText): Uint8Array {
const enc = new TextEncoder().encode(el.text);
const fixed = 16; // 2+2+2+2+2+1+1+2+2
const rawSize = fixed + enc.byteLength;
@ -231,7 +308,7 @@ export class MonoDisplayFile {
return out;
}
#encodeCurrentTime(el: CurrentTimeElement): Uint8Array {
#encodeCurrentTime(el: MonoFormatCurrentTime): Uint8Array {
// Fixed 16 bytes, already 32-bit aligned
const out = new Uint8Array(16);
const v = new DataView(out.buffer);
@ -278,6 +355,14 @@ export async function loadBinFile(url: string): Promise<ArrayBuffer> {
* Build a single-element-always .bin buffer containing one Image2D element.
* Convenience for tests; for production use MonoDisplayFile directly.
*/
export function buildBinBuffer(el: Image2DElement): Uint8Array {
return new MonoDisplayFile({ elements_always: [el] }).toBuffer();
export function buildBinBuffer(el: MonoFormatImage2D): Uint8Array {
return new MonoDisplayFile([{
sectionType: SectionType.ElementsAlways,
elements: [el],
flags: {
drawFront: true,
drawBack: true,
clearBuffer: true,
},
}]).toBuffer();
}

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

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

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

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

@ -0,0 +1,26 @@
// --- Theme --------------------------------------------------------------------
var _THEMES = ['dark', 'light', 'auto'];
function _getTheme() { return document.documentElement.getAttribute('data-theme') || 'auto'; }
function _applyTheme(t: string | undefined) {
if (t === 'auto') { document.documentElement.removeAttribute('data-theme'); localStorage.removeItem('theme'); }
else { document.documentElement.setAttribute('data-theme', t); localStorage.setItem('theme', t); }
_updateThemeBtn();
}
export function cycleTheme() {
var cur = _getTheme(), idx = _THEMES.indexOf(cur);
_applyTheme(_THEMES[(idx + 1) % _THEMES.length]);
}
function _detectDR() {
return !!(document.querySelector('meta[name="darkreader"]') ||
document.querySelector('style[data-darkreader-style]') ||
document.documentElement.getAttribute('data-darkreader-mode'));
}
function _updateThemeBtn() {
var btn = document.getElementById('theme-toggle');
if (!btn) return;
var t = _getTheme(), dr = _detectDR();
var lbl = { dark: '☾ dark', light: '☀ light', auto: '⊙ auto' };
btn.textContent = (dr ? 'DR · ' : '') + (lbl[t] || lbl.auto);
btn.title = dr ? 'DarkReader active - controlling theme' : ('Theme: ' + t + ' (click to cycle)');
}
window.addEventListener('DOMContentLoaded', _updateThemeBtn);

@ -22,6 +22,19 @@ export enum SectionType {
/** U8G2-format custom font used by draw elements in this file */
CustomFont = 32,
}
export type SectionTypeName = keyof typeof SectionType;
export const SectionTypeToString = Object.fromEntries(
(Object.keys(SectionType) as SectionTypeName[])
.filter(k => isNaN(Number(k)))
.map(k => [SectionType[k], k])
) as Record<SectionType, SectionTypeName>;
export const StringToSectionType = Object.fromEntries(
(Object.keys(SectionType) as SectionTypeName[])
.filter(k => isNaN(Number(k)))
.map(k => [k, SectionType[k]])
) as Record<SectionTypeName, SectionType>;
export enum ElementType {
Image2D = 1,
@ -31,15 +44,29 @@ export enum ElementType {
Line = 5,
ClippedText = 16,
HScrollText = 17,
VScrollText = 18,
CurrentTime = 32,
}
export type ElementTypeName = keyof typeof ElementType;
export const ElementTypeToString = Object.fromEntries(
(Object.keys(ElementType) as ElementTypeName[])
.filter(k => isNaN(Number(k)))
.map(k => [ElementType[k], k])
) as Record<ElementType, ElementTypeName>;
export const StringToElementType = Object.fromEntries(
(Object.keys(ElementType) as ElementTypeName[])
.filter(k => isNaN(Number(k)))
.map(k => [k, ElementType[k]])
) as Record<ElementTypeName, ElementType>;
// ---------------------------------------------------------------------------
// Section flags (section types 1 and 2)
// ---------------------------------------------------------------------------
/** Bit-field flags for ElementsAlways / ElementsTimespan sections. */
export interface SectionFlags {
export interface MonoFormatSectionFlags {
/** Bit 0 - render elements onto the front-side buffer. */
drawFront?: boolean;
/** Bit 1 - render elements onto the back-side buffer. */
@ -53,7 +80,7 @@ export interface SectionFlags {
// Scroll element flags (HorizontalScroll / VerticalScroll / HScrollText)
// ---------------------------------------------------------------------------
export interface ScrollElementFlags {
export interface MonoFormatScrollElementFlags {
/** Bit 0 - wrap content endlessly. */
endless?: boolean;
/** Bit 1 - reverse scroll direction. */
@ -69,7 +96,7 @@ export interface ScrollElementFlags {
// CurrentTime element flags
// ---------------------------------------------------------------------------
export interface CurrentTimeFlags {
export interface MonoFormatCurrentTimeFlags {
/** Bit 0 - use 12-hour clock (default 24-hour). */
clock12h?: boolean;
/** Bit 1 - show hours. */
@ -85,7 +112,7 @@ export interface CurrentTimeFlags {
// Line element flags
// ---------------------------------------------------------------------------
export interface LineFlags {
export interface MonoFormatLineFlags {
/** Bit 0 - invert pixel values along the line. */
invertPixels?: boolean;
// Bits 1-7: reserved.
@ -116,174 +143,15 @@ export function customFontOrdinal(fontIndex: FontIndex): number {
return fontIndex - CUSTOM_FONT_BASE;
}
// ---------------------------------------------------------------------------
// User-facing element descriptors (MonoDisplayFile / builder input)
// Pixels are 1 byte/pixel (0 = off, 1 = on) in the API; packed to 1bpp on
// serialisation.
// ---------------------------------------------------------------------------
export interface Image2DElement {
type: ElementType.Image2D;
/** Row-major, 1 byte/pixel (0 = off, 1 = on). Packed to 1bpp on write. */
pixels: Uint8Array;
width: number;
height: number;
xOffset?: number; // default 0
yOffset?: number; // default 0
}
export interface AnimationFrameDescriptor {
/** 1 byte/pixel, same dimensions as the parent AnimationElement. */
pixels: Uint8Array;
}
export interface AnimationElement {
type: ElementType.Animation;
width: number;
height: number;
frames: AnimationFrameDescriptor[];
/**
* Advance frame every (updateInterval + 1) ticks.
* 0 = every tick, 1 = every 2nd tick, ..., 65535 = every 65536th tick.
* Default 0.
*/
updateInterval?: number;
xOffset?: number;
yOffset?: number;
}
export interface HScrollElement {
type: ElementType.HorizontalScroll;
/** Viewport width in pixels. */
width: number;
/** Viewport height in pixels. */
height: number;
/** Scrolling content pixels (contentWidth × height), 1 byte/pixel. */
pixels: Uint8Array;
contentWidth: number;
/**
* Scroll speed byte SS; moves (SS + 1) / 16 pixels per tick.
* Range 0-255. Default 0 (= 1/16 px/tick).
*/
scrollSpeed?: number;
flags?: ScrollElementFlags;
xOffset?: number;
yOffset?: number;
}
export interface VScrollElement {
type: ElementType.VerticalScroll;
/** Viewport width in pixels. */
width: number;
/** Viewport height in pixels. */
height: number;
/** Scrolling content pixels (width × contentHeight), 1 byte/pixel. */
pixels: Uint8Array;
contentHeight: number;
/** Scroll speed byte SS; moves (SS + 1) / 16 pixels per tick. Default 0. */
scrollSpeed?: number;
flags?: ScrollElementFlags;
xOffset?: number;
yOffset?: number;
}
export interface LineElement {
type: ElementType.Line;
xOrigin: number;
yOrigin: number;
xTarget: number;
yTarget: number;
/** Reserved by spec; writers MUST write 0. */
lineStyle?: number;
invertPixels?: boolean; // flags bit 0
}
export interface ClippedTextElement {
type: ElementType.ClippedText;
text: string; // UTF-8
width: number;
height: number;
/** 0-32767 = built-in font; 0x8000+ = custom font in file. Default 0. */
fontIndex?: FontIndex;
xOffset?: number;
yOffset?: number;
}
export interface HScrollTextElement {
type: ElementType.HScrollText;
text: string; // UTF-8
width: number;
height: number;
scrollSpeed?: number; // SS byte, default 0
fontIndex?: FontIndex;
flags?: ScrollElementFlags;
xOffset?: number;
yOffset?: number;
}
export interface CurrentTimeElement {
type: ElementType.CurrentTime;
width: number;
height: number;
/** 0-32767 = built-in font; 0x8000+ = custom font in file. Default 0. */
fontIndex?: FontIndex;
/**
* UTC offset in whole minutes (signed 16-bit integer).
* e.g. UTC+5:30 330, UTC-8 -480.
*/
utcOffsetMinutes?: number;
flags?: CurrentTimeFlags;
xOffset?: number;
yOffset?: number;
}
export type DrawElement =
| Image2DElement
| AnimationElement
| HScrollElement
| VScrollElement
| LineElement
| ClippedTextElement
| HScrollTextElement
| CurrentTimeElement;
// ---------------------------------------------------------------------------
// Section descriptors (MonoDisplayFile / builder input)
// ---------------------------------------------------------------------------
export interface ElementsAlwaysDescriptor {
flags?: SectionFlags;
elements: DrawElement[];
}
export interface ElementsTimespanDescriptor {
flags?: SectionFlags;
elements: DrawElement[];
/** POSIX timestamp (seconds) - section visible when now >= startTimestamp. */
startTimestamp: bigint;
/** POSIX timestamp (seconds) - section visible when now < endTimestamp. */
endTimestamp: bigint;
}
export interface CustomFontDescriptor {
/** Raw U8G2 font data. */
fontData: Uint8Array;
}
/**
* Top-level descriptor passed to MonoDisplayFile.
*
* Sections are written in the order they appear here.
* Custom fonts MUST appear before any element that references them.
*/
export interface MonoDisplayFileDescriptor {
/** Section type 1 - drawn every render tick. */
elements_always?: DrawElement[] | ElementsAlwaysDescriptor;
/** Section type 2 - drawn only within the given timestamp window. */
elements_timespan?: ElementsTimespanDescriptor[];
/** Section type 32 - embedded U8G2 custom fonts. */
custom_fonts?: CustomFontDescriptor[];
}
// ---------------------------------------------------------------------------
// MonoFormat types (MonoDisplayParser output → renderer input)
@ -314,12 +182,6 @@ export interface MonoFormatAnimation {
frames: MonoFormatPixelImage[];
}
export interface MonoFormatScrollFlags {
endless: boolean;
invertDirection: boolean;
padStart: boolean;
padEnd: boolean;
}
export interface MonoFormatHScroll {
type: ElementType.HorizontalScroll;
@ -329,7 +191,7 @@ export interface MonoFormatHScroll {
height: number;
contentWidth: number;
scrollSpeed: number;
flags: MonoFormatScrollFlags;
flags: MonoFormatScrollElementFlags;
content: MonoFormatPixelImage;
}
@ -341,7 +203,7 @@ export interface MonoFormatVScroll {
height: number;
contentHeight: number;
scrollSpeed: number;
flags: MonoFormatScrollFlags;
flags: MonoFormatScrollElementFlags;
content: MonoFormatPixelImage;
}
@ -373,17 +235,10 @@ export interface MonoFormatHScrollText {
height: number;
scrollSpeed: number;
fontIndex: FontIndex;
flags: MonoFormatScrollFlags;
flags: MonoFormatScrollElementFlags;
text: string;
}
export interface MonoFormatCurrentTimeFlags {
clock12h: boolean;
showHours: boolean;
showMinutes: boolean;
showSeconds: boolean;
}
export interface MonoFormatCurrentTime {
type: ElementType.CurrentTime;
xOffset: number;
@ -406,11 +261,7 @@ export type MonoFormatElement =
| MonoFormatHScrollText
| MonoFormatCurrentTime;
export interface MonoFormatSectionFlags {
drawFront: boolean;
drawBack: boolean;
clearBuffer: boolean;
}
export interface MonoFormatElementsAlways {
sectionType: SectionType.ElementsAlways;

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

Loading…
Cancel
Save