Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9eeb817dca | |||
| ac80ab23cc | |||
| 516123345e | |||
| 11226872a1 | |||
| e6826c884c | |||
| 0740e34293 | |||
| 1e99d70387 | |||
| 7098b2f5aa | |||
| 9140bfd32b | |||
| c49c0aab33 | |||
| 66c13b5829 | |||
| 089c8ed66a | |||
| 086214f05e | |||
| 0f762475b5 | |||
| b00b612da0 | |||
| 39dab4b22b | |||
| 47f17e12ef | |||
| 851c4a77b4 | |||
| cdbb6dfd1c |
@@ -2,54 +2,113 @@
|
||||
|
||||
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
||||
|
||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages.
|
||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python3 mainline.py # news stream
|
||||
python3 mainline.py --poetry # literary consciousness mode
|
||||
python3 mainline.py -p # same
|
||||
python3 mainline.py # news stream
|
||||
python3 mainline.py --poetry # literary consciousness mode
|
||||
python3 mainline.py -p # same
|
||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
||||
```
|
||||
|
||||
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately.
|
||||
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache.
|
||||
|
||||
---
|
||||
|
||||
## Config
|
||||
|
||||
At the top of `mainline.py`:
|
||||
All constants live in `engine/config.py`:
|
||||
|
||||
| Constant | Default | What it does |
|
||||
|---|---|---|
|
||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||
| `_FONT_PATH` | hardcoded path | Path to your OTF/TTF display font |
|
||||
| `_FONT_SZ` | `60` | Font render size (affects block density) |
|
||||
| `_RENDER_H` | `8` | Terminal rows per headline line |
|
||||
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files used by the font picker |
|
||||
| `FONT_PATH` | first supported font in `fonts/` | Active display font file selected at startup |
|
||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
||||
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
|
||||
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON endpoint to poll |
|
||||
| `NTFY_POLL_INTERVAL` | `15` | Seconds between ntfy polls |
|
||||
| `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.
|
||||
**Font:** Put your `.otf`, `.ttf`, or `.ttc` files in `fonts/`. Startup opens the font picker from that folder and applies your selected font before streaming.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
- Feeds are fetched and filtered on startup (sports and vapid content stripped)
|
||||
- Headlines are rasterized via Pillow 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
|
||||
- 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
|
||||
- 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
|
||||
- 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 viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
||||
- An ntfy.sh poller runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
|
||||
|
||||
```
|
||||
engine/
|
||||
config.py constants, CLI flags, glyph tables
|
||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||
filter.py HTML stripping, content filter
|
||||
translate.py Google Translate wrapper + region detection
|
||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||
effects.py noise, glitch_bar, fade, firehose
|
||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||
mic.py MicMonitor — standalone, graceful fallback
|
||||
scroll.py stream() frame loop + message rendering
|
||||
app.py main(), boot sequence, signal handler
|
||||
```
|
||||
|
||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||
|
||||
---
|
||||
|
||||
## Feeds
|
||||
|
||||
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap in `FEEDS`.
|
||||
~25 sources across four categories: **Science & Technology**, **Economics & Business**, **World & Politics**, **Culture & Ideas**. Add or swap feeds in `engine/sources.py` → `FEEDS`.
|
||||
|
||||
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson.
|
||||
**Poetry mode** pulls from Project Gutenberg: Whitman, Dickinson, Thoreau, Emerson. Sources are in `engine/sources.py` → `POETRY_SOURCES`.
|
||||
|
||||
---
|
||||
|
||||
## ntfy.sh Integration
|
||||
|
||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||
|
||||
To push a message:
|
||||
|
||||
```bash
|
||||
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
||||
```
|
||||
|
||||
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic. The `NtfyPoller` class is fully standalone and can be reused by other visualizers:
|
||||
|
||||
```python
|
||||
from engine.ntfy import NtfyPoller
|
||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||
poller.start()
|
||||
# in render loop:
|
||||
msg = poller.get_active_message() # returns (title, body, timestamp) or None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +121,6 @@ At the top of `mainline.py`:
|
||||
|
||||
### Graphics
|
||||
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
||||
- **Animated gradient** — shift the white-hot leading edge left/right each frame for a pulse/comet effect
|
||||
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
||||
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
||||
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
||||
@@ -75,6 +133,10 @@ At the top of `mainline.py`:
|
||||
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
|
||||
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
|
||||
|
||||
### Extensibility
|
||||
- **serve.py** — HTTP server that imports `engine.render` and `engine.fetch` directly to stream 1-bit bitmaps to an ESP32 display
|
||||
- **Rust port** — `ntfy.py` and `render.py` are the natural first targets; clear module boundaries make incremental porting viable
|
||||
|
||||
---
|
||||
|
||||
*macOS only (system font paths hardcoded). Python 3.9+.*
|
||||
|
||||
+208
-1
@@ -3,11 +3,14 @@ Application orchestrator — boot sequence, signal handling, main loop wiring.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import signal
|
||||
import atexit
|
||||
import termios
|
||||
import tty
|
||||
|
||||
from engine import config
|
||||
from engine import config, render
|
||||
from engine.terminal import (
|
||||
RST, G_HI, G_MID, G_DIM, W_DIM, W_GHOST, CLR, CURSOR_OFF, CURSOR_ON, tw,
|
||||
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():
|
||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||
@@ -40,6 +245,8 @@ def main():
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
pick_font_face()
|
||||
w = tw()
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
|
||||
+66
-1
@@ -4,6 +4,53 @@ Configuration constants, CLI flags, and glyph tables.
|
||||
|
||||
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 ──────────────────────────────────────────────
|
||||
HEADLINE_LIMIT = 1000
|
||||
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
|
||||
|
||||
# ─── 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
|
||||
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 ───────────────────────────────────────────────
|
||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||
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))
|
||||
|
||||
+69
-7
@@ -6,6 +6,7 @@ Depends on: config, terminal, sources, translate.
|
||||
|
||||
import re
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -31,18 +32,73 @@ GRAD_COLS = [
|
||||
"\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_OBJ = None
|
||||
_FONT_OBJ_KEY = None
|
||||
_FONT_CACHE = {}
|
||||
|
||||
|
||||
def font():
|
||||
"""Lazy-load the primary OTF font."""
|
||||
global _FONT_OBJ
|
||||
if _FONT_OBJ is None:
|
||||
_FONT_OBJ = ImageFont.truetype(config.FONT_PATH, config.FONT_SZ)
|
||||
"""Lazy-load the primary OTF font (path + face index aware)."""
|
||||
global _FONT_OBJ, _FONT_OBJ_KEY
|
||||
if not config.FONT_PATH:
|
||||
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
|
||||
|
||||
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):
|
||||
"""Get appropriate font for a language."""
|
||||
@@ -132,9 +188,10 @@ def big_wrap(text, max_w, fnt=None):
|
||||
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."""
|
||||
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)
|
||||
out = []
|
||||
for row in rows:
|
||||
@@ -148,11 +205,16 @@ def lr_gradient(rows, offset=0.0):
|
||||
else:
|
||||
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||
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))
|
||||
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 ─────────────────────────────
|
||||
def make_block(title, src, ts, w):
|
||||
"""Render a headline into a content block with color."""
|
||||
|
||||
+61
-54
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Scroll engine — the main frame loop with headline rendering and message display.
|
||||
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||
Depends on: config, terminal, render, effects, ntfy, mic.
|
||||
"""
|
||||
|
||||
@@ -11,12 +11,12 @@ from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
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
|
||||
|
||||
|
||||
def stream(items, ntfy_poller, mic_monitor):
|
||||
"""Main rendering loop. Scrolls headlines, shows ntfy messages, applies effects."""
|
||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||
random.shuffle(items)
|
||||
pool = list(items)
|
||||
seen = set()
|
||||
@@ -28,16 +28,21 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
sh = h - fh # scroll zone height
|
||||
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
|
||||
GAP = 3 # blank rows between headlines
|
||||
scroll_interval = config.SCROLL_DUR / (sh + 15) * 2
|
||||
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
|
||||
|
||||
# active blocks: (content_rows, color, canvas_y, meta_idx)
|
||||
# Taxonomy:
|
||||
# - message: centered ntfy overlay panel
|
||||
# - ticker: large headline text content
|
||||
# - scroll: upward camera motion applied to ticker content
|
||||
# - firehose: fixed carriage-return style strip pinned at bottom
|
||||
# Active ticker blocks: (content_rows, color, canvas_y, meta_idx)
|
||||
active = []
|
||||
cam = 0 # viewport top in virtual canvas coords
|
||||
next_y = sh # canvas-y where next block starts (off-screen bottom)
|
||||
scroll_cam = 0 # viewport top in virtual canvas coords
|
||||
ticker_next_y = ticker_view_h # canvas-y where next block starts (off-screen bottom)
|
||||
noise_cache = {}
|
||||
scroll_accum = 0.0
|
||||
scroll_motion_accum = 0.0
|
||||
|
||||
def _noise_at(cy):
|
||||
if cy not in noise_cache:
|
||||
@@ -53,16 +58,17 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
t0 = time.monotonic()
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
sh = h - fh
|
||||
ticker_view_h = h - fh
|
||||
|
||||
# ── Check for ntfy message ────────────────────────
|
||||
msg_h = 0 # rows consumed by message zone at top
|
||||
msg_h = 0
|
||||
msg_overlay = []
|
||||
msg = ntfy_poller.get_active_message()
|
||||
|
||||
buf = []
|
||||
if msg is not None:
|
||||
m_title, m_body, m_ts = msg
|
||||
# ── Message zone: pinned to top, scroll continues below ──
|
||||
# ── Message overlay: centered in the viewport ──
|
||||
display_text = m_body or m_title or "(empty)"
|
||||
display_text = re.sub(r"\s+", " ", display_text.upper())
|
||||
cache_key = (display_text, w)
|
||||
@@ -71,15 +77,17 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
_msg_cache = (cache_key, msg_rows)
|
||||
else:
|
||||
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
|
||||
elapsed_s = int(time.monotonic() - m_ts)
|
||||
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||
ts_str = datetime.now().strftime("%H:%M:%S")
|
||||
panel_h = len(msg_rows) + 2 # meta + border
|
||||
panel_top = max(0, (h - panel_h) // 2)
|
||||
row_idx = 0
|
||||
for mr in msg_rows:
|
||||
ln = vis_trunc(mr, w)
|
||||
buf.append(f"\033[{row_idx+1};1H {ln}{RST}\033[K")
|
||||
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}{RST}\033[K")
|
||||
row_idx += 1
|
||||
# Meta line: title (if distinct) + source + countdown
|
||||
meta_parts = []
|
||||
@@ -87,49 +95,46 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
meta_parts.append(m_title)
|
||||
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
|
||||
meta = " " + " \u00b7 ".join(meta_parts) if len(meta_parts) > 1 else " " + meta_parts[0]
|
||||
buf.append(f"\033[{row_idx+1};1H{MSG_META}{meta}{RST}\033[K")
|
||||
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K")
|
||||
row_idx += 1
|
||||
# Border — constant boundary between message and scroll
|
||||
# Border — constant boundary under message panel
|
||||
bar = "\u2500" * (w - 4)
|
||||
buf.append(f"\033[{row_idx+1};1H {MSG_BORDER}{bar}{RST}\033[K")
|
||||
row_idx += 1
|
||||
msg_h = row_idx
|
||||
msg_overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K")
|
||||
|
||||
# Effective scroll zone: below message, above firehose
|
||||
scroll_h = sh - msg_h
|
||||
# Ticker draws above the fixed firehose strip; message is a centered overlay.
|
||||
ticker_h = ticker_view_h - msg_h
|
||||
|
||||
# ── Scroll: headline rendering (always runs) ──────
|
||||
# Advance scroll on schedule
|
||||
scroll_accum += config.FRAME_DT
|
||||
while scroll_accum >= scroll_interval:
|
||||
scroll_accum -= scroll_interval
|
||||
cam += 1
|
||||
# ── Ticker content + scroll motion (always runs) ──
|
||||
scroll_motion_accum += config.FRAME_DT
|
||||
while scroll_motion_accum >= scroll_step_interval:
|
||||
scroll_motion_accum -= scroll_step_interval
|
||||
scroll_cam += 1
|
||||
|
||||
# Enqueue new headlines when room at the bottom
|
||||
while next_y < cam + sh + 10 and queued < config.HEADLINE_LIMIT:
|
||||
while ticker_next_y < scroll_cam + ticker_view_h + 10 and queued < config.HEADLINE_LIMIT:
|
||||
t, src, ts = next_headline(pool, items, seen)
|
||||
content, hc, midx = make_block(t, src, ts, w)
|
||||
active.append((content, hc, next_y, midx))
|
||||
next_y += len(content) + GAP
|
||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||
ticker_next_y += len(ticker_content) + GAP
|
||||
queued += 1
|
||||
|
||||
# Prune off-screen blocks and stale noise
|
||||
active = [(c, hc, by, mi) for c, hc, by, mi in active
|
||||
if by + len(c) > cam]
|
||||
if by + len(c) > scroll_cam]
|
||||
for k in list(noise_cache):
|
||||
if k < cam:
|
||||
if k < scroll_cam:
|
||||
del noise_cache[k]
|
||||
|
||||
# Draw scroll zone (below message zone, above firehose)
|
||||
top_zone = max(1, int(scroll_h * 0.25))
|
||||
bot_zone = max(1, int(scroll_h * 0.10))
|
||||
# Draw ticker zone (above fixed firehose strip)
|
||||
top_zone = max(1, int(ticker_h * 0.25))
|
||||
bot_zone = max(1, int(ticker_h * 0.10))
|
||||
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||
scroll_buf_start = len(buf) # track where scroll rows start in buf
|
||||
for r in range(scroll_h):
|
||||
scr_row = msg_h + r + 1 # 1-indexed ANSI screen row
|
||||
cy = cam + r
|
||||
ticker_buf_start = len(buf) # track where ticker rows start in buf
|
||||
for r in range(ticker_h):
|
||||
scr_row = r + 1 # 1-indexed ANSI screen row
|
||||
cy = scroll_cam + r
|
||||
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||
bot_f = min(1.0, (scroll_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
|
||||
bot_f = min(1.0, (ticker_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
|
||||
row_fade = min(top_f, bot_f)
|
||||
drawn = False
|
||||
for content, hc, by, midx in active:
|
||||
@@ -160,22 +165,24 @@ def stream(items, ntfy_poller, mic_monitor):
|
||||
else:
|
||||
buf.append(f"\033[{scr_row};1H\033[K")
|
||||
|
||||
# Draw firehose zone
|
||||
if config.FIREHOSE and fh > 0:
|
||||
for fr in range(fh):
|
||||
fline = firehose_line(items, w)
|
||||
buf.append(f"\033[{sh + fr + 1};1H{fline}\033[K")
|
||||
|
||||
# Glitch — base rate + mic-reactive spikes (scroll zone only)
|
||||
# Glitch — base rate + mic-reactive spikes (ticker zone only)
|
||||
mic_excess = mic_monitor.excess
|
||||
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
||||
n_hits = 4 + int(mic_excess / 2)
|
||||
scroll_buf_len = len(buf) - scroll_buf_start
|
||||
if random.random() < glitch_prob and scroll_buf_len > 0:
|
||||
for _ in range(min(n_hits, scroll_buf_len)):
|
||||
gi = random.randint(0, scroll_buf_len - 1)
|
||||
scr_row = msg_h + gi + 1
|
||||
buf[scroll_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
||||
ticker_buf_len = len(buf) - ticker_buf_start
|
||||
if random.random() < glitch_prob and ticker_buf_len > 0:
|
||||
for _ in range(min(n_hits, ticker_buf_len)):
|
||||
gi = random.randint(0, ticker_buf_len - 1)
|
||||
scr_row = gi + 1
|
||||
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
||||
|
||||
if config.FIREHOSE and fh > 0:
|
||||
for fr in range(fh):
|
||||
scr_row = h - fh + fr + 1
|
||||
fline = firehose_line(items, w)
|
||||
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||
if msg_overlay:
|
||||
buf.extend(msg_overlay)
|
||||
|
||||
sys.stdout.buffer.write("".join(buf).encode())
|
||||
sys.stdout.flush()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user