Compare commits
17 Commits
drift
...
bddbd69371
| Author | SHA1 | Date | |
|---|---|---|---|
| bddbd69371 | |||
| 1ba3848bed | |||
| a986df344a | |||
| c84bd5c05a | |||
| 7b0f886e53 | |||
| 9eeb817dca | |||
| ac80ab23cc | |||
| 516123345e | |||
| 11226872a1 | |||
| e6826c884c | |||
| 0740e34293 | |||
| 1e99d70387 | |||
| 7098b2f5aa | |||
| e7de09be50 | |||
| 9140bfd32b | |||
| c49c0aab33 | |||
| 089c8ed66a |
@@ -3,29 +3,29 @@
|
|||||||
mainline\.py does heavy work unsuitable for ESP32: 25\+ HTTPS/TLS RSS feeds, OTF font rasterization via Pillow, Google Translate API calls, and complex text layout\. Simultaneously, messages arriving on `ntfy.sh/klubhaus_terminal_mainline` need to interrupt the news ticker on the same device\.
|
mainline\.py does heavy work unsuitable for ESP32: 25\+ HTTPS/TLS RSS feeds, OTF font rasterization via Pillow, Google Translate API calls, and complex text layout\. Simultaneously, messages arriving on `ntfy.sh/klubhaus_terminal_mainline` need to interrupt the news ticker on the same device\.
|
||||||
## Architecture: Server \+ Thin Client
|
## Architecture: Server \+ Thin Client
|
||||||
Split the system into two halves that are designed together\.
|
Split the system into two halves that are designed together\.
|
||||||
**Server \(mainline\.py `--serve` mode, runs on any always\-on machine\)**
|
**Server (mainline\.py `--serve` mode, runs on any always\-on machine)**
|
||||||
* Reuses existing feed fetching, caching, content filtering, translation, and Pillow font rendering pipeline — no duplication\.
|
* Reuses existing feed fetching, caching, content filtering, translation, and Pillow font rendering pipeline — no duplication\.
|
||||||
* Pre\-renders each headline into a 1\-bit bitmap strip \(the OTF→half\-block pipeline already produces this as an intermediate step in `_render_line()`\)\.
|
* Pre\-renders each headline into a 1\-bit bitmap strip (the OTF→half\-block pipeline already produces this as an intermediate step in `_render_line()`)\.
|
||||||
* Exposes a lightweight HTTP API the ESP32 polls\.
|
* Exposes a lightweight HTTP API the ESP32 polls\.
|
||||||
**ESP32 thin client \(Arduino sketch\)**
|
**ESP32 thin client (Arduino sketch)**
|
||||||
* Polls the mainline server for pre\-rendered headline bitmaps over plain HTTP \(no TLS needed if on the same LAN\)\.
|
* Polls the mainline server for pre\-rendered headline bitmaps over plain HTTP (no TLS needed if on the same LAN)\.
|
||||||
* Polls `ntfy.sh/klubhaus_terminal_mainline` directly for messages, reusing the proven `NetManager::httpGet()` \+ JSON parsing pattern from DoorbellLogic \(`DoorbellLogic.cpp:155-192`\)\.
|
* Polls `ntfy.sh/klubhaus_terminal_mainline` directly for messages, reusing the proven `NetManager::httpGet()` \+ JSON parsing pattern from DoorbellLogic (`DoorbellLogic.cpp:155-192`)\.
|
||||||
* Manages scrolling, gradient coloring, and glitch effects locally \(cheap per\-frame GPU work\)\.
|
* Manages scrolling, gradient coloring, and glitch effects locally (cheap per\-frame GPU work)\.
|
||||||
* When an ntfy message arrives, the scroll is paused and the message takes over the display — same interrupt pattern as the doorbell's ALERT→DASHBOARD flow\.
|
* When an ntfy message arrives, the scroll is paused and the message takes over the display — same interrupt pattern as the doorbell's ALERT→DASHBOARD flow\.
|
||||||
## Server API \(mainline repo\)
|
## Server API (mainline repo)
|
||||||
New file: `serve.py` \(or `--serve` mode in mainline\.py\)\.
|
New file: `serve.py` (or `--serve` mode in mainline\.py)\.
|
||||||
Endpoints:
|
Endpoints:
|
||||||
* `GET /api/headlines` — returns JSON array of headline metadata: `[{"id": 0, "src": "Nature", "ts": "14:30", "width": 280, "height": 16, "bitmap": "<base64 1-bit packed>"}]`\. Bitmaps are 1\-bit\-per\-pixel, row\-major, packed 8px/byte\. The ESP32 applies gradient color locally\.
|
* `GET /api/headlines` — returns JSON array of headline metadata: `[{"id": 0, "src": "Nature", "ts": "14:30", "width": 280, "height": 16, "bitmap": "<base64 1-bit packed>"}]`\. Bitmaps are 1\-bit\-per\-pixel, row\-major, packed 8px/byte\. The ESP32 applies gradient color locally\.
|
||||||
* `GET /api/config` — returns `{"count": 120, "version": "...", "mode": "news"}` so the ESP32 knows what it's getting\.
|
* `GET /api/config` — returns `{"count": 120, "version": "...", "mode": "news"}` so the ESP32 knows what it's getting\.
|
||||||
* `GET /api/health` — `{"ok": true, "last_fetch": "...", "headline_count": 120}`
|
* `GET /api/health` — `{"ok": true, "last_fetch": "...", "headline_count": 120}`
|
||||||
The server renders at a configurable target width \(e\.g\. 800px for Board 3, 320px for Boards 1/2\) via a `--width` flag or query parameter\. Height is fixed per headline by the font size\.
|
The server renders at a configurable target width (e\.g\. 800px for Board 3, 320px for Boards 1/2) via a `--width` flag or query parameter\. Height is fixed per headline by the font size\.
|
||||||
The server refreshes feeds on a timer \(reusing `_SCROLL_DUR` cadence or a longer interval\), re\-renders, and serves the latest set\. The ESP32 polls `/api/headlines` periodically \(e\.g\. every 60s\) and swaps in the new set\.
|
The server refreshes feeds on a timer (reusing `_SCROLL_DUR` cadence or a longer interval), re\-renders, and serves the latest set\. The ESP32 polls `/api/headlines` periodically (e\.g\. every 60s) and swaps in the new set\.
|
||||||
## Render pipeline \(server side\)
|
## Render pipeline (server side)
|
||||||
The existing `_render_line()` in mainline\.py already does:
|
The existing `_render_line()` in mainline\.py already does:
|
||||||
1. `ImageFont.truetype()` → `ImageDraw.text()` → grayscale `Image`
|
1. `ImageFont.truetype()` → `ImageDraw.text()` → grayscale `Image`
|
||||||
2. Resize to target height
|
2. Resize to target height
|
||||||
3. Threshold to 1\-bit \(the `thr = 80` step\)
|
3. Threshold to 1\-bit (the `thr = 80` step)
|
||||||
For the server, we stop at step 3 and pack the 1\-bit data into bytes instead of converting to half\-block Unicode\. This is the exact same pipeline, just with a different output format\. The `_big_wrap()` and `_lr_gradient()` logic stays on the server for layout; gradient *coloring* moves to the ESP32 \(it's just an index lookup per pixel column\)\.
|
For the server, we stop at step 3 and pack the 1\-bit data into bytes instead of converting to half\-block Unicode\. This is the exact same pipeline, just with a different output format\. The `_big_wrap()` and `_lr_gradient()` logic stays on the server for layout; gradient *coloring* moves to the ESP32 (it's just an index lookup per pixel column)\.
|
||||||
## ESP32 client
|
## ESP32 client
|
||||||
### State machine
|
### State machine
|
||||||
```warp-runnable-command
|
```warp-runnable-command
|
||||||
@@ -35,19 +35,19 @@ BOOT → SCROLL ⇄ MESSAGE
|
|||||||
* **BOOT** — WiFi connect, initial headline fetch from server\.
|
* **BOOT** — WiFi connect, initial headline fetch from server\.
|
||||||
* **SCROLL** — Vertical scroll through pre\-rendered headlines with local gradient \+ glitch\. Polls server for new headlines periodically\. Polls ntfy every 15s\.
|
* **SCROLL** — Vertical scroll through pre\-rendered headlines with local gradient \+ glitch\. Polls server for new headlines periodically\. Polls ntfy every 15s\.
|
||||||
* **MESSAGE** — ntfy message arrived\. Scroll paused, message displayed\. Auto\-dismiss after timeout or touch\-dismiss\. Returns to SCROLL\.
|
* **MESSAGE** — ntfy message arrived\. Scroll paused, message displayed\. Auto\-dismiss after timeout or touch\-dismiss\. Returns to SCROLL\.
|
||||||
* **OFF** — Backlight off after inactivity \(polling continues in background\)\.
|
* **OFF** — Backlight off after inactivity (polling continues in background)\.
|
||||||
### ntfy integration
|
### ntfy integration
|
||||||
The ESP32 polls `https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1` on the same 15s interval as the doorbell polls its topics\. When a message event arrives:
|
The ESP32 polls `https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1` on the same 15s interval as the doorbell polls its topics\. When a message event arrives:
|
||||||
1. Parse JSON: `{"event": "message", "title": "...", "message": "..."}`
|
1. Parse JSON: `{"event": "message", "title": "...", "message": "..."}`
|
||||||
2. Save current scroll position\.
|
2. Save current scroll position\.
|
||||||
3. Transition to MESSAGE state\.
|
3. Transition to MESSAGE state\.
|
||||||
4. Render message text using the display library's built\-in fonts \(messages are short, no custom font needed\)\.
|
4. Render message text using the display library's built\-in fonts (messages are short, no custom font needed)\.
|
||||||
5. After `MESSAGE_TIMEOUT_MS` \(e\.g\. 30s\) or touch, restore scroll position and resume\.
|
5. After `MESSAGE_TIMEOUT_MS` (e\.g\. 30s) or touch, restore scroll position and resume\.
|
||||||
This is architecturally identical to `DoorbellLogic::onAlert()` → `dismissAlert()`, just with different content\. The ntfy polling runs independently of the server connection, so messages work even if the mainline server is offline \(the device just shows the last cached headlines\)\.
|
This is architecturally identical to `DoorbellLogic::onAlert()` → `dismissAlert()`, just with different content\. The ntfy polling runs independently of the server connection, so messages work even if the mainline server is offline (the device just shows the last cached headlines)\.
|
||||||
### Headline storage
|
### Headline storage
|
||||||
* Board 3 \(8 MB PSRAM\): store all ~120 headline bitmaps in PSRAM\. At 800px × 16px × 1 bit = 1\.6 KB each → ~192 KB total\. Trivial\.
|
* Board 3 (8 MB PSRAM): store all ~120 headline bitmaps in PSRAM\. At 800px × 16px × 1 bit = 1\.6 KB each → ~192 KB total\. Trivial\.
|
||||||
* Boards 1/2 \(PSRAM TBD\): at 320px × 16px = 640 bytes each → ~77 KB for 120 headlines\. Fits if PSRAM is present\. Without PSRAM, keep ~20 headlines in a ring buffer \(~13 KB\)\.
|
* Boards 1/2 (PSRAM TBD): at 320px × 16px = 640 bytes each → ~77 KB for 120 headlines\. Fits if PSRAM is present\. Without PSRAM, keep ~20 headlines in a ring buffer (~13 KB)\.
|
||||||
### Gradient coloring \(local\)
|
### Gradient coloring (local)
|
||||||
The 12\-step ANSI gradient in mainline\.py maps to 12 RGB565 values:
|
The 12\-step ANSI gradient in mainline\.py maps to 12 RGB565 values:
|
||||||
```warp-runnable-command
|
```warp-runnable-command
|
||||||
const uint16_t GRADIENT[] = {
|
const uint16_t GRADIENT[] = {
|
||||||
@@ -68,8 +68,8 @@ mainline.py (existing, unchanged)
|
|||||||
serve.py (new — HTTP server, imports mainline rendering functions)
|
serve.py (new — HTTP server, imports mainline rendering functions)
|
||||||
klubhaus-doorbell-hardware.md (existing)
|
klubhaus-doorbell-hardware.md (existing)
|
||||||
```
|
```
|
||||||
`serve.py` imports the rendering functions from mainline\.py \(after refactoring them into importable form — they're currently top\-level but not wrapped in `if __name__`\)\.
|
`serve.py` imports the rendering functions from mainline\.py (after refactoring them into importable form — they're currently top\-level but not wrapped in `if __name__`)\.
|
||||||
### klubhaus\-doorbell repo \(or mainline repo under firmware/\)
|
### klubhaus\-doorbell repo (or mainline repo under firmware/)
|
||||||
```warp-runnable-command
|
```warp-runnable-command
|
||||||
boards/esp32-mainline/
|
boards/esp32-mainline/
|
||||||
├── esp32-mainline.ino Main sketch
|
├── esp32-mainline.ino Main sketch
|
||||||
@@ -79,31 +79,31 @@ boards/esp32-mainline/
|
|||||||
├── HeadlineStore.h/.cpp Bitmap ring buffer in PSRAM
|
├── HeadlineStore.h/.cpp Bitmap ring buffer in PSRAM
|
||||||
└── NtfyPoller.h/.cpp ntfy.sh polling (extracted from DoorbellLogic pattern)
|
└── NtfyPoller.h/.cpp ntfy.sh polling (extracted from DoorbellLogic pattern)
|
||||||
```
|
```
|
||||||
The display driver is reused from the target board \(e\.g\. `DisplayDriverGFX` for Board 3\)\. `MainlineLogic` replaces `DoorbellLogic` as the state machine but follows the same patterns\.
|
The display driver is reused from the target board (e\.g\. `DisplayDriverGFX` for Board 3)\. `MainlineLogic` replaces `DoorbellLogic` as the state machine but follows the same patterns\.
|
||||||
## Branch strategy recommendation
|
## Branch strategy recommendation
|
||||||
The work spans two repos and has clear dependency ordering\.
|
The work spans two repos and has clear dependency ordering\.
|
||||||
### Phase 1 — Finish current branch \(mainline repo\)
|
### Phase 1 — Finish current branch (mainline repo)
|
||||||
**Branch:** `feat/arduino` \(current\)
|
**Branch:** `feat/arduino` (current)
|
||||||
**Content:** Hardware spec doc\. Already done\.
|
**Content:** Hardware spec doc\. Already done\.
|
||||||
**Action:** Merge to main when ready\.
|
**Action:** Merge to main when ready\.
|
||||||
### Phase 2 — Server renderer \(mainline repo\)
|
### Phase 2 — Server renderer (mainline repo)
|
||||||
**Branch:** `feat/renderer` \(branch from main after Phase 1 merges\)
|
**Branch:** `feat/renderer` (branch from main after Phase 1 merges)
|
||||||
**Content:**
|
**Content:**
|
||||||
* Refactor mainline\.py rendering functions to be importable \(extract from `__main__` guard\)
|
* Refactor mainline\.py rendering functions to be importable (extract from `__main__` guard)
|
||||||
* `serve.py` — HTTP server with `/api/headlines`, `/api/config`, `/api/health`
|
* `serve.py` — HTTP server with `/api/headlines`, `/api/config`, `/api/health`
|
||||||
* Bitmap packing utility \(1\-bit row\-major\)
|
* Bitmap packing utility (1\-bit row\-major)
|
||||||
**Why a separate branch:** This changes mainline\.py's structure \(refactoring for imports\) and adds a new entry point\. It's a self\-contained, testable unit — you can verify the API with `curl` before touching any Arduino code\.
|
**Why a separate branch:** This changes mainline\.py's structure (refactoring for imports) and adds a new entry point\. It's a self\-contained, testable unit — you can verify the API with `curl` before touching any Arduino code\.
|
||||||
### Phase 3 — ESP32 client \(klubhaus\-doorbell repo, or mainline repo\)
|
### Phase 3 — ESP32 client (klubhaus\-doorbell repo, or mainline repo)
|
||||||
**Branch:** `feat/mainline-client` in whichever repo hosts it
|
**Branch:** `feat/mainline-client` in whichever repo hosts it
|
||||||
**Content:**
|
**Content:**
|
||||||
* `MainlineLogic` state machine
|
* `MainlineLogic` state machine
|
||||||
* `HeadlineStore` bitmap buffer
|
* `HeadlineStore` bitmap buffer
|
||||||
* `NtfyPoller` for `klubhaus_terminal_mainline`
|
* `NtfyPoller` for `klubhaus_terminal_mainline`
|
||||||
* Board\-specific sketch for the target board
|
* Board\-specific sketch for the target board
|
||||||
**Depends on:** Phase 2 \(needs a running server to test against\)
|
**Depends on:** Phase 2 (needs a running server to test against)
|
||||||
**Repo decision:** If you have push access to klubhaus\-doorbell, it fits naturally as a new board target alongside the existing doorbell sketches — it reuses `NetManager`, `IDisplayDriver`, and the vendored display libraries\. If not, put it under `mainline/firmware/` and vendor the shared KlubhausCore library\.
|
**Repo decision:** If you have push access to klubhaus\-doorbell, it fits naturally as a new board target alongside the existing doorbell sketches — it reuses `NetManager`, `IDisplayDriver`, and the vendored display libraries\. If not, put it under `mainline/firmware/` and vendor the shared KlubhausCore library\.
|
||||||
### Merge order
|
### Merge order
|
||||||
1. `feat/arduino` → main \(hardware spec\)
|
1. `feat/arduino` → main (hardware spec)
|
||||||
2. `feat/renderer` → main \(server\)
|
2. `feat/renderer` → main (server)
|
||||||
3. `feat/mainline-client` → main in whichever repo \(ESP32 client\)
|
3. `feat/mainline-client` → main in whichever repo (ESP32 client)
|
||||||
Each phase is independently testable and doesn't block the other until Phase 3 needs a running server\.
|
Each phase is independently testable and doesn't block the other until Phase 3 needs a running server\.
|
||||||
26
README.md
26
README.md
@@ -14,6 +14,10 @@ python3 mainline.py --poetry # literary consciousness mode
|
|||||||
python3 mainline.py -p # same
|
python3 mainline.py -p # same
|
||||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||||
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
||||||
|
python3 mainline.py --no-font-picker # skip interactive font picker
|
||||||
|
python3 mainline.py --font-file path.otf # use a specific font file
|
||||||
|
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
||||||
|
python3 mainline.py --font-index 1 # select face index within a collection
|
||||||
```
|
```
|
||||||
|
|
||||||
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache.
|
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache.
|
||||||
@@ -29,7 +33,10 @@ All constants live in `engine/config.py`:
|
|||||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||||
| `FONT_PATH` | hardcoded path | Path to your OTF/TTF display font |
|
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||||
|
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
||||||
|
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
||||||
|
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
||||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||||
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
||||||
@@ -41,15 +48,24 @@ All constants live in `engine/config.py`:
|
|||||||
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
|
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
|
||||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||||
|
|
||||||
**Font:** `FONT_PATH` is hardcoded to a local path. Update it to point to whatever display font you want — anything with strong contrast and wide letterforms works well.
|
---
|
||||||
|
|
||||||
|
## Fonts
|
||||||
|
|
||||||
|
A `fonts/` directory is bundled with demo faces (AlphatronDemo, CSBishopDrawn, CyberformDemo, KATA, Microbots, Neoform, Pixel Sparta, Robocops, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
||||||
|
|
||||||
|
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
||||||
|
|
||||||
|
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
|
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
||||||
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
||||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
||||||
- A left-to-right ANSI gradient colors each character: white-hot leading edge trails off to near-black; the gradient sweeps continuously across the full scroll canvas
|
- The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
|
||||||
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
||||||
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
||||||
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
||||||
@@ -74,7 +90,7 @@ engine/
|
|||||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
mic.py MicMonitor — standalone, graceful fallback
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
scroll.py stream() frame loop + message rendering
|
scroll.py stream() frame loop + message rendering
|
||||||
app.py main(), boot sequence, signal handler
|
app.py main(), font picker TUI, boot sequence, signal handler
|
||||||
```
|
```
|
||||||
|
|
||||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||||
@@ -138,4 +154,4 @@ msg = poller.get_active_message() # returns (title, body, timestamp) or None
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*macOS only (system font paths hardcoded). Python 3.9+.*
|
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.9+.*
|
||||||
|
|||||||
209
engine/app.py
209
engine/app.py
@@ -3,11 +3,14 @@ Application orchestrator — boot sequence, signal handling, main loop wiring.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
import signal
|
import signal
|
||||||
import atexit
|
import atexit
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
|
||||||
from engine import config
|
from engine import config, render
|
||||||
from engine.terminal import (
|
from engine.terminal import (
|
||||||
RST, G_HI, G_MID, G_DIM, W_DIM, W_GHOST, CLR, CURSOR_OFF, CURSOR_ON, tw,
|
RST, G_HI, G_MID, G_DIM, W_DIM, W_GHOST, CLR, CURSOR_OFF, CURSOR_ON, tw,
|
||||||
slow_print, boot_ln,
|
slow_print, boot_ln,
|
||||||
@@ -26,6 +29,208 @@ TITLE = [
|
|||||||
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
|
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _read_picker_key():
|
||||||
|
ch = sys.stdin.read(1)
|
||||||
|
if ch == "\x03":
|
||||||
|
return "interrupt"
|
||||||
|
if ch in ("\r", "\n"):
|
||||||
|
return "enter"
|
||||||
|
if ch == "\x1b":
|
||||||
|
c1 = sys.stdin.read(1)
|
||||||
|
if c1 != "[":
|
||||||
|
return None
|
||||||
|
c2 = sys.stdin.read(1)
|
||||||
|
if c2 == "A":
|
||||||
|
return "up"
|
||||||
|
if c2 == "B":
|
||||||
|
return "down"
|
||||||
|
return None
|
||||||
|
if ch in ("k", "K"):
|
||||||
|
return "up"
|
||||||
|
if ch in ("j", "J"):
|
||||||
|
return "down"
|
||||||
|
if ch in ("q", "Q"):
|
||||||
|
return "enter"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _normalize_preview_rows(rows):
|
||||||
|
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||||
|
non_empty = [r for r in rows if r.strip()]
|
||||||
|
if not non_empty:
|
||||||
|
return [""]
|
||||||
|
left_pad = min(len(r) - len(r.lstrip(" ")) for r in non_empty)
|
||||||
|
out = []
|
||||||
|
for row in rows:
|
||||||
|
if left_pad < len(row):
|
||||||
|
out.append(row[left_pad:].rstrip())
|
||||||
|
else:
|
||||||
|
out.append(row.rstrip())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_font_picker(faces, selected):
|
||||||
|
w = tw()
|
||||||
|
h = 24
|
||||||
|
try:
|
||||||
|
h = os.get_terminal_size().lines
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
max_preview_w = max(24, w - 8)
|
||||||
|
header_h = 6
|
||||||
|
footer_h = 3
|
||||||
|
preview_h = max(4, min(config.RENDER_H + 2, max(4, h // 2)))
|
||||||
|
visible = max(1, h - header_h - preview_h - footer_h)
|
||||||
|
top = max(0, selected - (visible // 2))
|
||||||
|
bottom = min(len(faces), top + visible)
|
||||||
|
top = max(0, bottom - visible)
|
||||||
|
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}FONT PICKER{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print(f" {W_DIM}{config.FONT_DIR[:max_preview_w]}{RST}")
|
||||||
|
print(f" {W_GHOST}↑/↓ move · Enter select · q accept current{RST}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for pos in range(top, bottom):
|
||||||
|
face = faces[pos]
|
||||||
|
active = pos == selected
|
||||||
|
pointer = "▶" if active else " "
|
||||||
|
color = G_HI if active else W_DIM
|
||||||
|
print(f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}")
|
||||||
|
|
||||||
|
if top > 0:
|
||||||
|
print(f" {W_GHOST}… {top} above{RST}")
|
||||||
|
if bottom < len(faces):
|
||||||
|
print(f" {W_GHOST}… {len(faces) - bottom} below{RST}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||||
|
print(
|
||||||
|
f" {W_DIM}Preview: {faces[selected]['name']} · {faces[selected]['file_name']}{RST}"
|
||||||
|
)
|
||||||
|
preview_rows = faces[selected]["preview_rows"][:preview_h]
|
||||||
|
for row in preview_rows:
|
||||||
|
shown = row[:max_preview_w]
|
||||||
|
print(f" {shown}")
|
||||||
|
|
||||||
|
def pick_font_face():
|
||||||
|
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||||
|
if not config.FONT_PICKER:
|
||||||
|
return
|
||||||
|
|
||||||
|
font_files = config.list_repo_font_files()
|
||||||
|
if not font_files:
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}FONT PICKER{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
print(f" {G_DIM}> no .otf/.ttf/.ttc files found in: {config.FONT_DIR}{RST}")
|
||||||
|
print(f" {W_GHOST}> add font files to the fonts folder, then rerun{RST}")
|
||||||
|
time.sleep(1.8)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
prepared = []
|
||||||
|
for font_path in font_files:
|
||||||
|
try:
|
||||||
|
faces = render.list_font_faces(font_path, max_faces=64)
|
||||||
|
except Exception:
|
||||||
|
fallback = os.path.splitext(os.path.basename(font_path))[0]
|
||||||
|
faces = [{"index": 0, "name": fallback}]
|
||||||
|
for face in faces:
|
||||||
|
idx = face["index"]
|
||||||
|
name = face["name"]
|
||||||
|
file_name = os.path.basename(font_path)
|
||||||
|
try:
|
||||||
|
fnt = render.load_font_face(font_path, idx)
|
||||||
|
rows = _normalize_preview_rows(render.render_line(name, fnt))
|
||||||
|
except Exception:
|
||||||
|
rows = ["(preview unavailable)"]
|
||||||
|
prepared.append(
|
||||||
|
{
|
||||||
|
"font_path": font_path,
|
||||||
|
"font_index": idx,
|
||||||
|
"name": name,
|
||||||
|
"file_name": file_name,
|
||||||
|
"preview_rows": rows,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not prepared:
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
print(f" {G_HI}FONT PICKER{RST}")
|
||||||
|
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||||
|
print(f" {G_DIM}> no readable font faces found in: {config.FONT_DIR}{RST}")
|
||||||
|
time.sleep(1.8)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def _same_path(a, b):
|
||||||
|
try:
|
||||||
|
return os.path.samefile(a, b)
|
||||||
|
except Exception:
|
||||||
|
return os.path.abspath(a) == os.path.abspath(b)
|
||||||
|
|
||||||
|
selected = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, f in enumerate(prepared)
|
||||||
|
if _same_path(f["font_path"], config.FONT_PATH)
|
||||||
|
and f["font_index"] == config.FONT_INDEX
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
selected_font = prepared[selected]
|
||||||
|
config.set_font_selection(
|
||||||
|
font_path=selected_font["font_path"],
|
||||||
|
font_index=selected_font["font_index"],
|
||||||
|
)
|
||||||
|
render.clear_font_cache()
|
||||||
|
print(
|
||||||
|
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
||||||
|
)
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
return
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_font_picker(prepared, selected)
|
||||||
|
key = _read_picker_key()
|
||||||
|
if key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(prepared) - 1, selected + 1)
|
||||||
|
elif key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
selected_font = prepared[selected]
|
||||||
|
config.set_font_selection(
|
||||||
|
font_path=selected_font["font_path"],
|
||||||
|
font_index=selected_font["font_index"],
|
||||||
|
)
|
||||||
|
render.clear_font_cache()
|
||||||
|
print(f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}")
|
||||||
|
time.sleep(0.8)
|
||||||
|
print(CLR, end="")
|
||||||
|
print(CURSOR_OFF, end="")
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||||
@@ -40,6 +245,8 @@ def main():
|
|||||||
w = tw()
|
w = tw()
|
||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
|
pick_font_face()
|
||||||
|
w = tw()
|
||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,53 @@ Configuration constants, CLI flags, and glyph tables.
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
||||||
|
|
||||||
|
|
||||||
|
def _arg_value(flag):
|
||||||
|
"""Get value following a CLI flag, if present."""
|
||||||
|
if flag not in sys.argv:
|
||||||
|
return None
|
||||||
|
i = sys.argv.index(flag)
|
||||||
|
return sys.argv[i + 1] if i + 1 < len(sys.argv) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _arg_int(flag, default):
|
||||||
|
"""Get int CLI argument with safe fallback."""
|
||||||
|
raw = _arg_value(flag)
|
||||||
|
if raw is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_font_path(raw_path):
|
||||||
|
"""Resolve font path; relative paths are anchored to repo root."""
|
||||||
|
p = Path(raw_path).expanduser()
|
||||||
|
if p.is_absolute():
|
||||||
|
return str(p)
|
||||||
|
return str((_REPO_ROOT / p).resolve())
|
||||||
|
|
||||||
|
|
||||||
|
def _list_font_files(font_dir):
|
||||||
|
"""List supported font files within a font directory."""
|
||||||
|
font_root = Path(font_dir)
|
||||||
|
if not font_root.exists() or not font_root.is_dir():
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
str(path.resolve())
|
||||||
|
for path in sorted(font_root.iterdir())
|
||||||
|
if path.is_file() and path.suffix.lower() in _FONT_EXTENSIONS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def list_repo_font_files():
|
||||||
|
"""Public helper for discovering repository font files."""
|
||||||
|
return _list_font_files(FONT_DIR)
|
||||||
# ─── RUNTIME ──────────────────────────────────────────────
|
# ─── RUNTIME ──────────────────────────────────────────────
|
||||||
HEADLINE_LIMIT = 1000
|
HEADLINE_LIMIT = 1000
|
||||||
FEED_TIMEOUT = 10
|
FEED_TIMEOUT = 10
|
||||||
@@ -17,7 +64,16 @@ NTFY_POLL_INTERVAL = 15 # seconds between polls
|
|||||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||||
|
|
||||||
# ─── FONT RENDERING ──────────────────────────────────────
|
# ─── FONT RENDERING ──────────────────────────────────────
|
||||||
FONT_PATH = "/Users/genejohnson/Documents/CS Bishop Drawn/CSBishopDrawn-Italic.otf"
|
FONT_DIR = _resolve_font_path(_arg_value('--font-dir') or "fonts")
|
||||||
|
_FONT_FILE_ARG = _arg_value('--font-file')
|
||||||
|
_FONT_FILES = _list_font_files(FONT_DIR)
|
||||||
|
FONT_PATH = (
|
||||||
|
_resolve_font_path(_FONT_FILE_ARG)
|
||||||
|
if _FONT_FILE_ARG
|
||||||
|
else (_FONT_FILES[0] if _FONT_FILES else "")
|
||||||
|
)
|
||||||
|
FONT_INDEX = max(0, _arg_int('--font-index', 0))
|
||||||
|
FONT_PICKER = '--no-font-picker' not in sys.argv
|
||||||
FONT_SZ = 60
|
FONT_SZ = 60
|
||||||
RENDER_H = 8 # terminal rows per rendered text line
|
RENDER_H = 8 # terminal rows per rendered text line
|
||||||
|
|
||||||
@@ -33,3 +89,12 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full swee
|
|||||||
# ─── GLYPHS ───────────────────────────────────────────────
|
# ─── GLYPHS ───────────────────────────────────────────────
|
||||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
|
|
||||||
|
def set_font_selection(font_path=None, font_index=None):
|
||||||
|
"""Set runtime primary font selection."""
|
||||||
|
global FONT_PATH, FONT_INDEX
|
||||||
|
if font_path is not None:
|
||||||
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
|
if font_index is not None:
|
||||||
|
FONT_INDEX = max(0, int(font_index))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Depends on: config, terminal, sources, translate.
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import random
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
@@ -31,18 +32,73 @@ GRAD_COLS = [
|
|||||||
"\033[2;38;5;235m", # near black
|
"\033[2;38;5;235m", # near black
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
||||||
|
MSG_GRAD_COLS = [
|
||||||
|
"\033[1;38;5;231m", # white
|
||||||
|
"\033[1;38;5;225m", # pale pink-white
|
||||||
|
"\033[38;5;219m", # bright pink
|
||||||
|
"\033[38;5;213m", # hot pink
|
||||||
|
"\033[38;5;207m", # magenta
|
||||||
|
"\033[38;5;201m", # bright magenta
|
||||||
|
"\033[38;5;165m", # orchid-red
|
||||||
|
"\033[38;5;161m", # ruby-magenta
|
||||||
|
"\033[38;5;125m", # dark magenta
|
||||||
|
"\033[38;5;89m", # deep maroon-magenta
|
||||||
|
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||||
|
"\033[2;38;5;235m", # near black
|
||||||
|
]
|
||||||
|
|
||||||
# ─── FONT LOADING ─────────────────────────────────────────
|
# ─── FONT LOADING ─────────────────────────────────────────
|
||||||
_FONT_OBJ = None
|
_FONT_OBJ = None
|
||||||
|
_FONT_OBJ_KEY = None
|
||||||
_FONT_CACHE = {}
|
_FONT_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
def font():
|
def font():
|
||||||
"""Lazy-load the primary OTF font."""
|
"""Lazy-load the primary OTF font (path + face index aware)."""
|
||||||
global _FONT_OBJ
|
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||||
if _FONT_OBJ is None:
|
if not config.FONT_PATH:
|
||||||
_FONT_OBJ = ImageFont.truetype(config.FONT_PATH, config.FONT_SZ)
|
raise FileNotFoundError(
|
||||||
|
f"No primary font selected. Add .otf/.ttf/.ttc files to {config.FONT_DIR}."
|
||||||
|
)
|
||||||
|
key = (config.FONT_PATH, config.FONT_INDEX, config.FONT_SZ)
|
||||||
|
if _FONT_OBJ is None or _FONT_OBJ_KEY != key:
|
||||||
|
_FONT_OBJ = ImageFont.truetype(
|
||||||
|
config.FONT_PATH, config.FONT_SZ, index=config.FONT_INDEX
|
||||||
|
)
|
||||||
|
_FONT_OBJ_KEY = key
|
||||||
return _FONT_OBJ
|
return _FONT_OBJ
|
||||||
|
|
||||||
|
def clear_font_cache():
|
||||||
|
"""Reset cached font objects after changing primary font selection."""
|
||||||
|
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||||
|
_FONT_OBJ = None
|
||||||
|
_FONT_OBJ_KEY = None
|
||||||
|
|
||||||
|
|
||||||
|
def load_font_face(font_path, font_index=0, size=None):
|
||||||
|
"""Load a specific face from a font file or collection."""
|
||||||
|
font_size = size or config.FONT_SZ
|
||||||
|
return ImageFont.truetype(font_path, font_size, index=font_index)
|
||||||
|
|
||||||
|
|
||||||
|
def list_font_faces(font_path, max_faces=64):
|
||||||
|
"""Return discoverable face indexes + display names from a font file."""
|
||||||
|
faces = []
|
||||||
|
for idx in range(max_faces):
|
||||||
|
try:
|
||||||
|
fnt = load_font_face(font_path, idx)
|
||||||
|
except Exception:
|
||||||
|
if idx == 0:
|
||||||
|
raise
|
||||||
|
break
|
||||||
|
family, style = fnt.getname()
|
||||||
|
display = f"{family} {style}".strip()
|
||||||
|
if not display:
|
||||||
|
display = f"{Path(font_path).stem} [{idx}]"
|
||||||
|
faces.append({"index": idx, "name": display})
|
||||||
|
return faces
|
||||||
|
|
||||||
|
|
||||||
def font_for_lang(lang=None):
|
def font_for_lang(lang=None):
|
||||||
"""Get appropriate font for a language."""
|
"""Get appropriate font for a language."""
|
||||||
@@ -132,9 +188,10 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows, offset=0.0):
|
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||||
n = len(GRAD_COLS)
|
cols = grad_cols or GRAD_COLS
|
||||||
|
n = len(cols)
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
out = []
|
out = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
@@ -148,11 +205,16 @@ def lr_gradient(rows, offset=0.0):
|
|||||||
else:
|
else:
|
||||||
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||||
idx = min(round(shifted * (n - 1)), n - 1)
|
idx = min(round(shifted * (n - 1)), n - 1)
|
||||||
buf.append(f"{GRAD_COLS[idx]}{ch}\033[0m")
|
buf.append(f"{cols[idx]}{ch}{RST}")
|
||||||
out.append("".join(buf))
|
out.append("".join(buf))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def lr_gradient_opposite(rows, offset=0.0):
|
||||||
|
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||||
|
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
|
||||||
|
|
||||||
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||||
def make_block(title, src, ts, w):
|
def make_block(title, src, ts, w):
|
||||||
"""Render a headline into a content block with color."""
|
"""Render a headline into a content block with color."""
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.terminal import RST, W_COOL, CLR, tw, th
|
from engine.terminal import RST, W_COOL, CLR, tw, th
|
||||||
from engine.render import big_wrap, lr_gradient, make_block
|
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite, make_block
|
||||||
from engine.effects import noise, glitch_bar, fade_line, vis_trunc, next_headline, firehose_line
|
from engine.effects import noise, glitch_bar, fade_line, vis_trunc, next_headline, firehose_line
|
||||||
|
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ def stream(items, ntfy_poller, mic_monitor):
|
|||||||
_msg_cache = (cache_key, msg_rows)
|
_msg_cache = (cache_key, msg_rows)
|
||||||
else:
|
else:
|
||||||
msg_rows = _msg_cache[1]
|
msg_rows = _msg_cache[1]
|
||||||
msg_rows = lr_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
msg_rows = lr_gradient_opposite(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
||||||
# Layout: rendered text + meta + border
|
# Layout: rendered text + meta + border
|
||||||
elapsed_s = int(time.monotonic() - m_ts)
|
elapsed_s = int(time.monotonic() - m_ts)
|
||||||
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||||
|
|||||||
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
BIN
fonts/AgorTechnoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/AlphatronDemo-Display.otf
Normal file
BIN
fonts/AlphatronDemo-Display.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Italic.otf
Normal file
BIN
fonts/CSBishopDrawn-Italic.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Italic.ttf
Normal file
BIN
fonts/CSBishopDrawn-Italic.ttf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Regular.otf
Normal file
BIN
fonts/CSBishopDrawn-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/CSBishopDrawn-Regular.ttf
Normal file
BIN
fonts/CSBishopDrawn-Regular.ttf
Normal file
Binary file not shown.
BIN
fonts/Corptic DEMO.otf
Normal file
BIN
fonts/Corptic DEMO.otf
Normal file
Binary file not shown.
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
BIN
fonts/CubaTechnologyDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/CyberformDemo-Oblique.otf
Normal file
BIN
fonts/CyberformDemo-Oblique.otf
Normal file
Binary file not shown.
BIN
fonts/CyberformDemo.otf
Normal file
BIN
fonts/CyberformDemo.otf
Normal file
Binary file not shown.
BIN
fonts/Eyekons.otf
Normal file
BIN
fonts/Eyekons.otf
Normal file
Binary file not shown.
BIN
fonts/KATA Mac.otf
Normal file
BIN
fonts/KATA Mac.otf
Normal file
Binary file not shown.
BIN
fonts/KATA Mac.ttf
Normal file
BIN
fonts/KATA Mac.ttf
Normal file
Binary file not shown.
BIN
fonts/KATA.otf
Normal file
BIN
fonts/KATA.otf
Normal file
Binary file not shown.
BIN
fonts/KATA.ttf
Normal file
BIN
fonts/KATA.ttf
Normal file
Binary file not shown.
BIN
fonts/Microbots Demo.otf
Normal file
BIN
fonts/Microbots Demo.otf
Normal file
Binary file not shown.
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
BIN
fonts/ModernSpaceDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Neoform-Demo.otf
Normal file
BIN
fonts/Neoform-Demo.otf
Normal file
Binary file not shown.
BIN
fonts/Pixel Sparta.otf
Normal file
BIN
fonts/Pixel Sparta.otf
Normal file
Binary file not shown.
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
BIN
fonts/RaceHugoDemo-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Resond-Regular.otf
Normal file
BIN
fonts/Resond-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/Robocops-Demo.otf
Normal file
BIN
fonts/Robocops-Demo.otf
Normal file
Binary file not shown.
BIN
fonts/Synthetix.otf
Normal file
BIN
fonts/Synthetix.otf
Normal file
Binary file not shown.
BIN
fonts/Xeonic.ttf
Normal file
BIN
fonts/Xeonic.ttf
Normal file
Binary file not shown.
Reference in New Issue
Block a user