17 Commits

Author SHA1 Message Date
42aa6f16cc Merge pull request 'feat/figment: periodic SVG glyph overlays with CLI flag' (#34) from feat/figment into main
Reviewed-on: #34
2026-03-19 20:42:10 +00:00
a25b80d4a6 feat: Enable and configure figment mode via new CLI flags, update documentation, and improve Cairo library detection on macOS. 2026-03-19 13:41:40 -07:00
3a1aa975d1 docs: update README for figment mode
- Add figment mode to intro paragraph
- New Figment Mode section: enabling, assets, trigger protocol, deps
- Architecture table updated with engine/figment_render.py,
  figment_trigger.py, and effects_plugins/ directory breakdown
- Dev setup: separate uv sync --extras entries (mic vs figment)
- Testing section: mentions figment test coverage and Cairo skip
- Roadmap: adds figment follow-up items (CLI flag, intensity wiring,
  ntfy trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:39:58 -07:00
d5e5f39404 test: add pytest.importorskip for cairosvg-dependent tests
Gracefully skips figment tests when system Cairo library is unavailable
instead of crashing with opaque OSError during test collection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
2bfd3a01da style: apply ruff lint fixes and formatting to figment modules
Fixes: unused imports, import sorting, unused variable, overly broad
exception type in test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
4cf316c280 feat(figment): integrate figment overlay into scroll loop
Wire render_figment_overlay() into stream() between the effects chain
and the ntfy message overlay, with optional cairosvg import guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
79d271c42b feat(figment): add render_figment_overlay() to layers.py
Implements phase-aware (REVEAL/HOLD/DISSOLVE) ANSI cursor-positioning overlay
renderer for figment glyphs, with deterministic shuffle seeding and gradient
coloring via _color_codes_to_ansi(). Includes 6 TDD tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
525af4bc46 feat(figment): add FigmentEffect plugin with state machine and timer
Implements the core figment plugin: timer-driven SVG selection, REVEAL →
HOLD → DISSOLVE state machine, trigger API, and get_figment_state() for
overlay rendering. process() is a deliberate no-op; scroll.py will call
get_figment_state() instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
085f150cb0 feat(figment): add SVG to half-block rasterization pipeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
0b6e2fae74 feat(figment): add trigger protocol and command types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
6864ad84c6 feat(figment): add test fixture SVG and FIGMENT_TRIGGER event type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
acb42ea140 build: add cairosvg optional dependency for figment mode
Also adds DYLD_LIBRARY_PATH to mise.toml for macOS Cairo discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
7014a9d5cd docs: add figment mode TDD implementation plan
8-task plan covering SVG rasterization, overlay rendering,
FigmentEffect plugin, trigger protocol, and scroll loop integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
2cc8dbfc02 docs: address spec review feedback for figment mode
- Rename FigmentPlugin to FigmentEffect (discovery convention)
- Define FigmentState dataclass and FigmentPhase enum
- Clarify chain exclusion (no-op process, not in chain order)
- Add isinstance() downcast for type-safe scroll.py access
- Use FigmentAction enum instead of string literals
- Add Error Handling section (missing deps, empty dir, resize)
- Add Goals, Out of Scope sections
- Split tests per module (test_figment_render, test_figment_trigger)
- Add FIGMENT_TRIGGER to modified files (events.py)
- Document timing formula (progress += FRAME_DT / phase_duration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
f1d5162488 docs: add figment mode design spec
Hybrid plugin + overlay architecture for periodic SVG glyph display
with theme-aware coloring and extensible input abstraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
9f61226779 Merge pull request 'docs/update-readme' (#33) from docs/update-readme into main
Reviewed-on: #33
2026-03-19 06:30:18 +00:00
9415e18679 docs: use david's most recent README from feature/vector_display branch 2026-03-18 23:29:02 -07:00

213
README.md
View File

@@ -11,18 +11,15 @@ A full-screen terminal news ticker that renders live global headlines in large O
- [Using](#using) - [Using](#using)
- [Run](#run) - [Run](#run)
- [Config](#config) - [Config](#config)
- [Display Modes](#display-modes)
- [Feeds](#feeds) - [Feeds](#feeds)
- [Fonts](#fonts) - [Fonts](#fonts)
- [Color Schemes](#color-schemes)
- [ntfy.sh](#ntfysh) - [ntfy.sh](#ntfysh)
- [Figment Mode](#figment-mode) - [Figment Mode](#figment-mode)
- [Command & Control](#command--control-cc)
- [Internals](#internals) - [Internals](#internals)
- [How it works](#how-it-works) - [How it works](#how-it-works)
- [Architecture](#architecture) - [Architecture](#architecture)
- [Extending](#extending)
- [NtfyPoller](#ntfypoller)
- [MicMonitor](#micmonitor)
- [Render pipeline](#render-pipeline)
- [Development](#development) - [Development](#development)
- [Setup](#setup) - [Setup](#setup)
- [Tasks](#tasks) - [Tasks](#tasks)
@@ -47,6 +44,8 @@ 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 --figment # enable periodic SVG glyph overlays python3 mainline.py --figment # enable periodic SVG glyph overlays
python3 mainline.py --figment-interval 30 # figment every 30 seconds (default: 60) python3 mainline.py --figment-interval 30 # figment every 30 seconds (default: 60)
python3 mainline.py --display websocket # web browser display only
python3 mainline.py --display both # terminal + web browser
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 --no-font-picker # skip interactive font picker
python3 mainline.py --font-file path.otf # use a specific font file python3 mainline.py --font-file path.otf # use a specific font file
@@ -60,7 +59,20 @@ Or with uv:
uv run mainline.py uv run mainline.py
``` ```
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. With uv, run `uv sync` or `uv sync --all-extras` (includes mic support) instead. First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
### Command & Control (C&C)
Control mainline remotely using `cmdline.py`:
```bash
uv run cmdline.py # Interactive TUI
uv run cmdline.py /effects list # List all effects
uv run cmdline.py /effects stats # Show performance stats
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
```
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
### Config ### Config
@@ -71,21 +83,33 @@ 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 |
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files | | `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_PATH` | first file in `FONT_DIR` | Active display font |
| `FONT_INDEX` | `0` | Face index within a font collection file | | `FONT_PICKER` | `True` | Show interactive font picker at boot |
| `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 |
| `SCROLL_DUR` | `5.625` | Seconds per headline | | `SCROLL_DUR` | `5.625` | Seconds per headline |
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) | | `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) | | `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) | | `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream | ### Display Modes
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
Mainline supports multiple display backends:
- **Terminal** (`--display terminal`): ANSI terminal output (default)
- **WebSocket** (`--display websocket`): Stream to web browser clients
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
- **Both** (`--display both`): Terminal + WebSocket simultaneously
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
### Feeds ### Feeds
@@ -95,30 +119,15 @@ All constants live in `engine/config.py`:
### Fonts ### Fonts
A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size. A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session. Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
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. To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
### Color Schemes
Mainline supports three color themes for the scrolling gradient: **Verdant Green**, **Molten Orange**, and **Violet Purple**. Each theme uses a precise color-opposite palette for ntfy message queue rendering (magenta, blue, and yellow respectively).
On startup, an interactive picker presents all available color schemes:
```
[1] Verdant Green (white-hot → deep green)
[2] Molten Orange (white-hot → deep orange)
[3] Violet Purple (white-hot → deep purple)
```
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selection applies only to the current session; you'll pick a fresh theme each run.
**Note:** The boot UI (title, status lines, font picker menu) uses a hardcoded green accent color for visual continuity. Only the scrolling headlines and incoming messages render in the selected theme gradient.
### ntfy.sh ### ntfy.sh
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. Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen.
To push a message: To push a message:
@@ -135,7 +144,7 @@ Figment mode periodically overlays a full-screen SVG glyph on the running ticker
**Enable it** with the `--figment` flag: **Enable it** with the `--figment` flag:
```bash ```bash
uv run mainline.py --figment # glyph every 60 seconds (default) uv run mainline.py --figment # glyph every 60 seconds (default)
uv run mainline.py --figment --figment-interval 30 # every 30 seconds uv run mainline.py --figment --figment-interval 30 # every 30 seconds
``` ```
@@ -174,24 +183,28 @@ uv sync --extra figment # adds cairosvg
- 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
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired - An ntfy.sh SSE stream runs in a background thread for messages and C&C commands; incoming messages interrupt the scroll and render full-screen until dismissed or expired
- Figment mode rasterizes SVGs via cairosvg → PIL → greyscale → half-block encode, then overlays them with ANSI cursor-positioning commands between the effect chain and the ntfy message layer - Figment mode rasterizes SVGs via cairosvg → PIL → greyscale → half-block encode, then overlays them with ANSI cursor-positioning commands between the effect chain and the ntfy message layer
### Architecture ### Architecture
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
``` ```
engine/ engine/
__init__.py package marker __init__.py package marker
app.py main(), font picker TUI, boot sequence, signal handler app.py main(), font picker TUI, boot sequence, C&C poller
config.py constants, CLI flags, glyph tables config.py constants, CLI flags, glyph tables
sources.py FEEDS, POETRY_SOURCES, language/script maps sources.py FEEDS, POETRY_SOURCES, language/script maps
terminal.py ANSI codes, tw/th, type_out, boot_ln terminal.py ANSI codes, tw/th, type_out, boot_ln
filter.py HTML stripping, content filter filter.py HTML stripping, content filter
translate.py Google Translate wrapper + region detection translate.py Google Translate wrapper + region detection
render.py OTF → half-block pipeline (SSAA, gradient) render.py OTF → half-block pipeline (SSAA, gradient)
effects.py noise, glitch_bar, fade, firehose effects/ plugin architecture for visual effects
types.py EffectPlugin ABC, EffectConfig, EffectContext
registry.py effect registration and lookup
chain.py effect pipeline chaining
controller.py handles /effects commands
performance.py performance monitoring
legacy.py legacy functional effects
fetch.py RSS/Gutenberg fetching + cache load/save fetch.py RSS/Gutenberg fetching + cache load/save
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
@@ -207,6 +220,15 @@ engine/
emitters.py background emitters for ntfy and mic emitters.py background emitters for ntfy and mic
types.py type definitions and dataclasses types.py type definitions and dataclasses
themes.py THEME_REGISTRY — gradient color definitions themes.py THEME_REGISTRY — gradient color definitions
display/ Display backend system
__init__.py DisplayRegistry, get_monitor
backends/
terminal.py ANSI terminal display
websocket.py WebSocket server for browser clients
sixel.py Sixel graphics (pure Python)
null.py headless display for testing
multi.py forwards to multiple displays
benchmark.py performance benchmarking tool
effects_plugins/ effects_plugins/
__init__.py plugin discovery (ABC issubclass scan) __init__.py plugin discovery (ABC issubclass scan)
@@ -219,62 +241,6 @@ effects_plugins/
figments/ SVG assets for figment mode figments/ SVG assets for figment mode
``` ```
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
---
## Extending
`ntfy.py` and `mic.py` are fully standalone and designed to be reused by any terminal visualizer. `engine.render` is the importable rendering pipeline for non-terminal targets.
### NtfyPoller
```python
from engine.ntfy import NtfyPoller
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
poller.start()
# in your render loop:
msg = poller.get_active_message() # → (title, body, timestamp) or None
if msg:
title, body, ts = msg
render_my_message(title, body) # visualizer-specific
```
Dependencies: `urllib.request`, `json`, `threading`, `time` — stdlib only. The `since=` parameter is managed automatically on reconnect.
### MicMonitor
```python
from engine.mic import MicMonitor
mic = MicMonitor(threshold_db=50)
result = mic.start() # None = sounddevice unavailable; False = stream failed; True = ok
if result:
excess = mic.excess # dB above threshold, clamped to 0
db = mic.db # raw RMS dB level
```
Dependencies: `sounddevice`, `numpy` — both optional; degrades gracefully if unavailable.
### Render pipeline
`engine.render` exposes the OTF → raster pipeline independently of the terminal scroll loop. The planned `serve.py` extension will import it directly to pre-render headlines as 1-bit bitmaps for an ESP32 thin client:
```python
# planned — serve.py does not yet exist
from engine.render import render_line, big_wrap
from engine.fetch import fetch_all
headlines = fetch_all()
for h in headlines:
rows = big_wrap(h.text, font, width=800) # list of half-block rows
# threshold to 1-bit, pack bytes, serve over HTTP
```
See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server + thin client architecture.
--- ---
## Development ## Development
@@ -285,7 +251,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
```bash ```bash
uv sync # minimal (no mic, no figment) uv sync # minimal (no mic, no figment)
uv sync --extras mic # with mic support (sounddevice + numpy) uv sync --extra mic # with mic support (sounddevice + numpy)
uv sync --extra figment # with figment mode (cairosvg + system Cairo) uv sync --extra figment # with figment mode (cairosvg + system Cairo)
uv sync --all-extras # all optional features uv sync --all-extras # all optional features
uv sync --all-extras --group dev # full dev environment uv sync --all-extras --group dev # full dev environment
@@ -299,13 +265,25 @@ With [mise](https://mise.jdx.dev/):
```bash ```bash
mise run test # run test suite mise run test # run test suite
mise run test-cov # run with coverage report mise run test-cov # run with coverage report
mise run lint # ruff check
mise run lint-fix # ruff check --fix mise run lint # ruff check
mise run format # ruff format mise run lint-fix # ruff check --fix
mise run run # uv run mainline.py mise run format # ruff format
mise run run-poetry # uv run mainline.py --poetry
mise run run-firehose # uv run mainline.py --firehose mise run run # terminal display
mise run run-websocket # web display only
mise run run-sixel # sixel graphics
mise run run-both # terminal + web
mise run run-client # both + open browser
mise run cmd # C&C command interface
mise run cmd-stats # watch effects stats
mise run benchmark # run performance benchmarks
mise run benchmark-json # save as JSON
mise run topics-init # initialize ntfy topics
``` ```
### Testing ### Testing
@@ -315,8 +293,21 @@ Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, `
```bash ```bash
uv run pytest uv run pytest
uv run pytest --cov=engine --cov-report=term-missing uv run pytest --cov=engine --cov-report=term-missing
# Run with mise
mise run test
mise run test-cov
# Run performance benchmarks
mise run benchmark
mise run benchmark-json
# Run benchmark hook mode (for CI)
uv run python -m engine.benchmark --hook
``` ```
Performance regression tests are in `tests/test_benchmark.py` marked with `@pytest.mark.benchmark`.
### Linting ### Linting
```bash ```bash
@@ -331,15 +322,15 @@ Pre-commit hooks run lint automatically via `hk`.
## Roadmap ## Roadmap
### Performance ### Performance
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed - Concurrent feed fetching with ThreadPoolExecutor
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart - Background feed refresh daemon
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render - Translation pre-fetch during boot
### Graphics ### Graphics
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer - Matrix rain katakana underlay
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen - CRT scanline simulation
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal - Sixel/iTerm2 inline images
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side - Parallax secondary column
### Cyberpunk Vibes ### Cyberpunk Vibes
- **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency - **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency
@@ -357,4 +348,4 @@ Pre-commit hooks run lint automatically via `hk`.
--- ---
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.* *Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*