forked from genewildish/Mainline
Compare commits
87 Commits
docs/updat
...
60ae4f7dfb
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ae4f7dfb | |||
| 4b26c947e8 | |||
| b37b2ccc73 | |||
| b926b346ad | |||
| a65fb50464 | |||
| 10e2f00edd | |||
| 05d261273e | |||
| 57de835ae0 | |||
| 4c97cfe6aa | |||
| 10c1d057a9 | |||
| 7f6413c83b | |||
| d54147cfb4 | |||
| affafe810c | |||
| 85d8b29bab | |||
| d14f850711 | |||
| 6fc3cbc0d2 | |||
| 3e73ea0adb | |||
| 7c69086fa5 | |||
| 0980279332 | |||
| cda13584c5 | |||
| 526e5ae47d | |||
| dfe42b0883 | |||
| 1d244cf76a | |||
| 0aa80f92de | |||
| 5762d5e845 | |||
| 28203bac4b | |||
| 952b73cdf0 | |||
| d9c7138fe3 | |||
| c976b99da6 | |||
| 8d066edcca | |||
| b20b4973b5 | |||
| 73ca72d920 | |||
| 015d563c4a | |||
| 4a08b474c1 | |||
| 637cbc5515 | |||
| e0bbfea26c | |||
| 3a3d0c0607 | |||
| f638fb7597 | |||
| 2a41a90d79 | |||
| f43920e2f0 | |||
| b27ddbccb8 | |||
| bfd94fe046 | |||
| 76126bdaac | |||
| 4616a21359 | |||
| ce9d888cf5 | |||
| 1a42fca507 | |||
| e23ba81570 | |||
| 997bffab68 | |||
| 2e96b7cd83 | |||
| a370c7e1a0 | |||
| ea379f5aca | |||
| 828b8489e1 | |||
| 31cabe9128 | |||
| bcb4ef0cfe | |||
| 996ba14b1d | |||
| a1dcceac47 | |||
| c2d77ee358 | |||
| 8e27f89fa4 | |||
| 4d28f286db | |||
| 9b139a40f7 | |||
| e1408dcf16 | |||
| 0152e32115 | |||
| dc1adb2558 | |||
| fada11b58d | |||
| 3e9c1be6d2 | |||
| 0f2d8bf5c2 | |||
| f5de2c62e0 | |||
| f9991c24af | |||
| 20ed014491 | |||
| 9e4d54a82e | |||
| dcd31469a5 | |||
| 829c4ab63d | |||
| 22dd063baa | |||
| 0f7203e4e0 | |||
| ba050ada24 | |||
| d7b044ceae | |||
| ac1306373d | |||
| 2650f7245e | |||
| b1f2b9d2be | |||
| c08a7d3cb0 | |||
| d5a3edba97 | |||
| fb35458718 | |||
| 15de46722a | |||
| 35e5c8d38b | |||
| cdc8094de2 | |||
| f170143939 | |||
| 19fb4bc4fe |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ htmlcov/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
coverage.xml
|
||||
*.dot
|
||||
*.png
|
||||
|
||||
78
.opencode/skills/mainline-architecture/SKILL.md
Normal file
78
.opencode/skills/mainline-architecture/SKILL.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
name: mainline-architecture
|
||||
description: Pipeline stages, capability resolution, and core architecture patterns
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers Mainline's pipeline architecture - the Stage-based system for dependency resolution, data flow, and component composition.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Stage Class (engine/pipeline/core.py)
|
||||
|
||||
The `Stage` ABC is the foundation. All pipeline components inherit from it:
|
||||
|
||||
```python
|
||||
class Stage(ABC):
|
||||
name: str
|
||||
category: str # "source", "effect", "overlay", "display", "camera"
|
||||
optional: bool = False
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
"""What this stage provides (e.g., 'source.headlines')"""
|
||||
return set()
|
||||
|
||||
@property
|
||||
def dependencies(self) -> list[str]:
|
||||
"""What this stage needs (e.g., ['source'])"""
|
||||
return []
|
||||
```
|
||||
|
||||
### Capability-Based Dependencies
|
||||
|
||||
The Pipeline resolves dependencies using **prefix matching**:
|
||||
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||
- This allows flexible composition without hardcoding specific stage names
|
||||
|
||||
### DataType Enum
|
||||
|
||||
PureData-style data types for inlet/outlet validation:
|
||||
- `SOURCE_ITEMS`: List[SourceItem] - raw items from sources
|
||||
- `ITEM_TUPLES`: List[tuple] - (title, source, timestamp) tuples
|
||||
- `TEXT_BUFFER`: List[str] - rendered ANSI buffer
|
||||
- `RAW_TEXT`: str - raw text strings
|
||||
- `PIL_IMAGE`: PIL Image object
|
||||
|
||||
### Pipeline Execution
|
||||
|
||||
The Pipeline (engine/pipeline/controller.py):
|
||||
1. Collects all stages from StageRegistry
|
||||
2. Resolves dependencies using prefix matching
|
||||
3. Executes stages in dependency order
|
||||
4. Handles errors for non-optional stages
|
||||
|
||||
### Canvas & Camera
|
||||
|
||||
- **Canvas** (`engine/canvas.py`): 2D rendering surface with dirty region tracking
|
||||
- **Camera** (`engine/camera.py`): Viewport controller for scrolling content
|
||||
|
||||
Canvas tracks dirty regions automatically when content is written via `put_region`, `put_text`, `fill`, enabling partial buffer updates.
|
||||
|
||||
## Adding New Stages
|
||||
|
||||
1. Create a class inheriting from `Stage`
|
||||
2. Define `capabilities` and `dependencies` properties
|
||||
3. Implement required abstract methods
|
||||
4. Register in StageRegistry or use as adapter
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- Use adapters (engine/pipeline/adapters.py) to wrap existing components as stages
|
||||
- Set `optional=True` for stages that can fail gracefully
|
||||
- Use `stage_type` and `render_order` for execution ordering
|
||||
86
.opencode/skills/mainline-display/SKILL.md
Normal file
86
.opencode/skills/mainline-display/SKILL.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
name: mainline-display
|
||||
description: Display backend implementation and the Display protocol
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers Mainline's display backend system - how to implement new display backends and how the Display protocol works.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Display Protocol
|
||||
|
||||
All backends implement a common Display protocol (in `engine/display/__init__.py`):
|
||||
|
||||
```python
|
||||
class Display(Protocol):
|
||||
def show(self, buf: list[str]) -> None:
|
||||
"""Display the buffer"""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the display"""
|
||||
...
|
||||
|
||||
def size(self) -> tuple[int, int]:
|
||||
"""Return (width, height)"""
|
||||
...
|
||||
```
|
||||
|
||||
### DisplayRegistry
|
||||
|
||||
Discovers and manages backends:
|
||||
|
||||
```python
|
||||
from engine.display import get_monitor
|
||||
display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi"
|
||||
```
|
||||
|
||||
### Available Backends
|
||||
|
||||
| Backend | File | Description |
|
||||
|---------|------|-------------|
|
||||
| terminal | backends/terminal.py | ANSI terminal output |
|
||||
| websocket | backends/websocket.py | Web browser via WebSocket |
|
||||
| sixel | backends/sixel.py | Sixel graphics (pure Python) |
|
||||
| null | backends/null.py | Headless for testing |
|
||||
| multi | backends/multi.py | Forwards to multiple displays |
|
||||
|
||||
### WebSocket Backend
|
||||
|
||||
- WebSocket server: port 8765
|
||||
- HTTP server: port 8766 (serves client/index.html)
|
||||
- Client has ANSI color parsing and fullscreen support
|
||||
|
||||
### Multi Backend
|
||||
|
||||
Forwards to multiple displays simultaneously - useful for `terminal + websocket`.
|
||||
|
||||
## Adding a New Backend
|
||||
|
||||
1. Create `engine/display/backends/my_backend.py`
|
||||
2. Implement the Display protocol methods
|
||||
3. Register in `engine/display/__init__.py`'s `DisplayRegistry`
|
||||
|
||||
Required methods:
|
||||
- `show(buf: list[str])` - Display buffer
|
||||
- `clear()` - Clear screen
|
||||
- `size() -> tuple[int, int]` - Terminal dimensions
|
||||
|
||||
Optional methods:
|
||||
- `title(text: str)` - Set window title
|
||||
- `cursor(show: bool)` - Control cursor
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python mainline.py --display terminal # default
|
||||
python mainline.py --display websocket
|
||||
python mainline.py --display sixel
|
||||
python mainline.py --display both # terminal + websocket
|
||||
```
|
||||
113
.opencode/skills/mainline-effects/SKILL.md
Normal file
113
.opencode/skills/mainline-effects/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: mainline-effects
|
||||
description: How to add new effect plugins to Mainline's effect system
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers Mainline's effect plugin system - how to create, configure, and integrate visual effects into the pipeline.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### EffectPlugin ABC (engine/effects/types.py)
|
||||
|
||||
All effects must inherit from `EffectPlugin` and implement:
|
||||
|
||||
```python
|
||||
class EffectPlugin(ABC):
|
||||
name: str
|
||||
config: EffectConfig
|
||||
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||
supports_partial_updates: bool = False
|
||||
|
||||
@abstractmethod
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
"""Process buffer with effect applied"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
"""Configure the effect"""
|
||||
...
|
||||
```
|
||||
|
||||
### EffectContext
|
||||
|
||||
Passed to every effect's process method:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EffectContext:
|
||||
terminal_width: int
|
||||
terminal_height: int
|
||||
scroll_cam: int
|
||||
ticker_height: int
|
||||
camera_x: int = 0
|
||||
mic_excess: float = 0.0
|
||||
grad_offset: float = 0.0
|
||||
frame_number: int = 0
|
||||
has_message: bool = False
|
||||
items: list = field(default_factory=list)
|
||||
_state: dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
Access sensor values via `ctx.get_sensor_value("sensor_name")`.
|
||||
|
||||
### EffectConfig
|
||||
|
||||
Configuration dataclass:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EffectConfig:
|
||||
enabled: bool = True
|
||||
intensity: float = 1.0
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
### Partial Updates
|
||||
|
||||
For performance optimization, set `supports_partial_updates = True` and implement `process_partial`:
|
||||
|
||||
```python
|
||||
class MyEffect(EffectPlugin):
|
||||
supports_partial_updates = True
|
||||
|
||||
def process_partial(self, buf, ctx, partial: PartialUpdate) -> list[str]:
|
||||
# Only process changed regions
|
||||
...
|
||||
```
|
||||
|
||||
## Adding a New Effect
|
||||
|
||||
1. Create file in `effects_plugins/my_effect.py`
|
||||
2. Inherit from `EffectPlugin`
|
||||
3. Implement `process()` and `configure()`
|
||||
4. Add to `effects_plugins/__init__.py` (runtime discovery via issubclass checks)
|
||||
|
||||
## Param Bindings
|
||||
|
||||
Declarative sensor-to-param mappings:
|
||||
|
||||
```python
|
||||
param_bindings = {
|
||||
"intensity": {"sensor": "mic", "transform": "linear"},
|
||||
"rate": {"sensor": "oscillator", "transform": "exponential"},
|
||||
}
|
||||
```
|
||||
|
||||
Transforms: `linear`, `exponential`, `threshold`
|
||||
|
||||
## Effect Chain
|
||||
|
||||
Effects are chained via `engine/effects/chain.py` - processes each effect in order, passing output to next.
|
||||
|
||||
## Existing Effects
|
||||
|
||||
See `effects_plugins/`:
|
||||
- noise.py, fade.py, glitch.py, firehose.py
|
||||
- border.py, crop.py, tint.py, hud.py
|
||||
103
.opencode/skills/mainline-presets/SKILL.md
Normal file
103
.opencode/skills/mainline-presets/SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: mainline-presets
|
||||
description: Creating pipeline presets in TOML format for Mainline
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers how to create pipeline presets in TOML format for Mainline's rendering pipeline.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Preset Loading Order
|
||||
|
||||
Presets are loaded from multiple locations (later overrides earlier):
|
||||
1. Built-in: `engine/presets.toml`
|
||||
2. User config: `~/.config/mainline/presets.toml`
|
||||
3. Local override: `./presets.toml`
|
||||
|
||||
### PipelinePreset Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PipelinePreset:
|
||||
name: str
|
||||
description: str = ""
|
||||
source: str = "headlines" # Data source
|
||||
display: str = "terminal" # Display backend
|
||||
camera: str = "scroll" # Camera mode
|
||||
effects: list[str] = field(default_factory=list)
|
||||
border: bool = False
|
||||
```
|
||||
|
||||
### TOML Format
|
||||
|
||||
```toml
|
||||
[presets.my-preset]
|
||||
description = "My custom pipeline"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "scroll"
|
||||
effects = ["noise", "fade"]
|
||||
border = true
|
||||
```
|
||||
|
||||
## Creating a Preset
|
||||
|
||||
### Option 1: User Config
|
||||
|
||||
Create/edit `~/.config/mainline/presets.toml`:
|
||||
|
||||
```toml
|
||||
[presets.my-cool-preset]
|
||||
description = "Noise and glitch effects"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
effects = ["noise", "glitch"]
|
||||
```
|
||||
|
||||
### Option 2: Local Override
|
||||
|
||||
Create `./presets.toml` in project root:
|
||||
|
||||
```toml
|
||||
[presets.dev-inspect]
|
||||
description = "Pipeline introspection for development"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
effects = ["hud"]
|
||||
```
|
||||
|
||||
### Option 3: Built-in
|
||||
|
||||
Edit `engine/presets.toml` (requires PR to repository).
|
||||
|
||||
## Available Sources
|
||||
|
||||
- `headlines` - RSS news feeds
|
||||
- `poetry` - Literature mode
|
||||
- `pipeline-inspect` - Live DAG visualization
|
||||
|
||||
## Available Displays
|
||||
|
||||
- `terminal` - ANSI terminal
|
||||
- `websocket` - Web browser
|
||||
- `sixel` - Sixel graphics
|
||||
- `null` - Headless
|
||||
|
||||
## Available Effects
|
||||
|
||||
See `effects_plugins/`:
|
||||
- noise, fade, glitch, firehose
|
||||
- border, crop, tint, hud
|
||||
|
||||
## Validation Functions
|
||||
|
||||
Use these from `engine/pipeline/presets.py`:
|
||||
- `validate_preset()` - Validate preset structure
|
||||
- `validate_signal_path()` - Detect circular dependencies
|
||||
- `generate_preset_toml()` - Generate skeleton preset
|
||||
136
.opencode/skills/mainline-sensors/SKILL.md
Normal file
136
.opencode/skills/mainline-sensors/SKILL.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: mainline-sensors
|
||||
description: Sensor framework for real-time input in Mainline
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers Mainline's sensor framework - how to use, create, and integrate sensors for real-time input.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Sensor Base Class (engine/sensors/__init__.py)
|
||||
|
||||
```python
|
||||
class Sensor(ABC):
|
||||
name: str
|
||||
unit: str = ""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Whether sensor is currently available"""
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def read(self) -> SensorValue | None:
|
||||
"""Read current sensor value"""
|
||||
...
|
||||
|
||||
def start(self) -> None:
|
||||
"""Initialize sensor (optional)"""
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Clean up sensor (optional)"""
|
||||
pass
|
||||
```
|
||||
|
||||
### SensorValue Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SensorValue:
|
||||
sensor_name: str
|
||||
value: float
|
||||
timestamp: float
|
||||
unit: str = ""
|
||||
```
|
||||
|
||||
### SensorRegistry
|
||||
|
||||
Discovers and manages sensors globally:
|
||||
|
||||
```python
|
||||
from engine.sensors import SensorRegistry
|
||||
registry = SensorRegistry()
|
||||
sensor = registry.get("mic")
|
||||
```
|
||||
|
||||
### SensorStage
|
||||
|
||||
Pipeline adapter that provides sensor values to effects:
|
||||
|
||||
```python
|
||||
from engine.pipeline.adapters import SensorStage
|
||||
stage = SensorStage(sensor_name="mic")
|
||||
```
|
||||
|
||||
## Built-in Sensors
|
||||
|
||||
| Sensor | File | Description |
|
||||
|--------|------|-------------|
|
||||
| MicSensor | sensors/mic.py | Microphone input (RMS dB) |
|
||||
| OscillatorSensor | sensors/oscillator.py | Test sine wave generator |
|
||||
| PipelineMetricsSensor | sensors/pipeline_metrics.py | FPS, frame time, etc. |
|
||||
|
||||
## Param Bindings
|
||||
|
||||
Effects declare sensor-to-param mappings:
|
||||
|
||||
```python
|
||||
class GlitchEffect(EffectPlugin):
|
||||
param_bindings = {
|
||||
"intensity": {"sensor": "mic", "transform": "linear"},
|
||||
}
|
||||
```
|
||||
|
||||
### Transform Functions
|
||||
|
||||
- `linear` - Direct mapping to param range
|
||||
- `exponential` - Exponential scaling
|
||||
- `threshold` - Binary on/off
|
||||
|
||||
## Adding a New Sensor
|
||||
|
||||
1. Create `engine/sensors/my_sensor.py`
|
||||
2. Inherit from `Sensor` ABC
|
||||
3. Implement required methods
|
||||
4. Register in `SensorRegistry`
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MySensor(Sensor):
|
||||
name = "my-sensor"
|
||||
unit = "units"
|
||||
|
||||
def read(self) -> SensorValue | None:
|
||||
return SensorValue(
|
||||
sensor_name=self.name,
|
||||
value=self._read_hardware(),
|
||||
timestamp=time.time(),
|
||||
unit=self.unit
|
||||
)
|
||||
```
|
||||
|
||||
## Using Sensors in Effects
|
||||
|
||||
Access sensor values via EffectContext:
|
||||
|
||||
```python
|
||||
def process(self, buf, ctx):
|
||||
mic_level = ctx.get_sensor_value("mic")
|
||||
if mic_level and mic_level > 0.5:
|
||||
# Apply intense effect
|
||||
...
|
||||
```
|
||||
|
||||
Or via param_bindings (automatic):
|
||||
|
||||
```python
|
||||
# If intensity is bound to "mic", it's automatically
|
||||
# available in self.config.intensity
|
||||
```
|
||||
87
.opencode/skills/mainline-sources/SKILL.md
Normal file
87
.opencode/skills/mainline-sources/SKILL.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: mainline-sources
|
||||
description: Adding new RSS feeds and data sources to Mainline
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers how to add new data sources (RSS feeds, poetry) to Mainline.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Feeds Dictionary (engine/sources.py)
|
||||
|
||||
All feeds are defined in a simple dictionary:
|
||||
|
||||
```python
|
||||
FEEDS = {
|
||||
"Feed Name": "https://example.com/feed.xml",
|
||||
# Category comments help organize:
|
||||
# Science & Technology
|
||||
# Economics & Business
|
||||
# World & Politics
|
||||
# Culture & Ideas
|
||||
}
|
||||
```
|
||||
|
||||
### Poetry Sources
|
||||
|
||||
Project Gutenberg URLs for public domain literature:
|
||||
|
||||
```python
|
||||
POETRY_SOURCES = {
|
||||
"Author Name": "https://www.gutenberg.org/cache/epub/1234/pg1234.txt",
|
||||
}
|
||||
```
|
||||
|
||||
### Language & Script Mapping
|
||||
|
||||
The sources.py also contains language/script detection mappings used for auto-translation and font selection.
|
||||
|
||||
## Adding a New RSS Feed
|
||||
|
||||
1. Edit `engine/sources.py`
|
||||
2. Add entry to `FEEDS` dict under appropriate category:
|
||||
```python
|
||||
"My Feed": "https://example.com/feed.xml",
|
||||
```
|
||||
3. The feed will be automatically discovered on next run
|
||||
|
||||
### Feed Requirements
|
||||
|
||||
- Must be valid RSS or Atom XML
|
||||
- Should have `<title>` elements for items
|
||||
- Must be HTTP/HTTPS accessible
|
||||
|
||||
## Adding Poetry Sources
|
||||
|
||||
1. Edit `engine/sources.py`
|
||||
2. Add to `POETRY_SOURCES` dict:
|
||||
```python
|
||||
"Author": "https://www.gutenberg.org/cache/epub/XXXX/pgXXXX.txt",
|
||||
```
|
||||
|
||||
### Poetry Requirements
|
||||
|
||||
- Plain text (UTF-8)
|
||||
- Project Gutenberg format preferred
|
||||
- No DRM-protected sources
|
||||
|
||||
## Data Flow
|
||||
|
||||
Feeds are fetched via `engine/fetch.py`:
|
||||
- `fetch_feed(url)` - Fetches and parses RSS/Atom
|
||||
- Results cached for fast restarts
|
||||
- Filtered via `engine/filter.py` for content cleaning
|
||||
|
||||
## Categories
|
||||
|
||||
Organize new feeds by category using comments:
|
||||
- Science & Technology
|
||||
- Economics & Business
|
||||
- World & Politics
|
||||
- Culture & Ideas
|
||||
413
AGENTS.md
Normal file
413
AGENTS.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# Agent Development Guide
|
||||
|
||||
## Development Environment
|
||||
|
||||
This project uses:
|
||||
- **mise** (mise.jdx.dev) - tool version manager and task runner
|
||||
- **uv** - fast Python package installer
|
||||
- **ruff** - linter and formatter (line-length 88, target Python 3.10)
|
||||
- **pytest** - test runner with strict marker enforcement
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
mise run install # Install dependencies
|
||||
# Or: uv sync --all-extras # includes mic, websocket, sixel support
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
|
||||
```bash
|
||||
# Testing
|
||||
mise run test # Run all tests
|
||||
mise run test-cov # Run tests with coverage report
|
||||
pytest tests/test_foo.py::TestClass::test_method # Run single test
|
||||
|
||||
# Linting & Formatting
|
||||
mise run lint # Run ruff linter
|
||||
mise run lint-fix # Run ruff with auto-fix
|
||||
mise run format # Run ruff formatter
|
||||
|
||||
# CI
|
||||
mise run ci # Full CI pipeline (topics-init + lint + test-cov)
|
||||
```
|
||||
|
||||
### Running a Single Test
|
||||
|
||||
```bash
|
||||
# Run a specific test function
|
||||
pytest tests/test_eventbus.py::TestEventBusInit::test_init_creates_empty_subscribers
|
||||
|
||||
# Run all tests in a file
|
||||
pytest tests/test_eventbus.py
|
||||
|
||||
# Run tests matching a pattern
|
||||
pytest -k "test_subscribe"
|
||||
```
|
||||
|
||||
### Git Hooks
|
||||
|
||||
Install hooks at start of session:
|
||||
```bash
|
||||
ls -la .git/hooks/pre-commit # Verify installed
|
||||
hk init --mise # Install if missing
|
||||
mise run pre-commit # Run manually
|
||||
```
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports (three sections, alphabetical within each)
|
||||
|
||||
```python
|
||||
# 1. Standard library
|
||||
import os
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# 2. Third-party
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# 3. Local project
|
||||
from engine.events import EventType
|
||||
```
|
||||
|
||||
### Type Hints
|
||||
|
||||
- Use type hints for all function signatures (parameters and return)
|
||||
- Use `|` for unions (Python 3.10+): `EventType | None`
|
||||
- Use `dict[K, V]`, `list[V]` (generic syntax): `dict[str, list[int]]`
|
||||
- Use `Callable[[ArgType], ReturnType]` for callbacks
|
||||
|
||||
```python
|
||||
def subscribe(self, event_type: EventType, callback: Callable[[Any], None]) -> None:
|
||||
...
|
||||
|
||||
def get_sensor_value(self, sensor_name: str) -> float | None:
|
||||
return self._state.get(f"sensor.{sensor_name}")
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Classes**: `PascalCase` (e.g., `EventBus`, `EffectPlugin`)
|
||||
- **Functions/methods**: `snake_case` (e.g., `get_event_bus`, `process_partial`)
|
||||
- **Constants**: `SCREAMING_SNAKE_CASE` (e.g., `CURSOR_OFF`)
|
||||
- **Private methods**: `_snake_case` prefix (e.g., `_initialize`)
|
||||
- **Type variables**: `PascalCase` (e.g., `T`, `EffectT`)
|
||||
|
||||
### Dataclasses
|
||||
|
||||
Use `@dataclass` for simple data containers:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EffectContext:
|
||||
terminal_width: int
|
||||
terminal_height: int
|
||||
scroll_cam: int
|
||||
ticker_height: int = 0
|
||||
_state: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
```
|
||||
|
||||
### Abstract Base Classes
|
||||
|
||||
Use ABC for interface enforcement:
|
||||
|
||||
```python
|
||||
class EffectPlugin(ABC):
|
||||
name: str
|
||||
config: EffectConfig
|
||||
|
||||
@abstractmethod
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Catch specific exceptions, not bare `Exception`
|
||||
- Use `try/except` with fallbacks for optional features
|
||||
- Silent pass in event callbacks to prevent one handler from breaking others
|
||||
|
||||
```python
|
||||
# Good: specific exception
|
||||
try:
|
||||
term_size = os.get_terminal_size()
|
||||
except OSError:
|
||||
term_width = 80
|
||||
|
||||
# Good: silent pass in callbacks
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
|
||||
### Thread Safety
|
||||
|
||||
Use locks for shared state:
|
||||
|
||||
```python
|
||||
class EventBus:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def publish(self, event_type: EventType, event: Any = None) -> None:
|
||||
with self._lock:
|
||||
callbacks = list(self._subscribers.get(event_type, []))
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
- **DO NOT ADD comments** unless explicitly required
|
||||
- Let code be self-documenting with good naming
|
||||
- Use docstrings only for public APIs or complex logic
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
Follow pytest conventions:
|
||||
|
||||
```python
|
||||
class TestEventBusSubscribe:
|
||||
"""Tests for EventBus.subscribe method."""
|
||||
|
||||
def test_subscribe_adds_callback(self):
|
||||
"""subscribe() adds a callback for an event type."""
|
||||
bus = EventBus()
|
||||
def callback(e):
|
||||
return None
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||
```
|
||||
|
||||
- Use classes to group related tests (`Test<ClassName>`, `Test<method_name>`)
|
||||
- Test docstrings follow `"<method>() <action>"` pattern
|
||||
- Use descriptive assertion messages via pytest behavior
|
||||
|
||||
## Workflow Rules
|
||||
|
||||
### Before Committing
|
||||
|
||||
1. Run tests: `mise run test`
|
||||
2. Run linter: `mise run lint`
|
||||
3. Review changes: `git diff`
|
||||
|
||||
### On Failing Tests
|
||||
|
||||
- **Out-of-date test**: Update test to match new expected behavior
|
||||
- **Correctly failing test**: Fix implementation, not the test
|
||||
|
||||
**Never** modify a test to make it pass without understanding why it failed.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Pipeline**: source → render → effects → display
|
||||
- **EffectPlugin**: ABC with `process()` and `configure()` methods
|
||||
- **Display backends**: terminal, websocket, sixel, null (for testing)
|
||||
- **EventBus**: thread-safe pub/sub messaging
|
||||
- **Presets**: TOML format in `engine/presets.toml`
|
||||
|
||||
Key files:
|
||||
- `engine/pipeline/core.py` - Stage base class
|
||||
- `engine/effects/types.py` - EffectPlugin ABC and dataclasses
|
||||
- `engine/display/backends/` - Display backend implementations
|
||||
- `engine/eventbus.py` - Thread-safe event system
|
||||
=======
|
||||
## Testing
|
||||
|
||||
Tests live in `tests/` and follow the pattern `test_*.py`.
|
||||
|
||||
Run all tests:
|
||||
```bash
|
||||
mise run test
|
||||
```
|
||||
|
||||
Run with coverage:
|
||||
```bash
|
||||
mise run test-cov
|
||||
```
|
||||
|
||||
The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||
|
||||
### Test Coverage Strategy
|
||||
|
||||
Current coverage: 56% (463 tests)
|
||||
|
||||
Key areas with lower coverage (acceptable for now):
|
||||
- **app.py** (8%): Main entry point - integration heavy, requires terminal
|
||||
- **scroll.py** (10%): Terminal-dependent rendering logic (unused)
|
||||
|
||||
Key areas with good coverage:
|
||||
- **display/backends/null.py** (95%): Easy to test headlessly
|
||||
- **display/backends/terminal.py** (96%): Uses mocking
|
||||
- **display/backends/multi.py** (100%): Simple forwarding logic
|
||||
- **effects/performance.py** (99%): Pure Python logic
|
||||
- **eventbus.py** (96%): Simple event system
|
||||
- **effects/controller.py** (95%): Effects command handling
|
||||
|
||||
Areas needing more tests:
|
||||
- **websocket.py** (48%): Network I/O, hard to test in CI
|
||||
- **ntfy.py** (50%): Network I/O, hard to test in CI
|
||||
- **mic.py** (61%): Audio I/O, hard to test in CI
|
||||
|
||||
Note: Terminal-dependent modules (scroll, layers render) are harder to test in CI.
|
||||
Performance regression tests are in `tests/test_benchmark.py` with `@pytest.mark.benchmark`.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **ntfy.py** - standalone notification poller with zero internal dependencies
|
||||
- **sensors/** - Sensor framework (MicSensor, OscillatorSensor) for real-time input
|
||||
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||
- **effects/** - plugin architecture with performance monitoring
|
||||
- The new pipeline architecture: source → render → effects → display
|
||||
|
||||
#### Canvas & Camera
|
||||
|
||||
- **Canvas** (`engine/canvas.py`): 2D rendering surface with dirty region tracking
|
||||
- **Camera** (`engine/camera.py`): Viewport controller for scrolling content
|
||||
|
||||
The Canvas tracks dirty regions automatically when content is written (via `put_region`, `put_text`, `fill`), enabling partial buffer updates for optimized effect processing.
|
||||
|
||||
### Pipeline Architecture
|
||||
|
||||
The new Stage-based pipeline architecture provides capability-based dependency resolution:
|
||||
|
||||
- **Stage** (`engine/pipeline/core.py`): Base class for pipeline stages
|
||||
- **Pipeline** (`engine/pipeline/controller.py`): Executes stages with capability-based dependency resolution
|
||||
- **StageRegistry** (`engine/pipeline/registry.py`): Discovers and registers stages
|
||||
- **Stage Adapters** (`engine/pipeline/adapters.py`): Wraps existing components as stages
|
||||
|
||||
#### Capability-Based Dependencies
|
||||
|
||||
Stages declare capabilities (what they provide) and dependencies (what they need). The Pipeline resolves dependencies using prefix matching:
|
||||
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||
- This allows flexible composition without hardcoding specific stage names
|
||||
|
||||
#### Sensor Framework
|
||||
|
||||
- **Sensor** (`engine/sensors/__init__.py`): Base class for real-time input sensors
|
||||
- **SensorRegistry**: Discovers available sensors
|
||||
- **SensorStage**: Pipeline adapter that provides sensor values to effects
|
||||
- **MicSensor** (`engine/sensors/mic.py`): Self-contained microphone input
|
||||
- **OscillatorSensor** (`engine/sensors/oscillator.py`): Test sensor for development
|
||||
- **PipelineMetricsSensor** (`engine/sensors/pipeline_metrics.py`): Exposes pipeline metrics as sensor values
|
||||
|
||||
Sensors support param bindings to drive effect parameters in real-time.
|
||||
|
||||
#### Pipeline Introspection
|
||||
|
||||
- **PipelineIntrospectionSource** (`engine/data_sources/pipeline_introspection.py`): Renders live ASCII visualization of pipeline DAG with metrics
|
||||
- **PipelineIntrospectionDemo** (`engine/pipeline/pipeline_introspection_demo.py`): 3-phase demo controller for effect animation
|
||||
|
||||
Preset: `pipeline-inspect` - Live pipeline introspection with DAG and performance metrics
|
||||
|
||||
#### Partial Update Support
|
||||
|
||||
Effect plugins can opt-in to partial buffer updates for performance optimization:
|
||||
- Set `supports_partial_updates = True` on the effect class
|
||||
- Implement `process_partial(buf, ctx, partial)` method
|
||||
- The `PartialUpdate` dataclass indicates which regions changed
|
||||
|
||||
### Preset System
|
||||
|
||||
Presets use TOML format (no external dependencies):
|
||||
|
||||
- Built-in: `engine/presets.toml`
|
||||
- User config: `~/.config/mainline/presets.toml`
|
||||
- Local override: `./presets.toml`
|
||||
|
||||
- **Preset loader** (`engine/pipeline/preset_loader.py`): Loads and validates presets
|
||||
- **PipelinePreset** (`engine/pipeline/presets.py`): Dataclass for preset configuration
|
||||
|
||||
Functions:
|
||||
- `validate_preset()` - Validate preset structure
|
||||
- `validate_signal_path()` - Detect circular dependencies
|
||||
- `generate_preset_toml()` - Generate skeleton preset
|
||||
|
||||
### Display System
|
||||
|
||||
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
||||
- `display/backends/terminal.py` - ANSI terminal output
|
||||
- `display/backends/websocket.py` - broadcasts to web clients via WebSocket
|
||||
- `display/backends/sixel.py` - renders to Sixel graphics (pure Python, no C dependency)
|
||||
- `display/backends/null.py` - headless display for testing
|
||||
- `display/backends/multi.py` - forwards to multiple displays simultaneously
|
||||
- `display/__init__.py` - DisplayRegistry for backend discovery
|
||||
|
||||
- **WebSocket display** (`engine/display/backends/websocket.py`): real-time frame broadcasting to web browsers
|
||||
- WebSocket server on port 8765
|
||||
- HTTP server on port 8766 (serves HTML client)
|
||||
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
||||
|
||||
- **Display modes** (`--display` flag):
|
||||
- `terminal` - Default ANSI terminal output
|
||||
- `websocket` - Web browser display (requires websockets package)
|
||||
- `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.)
|
||||
- `both` - Terminal + WebSocket simultaneously
|
||||
|
||||
### Effect Plugin System
|
||||
|
||||
- **EffectPlugin ABC** (`engine/effects/types.py`): abstract base class for effects
|
||||
- All effects must inherit from EffectPlugin and implement `process()` and `configure()`
|
||||
- Runtime discovery via `effects_plugins/__init__.py` using `issubclass()` checks
|
||||
|
||||
- **EffectRegistry** (`engine/effects/registry.py`): manages registered effects
|
||||
- **EffectChain** (`engine/effects/chain.py`): chains effects in pipeline order
|
||||
|
||||
### Command & Control
|
||||
|
||||
- C&C uses separate ntfy topics for commands and responses
|
||||
- `NTFY_CC_CMD_TOPIC` - commands from cmdline.py
|
||||
- `NTFY_CC_RESP_TOPIC` - responses back to cmdline.py
|
||||
- Effects controller handles `/effects` commands (list, on/off, intensity, reorder, stats)
|
||||
|
||||
### Pipeline Documentation
|
||||
|
||||
The rendering pipeline is documented in `docs/PIPELINE.md` using Mermaid diagrams.
|
||||
|
||||
**IMPORTANT**: When making significant architectural changes to the rendering pipeline (new layers, effects, display backends), update `docs/PIPELINE.md` to reflect the changes:
|
||||
1. Edit `docs/PIPELINE.md` with the new architecture
|
||||
2. If adding new SVG diagrams, render them manually using an external tool (e.g., Mermaid Live Editor)
|
||||
3. Commit both the markdown and any new diagram files
|
||||
|
||||
## Skills Library
|
||||
|
||||
A skills library MCP server (`skills`) is available for capturing and tracking learned knowledge. Skills are stored in `~/.skills/`.
|
||||
|
||||
### Workflow
|
||||
|
||||
**Before starting work:**
|
||||
1. Run `skills_list_skills` to see available skills
|
||||
2. Use `skills_peek_skill({name: "skill-name"})` to preview relevant skills
|
||||
3. Use `skills_skill_slice({name: "skill-name", query: "your question"})` to get relevant sections
|
||||
|
||||
**While working:**
|
||||
- If a skill was wrong or incomplete: `skills_update_skill` → `skills_record_assessment` → `skills_report_outcome({quality: 1})`
|
||||
- If a skill worked correctly: `skills_report_outcome({quality: 4})` (normal) or `quality: 5` (perfect)
|
||||
|
||||
**End of session:**
|
||||
- Run `skills_reflect_on_session({context_summary: "what you did"})` to identify new skills to capture
|
||||
- Use `skills_create_skill` to add new skills
|
||||
- Use `skills_record_assessment` to score them
|
||||
|
||||
### Useful Tools
|
||||
- `skills_review_stale_skills()` - Skills due for review (negative days_until_due)
|
||||
- `skills_skills_report()` - Overview of entire collection
|
||||
- `skills_validate_skill({name: "skill-name"})` - Load skill for review with sources
|
||||
|
||||
### Agent Skills
|
||||
|
||||
This project also has Agent Skills (SKILL.md files) in `.opencode/skills/`. Use the `skill` tool to load them:
|
||||
- `skill({name: "mainline-architecture"})` - Pipeline stages, capability resolution
|
||||
- `skill({name: "mainline-effects"})` - How to add new effect plugins
|
||||
- `skill({name: "mainline-display"})` - Display backend implementation
|
||||
- `skill({name: "mainline-sources"})` - Adding new RSS feeds
|
||||
- `skill({name: "mainline-presets"})` - Creating pipeline presets
|
||||
- `skill({name: "mainline-sensors"})` - Sensor framework usage
|
||||
265
README.md
265
README.md
@@ -15,7 +15,8 @@ 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)
|
||||
python3 mainline.py --display websocket # web browser display only
|
||||
python3 mainline.py --display both # terminal + web browser
|
||||
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
|
||||
@@ -28,7 +29,20 @@ Or with uv:
|
||||
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
|
||||
|
||||
@@ -39,20 +53,32 @@ All constants live in `engine/config.py`:
|
||||
| `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 |
|
||||
| `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_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_PATH` | first file in `FONT_DIR` | Active display font |
|
||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
||||
| `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) |
|
||||
| `SSAA` | `4` | Super-sampling factor |
|
||||
| `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 stream endpoint |
|
||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
||||
|
||||
### Display Modes
|
||||
|
||||
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
|
||||
|
||||
@@ -62,15 +88,15 @@ All constants live in `engine/config.py`:
|
||||
|
||||
### 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/`.
|
||||
|
||||
### 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:
|
||||
|
||||
@@ -78,107 +104,68 @@ To push a message:
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Internals
|
||||
|
||||
### 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
|
||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
||||
- 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
|
||||
- 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 SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection
|
||||
- Feeds are fetched and filtered on startup; results are cached for fast restarts
|
||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters
|
||||
- The ticker uses a sweeping white-hot → deep green gradient
|
||||
- Subject-region detection triggers Google Translate and font swap for non-Latin scripts
|
||||
- The mic stream runs in a background thread, feeding RMS dB into glitch probability
|
||||
- The viewport scrolls through pre-rendered blocks with fade zones
|
||||
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands
|
||||
|
||||
### 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(), font picker TUI, boot sequence, signal handler
|
||||
|
||||
tests/
|
||||
test_config.py
|
||||
test_filter.py
|
||||
test_mic.py
|
||||
test_ntfy.py
|
||||
test_sources.py
|
||||
test_terminal.py
|
||||
__init__.py package marker
|
||||
app.py main(), font picker TUI, boot sequence, C&C poller
|
||||
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/ 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
|
||||
effects_plugins/ effect plugin implementations
|
||||
noise.py noise effect
|
||||
fade.py fade effect
|
||||
glitch.py glitch effect
|
||||
firehose.py firehose effect
|
||||
fetch.py RSS/Gutenberg fetching + cache
|
||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||
mic.py MicMonitor — standalone, graceful fallback
|
||||
scroll.py stream() frame loop + message rendering
|
||||
viewport.py terminal dimension tracking
|
||||
frame.py scroll step calculation, timing
|
||||
layers.py ticker zone, firehose, message overlay
|
||||
eventbus.py thread-safe event publishing
|
||||
events.py event types and definitions
|
||||
controller.py coordinates ntfy/mic monitoring
|
||||
emitters.py background emitters
|
||||
types.py type 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
|
||||
```
|
||||
|
||||
`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
|
||||
@@ -189,7 +176,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
```bash
|
||||
uv sync # minimal (no mic)
|
||||
uv sync --all-extras # with mic support (sounddevice + numpy)
|
||||
uv sync --all-extras # with mic support
|
||||
uv sync --all-extras --group dev # full dev environment
|
||||
```
|
||||
|
||||
@@ -199,24 +186,47 @@ With [mise](https://mise.jdx.dev/):
|
||||
|
||||
```bash
|
||||
mise run test # run test suite
|
||||
mise run test-cov # run with coverage report
|
||||
mise run lint # ruff check
|
||||
mise run lint-fix # ruff check --fix
|
||||
mise run format # ruff format
|
||||
mise run run # uv run mainline.py
|
||||
mise run run-poetry # uv run mainline.py --poetry
|
||||
mise run run-firehose # uv run mainline.py --firehose
|
||||
mise run test-cov # run with coverage report
|
||||
|
||||
mise run lint # ruff check
|
||||
mise run lint-fix # ruff check --fix
|
||||
mise run format # ruff format
|
||||
|
||||
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
|
||||
|
||||
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -231,28 +241,23 @@ Pre-commit hooks run lint automatically via `hk`.
|
||||
## Roadmap
|
||||
|
||||
### Performance
|
||||
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
||||
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
||||
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
||||
- Concurrent feed fetching with ThreadPoolExecutor
|
||||
- Background feed refresh daemon
|
||||
- Translation pre-fetch during boot
|
||||
|
||||
### Graphics
|
||||
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
||||
- **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
|
||||
- Matrix rain katakana underlay
|
||||
- CRT scanline simulation
|
||||
- Sixel/iTerm2 inline images
|
||||
- Parallax secondary column
|
||||
|
||||
### Cyberpunk Vibes
|
||||
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
||||
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
||||
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
||||
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
|
||||
- **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
|
||||
- Keyword watch list with strobe effects
|
||||
- Breaking interrupt with synthesized audio
|
||||
- Live data overlay (BTC, ISS position)
|
||||
- Theme switcher (amber, ice, red)
|
||||
- Persona modes (surveillance, oracle, underground)
|
||||
|
||||
---
|
||||
|
||||
*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.*
|
||||
366
client/index.html
Normal file
366
client/index.html
Normal file
@@ -0,0 +1,366 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mainline Terminal</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #ccc;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
body.fullscreen {
|
||||
padding: 0;
|
||||
}
|
||||
body.fullscreen #controls {
|
||||
display: none;
|
||||
}
|
||||
#container {
|
||||
position: relative;
|
||||
}
|
||||
canvas {
|
||||
background: #000;
|
||||
border: 1px solid #333;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
body.fullscreen canvas {
|
||||
border: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
#controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
#controls button {
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
border: 1px solid #555;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
#controls button:hover {
|
||||
background: #444;
|
||||
}
|
||||
#controls input {
|
||||
width: 60px;
|
||||
background: #222;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
#status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
#status.connected {
|
||||
color: #4f4;
|
||||
}
|
||||
#status.disconnected {
|
||||
color: #f44;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<canvas id="terminal"></canvas>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<label>Cols: <input type="number" id="cols" value="80" min="20" max="200"></label>
|
||||
<label>Rows: <input type="number" id="rows" value="24" min="10" max="60"></label>
|
||||
<button id="apply">Apply</button>
|
||||
<button id="fullscreen">Fullscreen</button>
|
||||
</div>
|
||||
<div id="status" class="disconnected">Connecting...</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('terminal');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const status = document.getElementById('status');
|
||||
const colsInput = document.getElementById('cols');
|
||||
const rowsInput = document.getElementById('rows');
|
||||
const applyBtn = document.getElementById('apply');
|
||||
const fullscreenBtn = document.getElementById('fullscreen');
|
||||
|
||||
const CHAR_WIDTH = 9;
|
||||
const CHAR_HEIGHT = 16;
|
||||
|
||||
const ANSI_COLORS = {
|
||||
0: '#000000', 1: '#cd3131', 2: '#0dbc79', 3: '#e5e510',
|
||||
4: '#2472c8', 5: '#bc3fbc', 6: '#11a8cd', 7: '#e5e5e5',
|
||||
8: '#666666', 9: '#f14c4c', 10: '#23d18b', 11: '#f5f543',
|
||||
12: '#3b8eea', 13: '#d670d6', 14: '#29b8db', 15: '#ffffff',
|
||||
};
|
||||
|
||||
let cols = 80;
|
||||
let rows = 24;
|
||||
let ws = null;
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = cols * CHAR_WIDTH;
|
||||
canvas.height = rows * CHAR_HEIGHT;
|
||||
}
|
||||
|
||||
function parseAnsi(text) {
|
||||
if (!text) return [];
|
||||
|
||||
const tokens = [];
|
||||
let currentText = '';
|
||||
let fg = '#cccccc';
|
||||
let bg = '#000000';
|
||||
let bold = false;
|
||||
let i = 0;
|
||||
let inEscape = false;
|
||||
let escapeCode = '';
|
||||
|
||||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
|
||||
if (inEscape) {
|
||||
if (char >= '0' && char <= '9' || char === ';' || char === '[') {
|
||||
escapeCode += char;
|
||||
}
|
||||
|
||||
if (char === 'm') {
|
||||
const codes = escapeCode.replace('\x1b[', '').split(';');
|
||||
|
||||
for (const code of codes) {
|
||||
const num = parseInt(code) || 0;
|
||||
|
||||
if (num === 0) {
|
||||
fg = '#cccccc';
|
||||
bg = '#000000';
|
||||
bold = false;
|
||||
} else if (num === 1) {
|
||||
bold = true;
|
||||
} else if (num === 22) {
|
||||
bold = false;
|
||||
} else if (num === 39) {
|
||||
fg = '#cccccc';
|
||||
} else if (num === 49) {
|
||||
bg = '#000000';
|
||||
} else if (num >= 30 && num <= 37) {
|
||||
fg = ANSI_COLORS[num - 30 + (bold ? 8 : 0)] || '#cccccc';
|
||||
} else if (num >= 40 && num <= 47) {
|
||||
bg = ANSI_COLORS[num - 40] || '#000000';
|
||||
} else if (num >= 90 && num <= 97) {
|
||||
fg = ANSI_COLORS[num - 90 + 8] || '#cccccc';
|
||||
} else if (num >= 100 && num <= 107) {
|
||||
bg = ANSI_COLORS[num - 100 + 8] || '#000000';
|
||||
} else if (num >= 1 && num <= 256) {
|
||||
// 256 colors
|
||||
if (num < 16) {
|
||||
fg = ANSI_COLORS[num] || '#cccccc';
|
||||
} else if (num < 232) {
|
||||
const c = num - 16;
|
||||
const r = Math.floor(c / 36) * 51;
|
||||
const g = Math.floor((c % 36) / 6) * 51;
|
||||
const b = (c % 6) * 51;
|
||||
fg = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
||||
} else {
|
||||
const gray = (num - 232) * 10 + 8;
|
||||
fg = `#${gray.toString(16).repeat(2)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
tokens.push({ text: currentText, fg, bg, bold });
|
||||
currentText = '';
|
||||
}
|
||||
inEscape = false;
|
||||
escapeCode = '';
|
||||
}
|
||||
} else if (char === '\x1b' && text[i + 1] === '[') {
|
||||
if (currentText) {
|
||||
tokens.push({ text: currentText, fg, bg, bold });
|
||||
currentText = '';
|
||||
}
|
||||
inEscape = true;
|
||||
escapeCode = '';
|
||||
i++;
|
||||
} else {
|
||||
currentText += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
tokens.push({ text: currentText, fg, bg, bold });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function renderLine(text, x, y, lineHeight) {
|
||||
const tokens = parseAnsi(text);
|
||||
let xOffset = x;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.text) {
|
||||
if (token.bold) {
|
||||
ctx.font = 'bold 16px monospace';
|
||||
} else {
|
||||
ctx.font = '16px monospace';
|
||||
}
|
||||
|
||||
const metrics = ctx.measureText(token.text);
|
||||
|
||||
if (token.bg !== '#000000') {
|
||||
ctx.fillStyle = token.bg;
|
||||
ctx.fillRect(xOffset, y - 2, metrics.width + 1, lineHeight);
|
||||
}
|
||||
|
||||
ctx.fillStyle = token.fg;
|
||||
ctx.fillText(token.text, xOffset, y);
|
||||
xOffset += metrics.width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.hostname}:8765`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
status.textContent = 'Connected';
|
||||
status.className = 'connected';
|
||||
sendSize();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
status.textContent = 'Disconnected - Reconnecting...';
|
||||
status.className = 'disconnected';
|
||||
setTimeout(connect, 1000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
status.textContent = 'Connection error';
|
||||
status.className = 'disconnected';
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'frame') {
|
||||
cols = data.width || 80;
|
||||
rows = data.height || 24;
|
||||
colsInput.value = cols;
|
||||
rowsInput.value = rows;
|
||||
resizeCanvas();
|
||||
render(data.lines || []);
|
||||
} else if (data.type === 'clear') {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse message:', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sendSize() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
width: parseInt(colsInput.value),
|
||||
height: parseInt(rowsInput.value)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function render(lines) {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.font = '16px monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const lineHeight = CHAR_HEIGHT;
|
||||
const maxLines = Math.min(lines.length, rows);
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const line = lines[i] || '';
|
||||
renderLine(line, 0, i * lineHeight, lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateViewportSize() {
|
||||
const isFullscreen = document.fullscreenElement !== null;
|
||||
const padding = isFullscreen ? 0 : 40;
|
||||
const controlsHeight = isFullscreen ? 0 : 60;
|
||||
const availableWidth = window.innerWidth - padding;
|
||||
const availableHeight = window.innerHeight - controlsHeight;
|
||||
cols = Math.max(20, Math.floor(availableWidth / CHAR_WIDTH));
|
||||
rows = Math.max(10, Math.floor(availableHeight / CHAR_HEIGHT));
|
||||
colsInput.value = cols;
|
||||
rowsInput.value = rows;
|
||||
resizeCanvas();
|
||||
console.log('Fullscreen:', isFullscreen, 'Size:', cols, 'x', rows);
|
||||
sendSize();
|
||||
}
|
||||
|
||||
applyBtn.addEventListener('click', () => {
|
||||
cols = parseInt(colsInput.value);
|
||||
rows = parseInt(rowsInput.value);
|
||||
resizeCanvas();
|
||||
sendSize();
|
||||
});
|
||||
|
||||
fullscreenBtn.addEventListener('click', () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.body.classList.add('fullscreen');
|
||||
document.documentElement.requestFullscreen().then(() => {
|
||||
calculateViewportSize();
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => {
|
||||
calculateViewportSize();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.body.classList.remove('fullscreen');
|
||||
calculateViewportSize();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (document.fullscreenElement) {
|
||||
calculateViewportSize();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial setup
|
||||
resizeCanvas();
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
256
cmdline.py
Normal file
256
cmdline.py
Normal file
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Command-line utility for interacting with mainline via ntfy.
|
||||
|
||||
Usage:
|
||||
python cmdline.py # Interactive TUI mode
|
||||
python cmdline.py --help # Show help
|
||||
python cmdline.py /effects list # Send single command via ntfy
|
||||
python cmdline.py /effects stats # Get performance stats via ntfy
|
||||
python cmdline.py -w /effects stats # Watch mode (polls for stats)
|
||||
|
||||
The TUI mode provides:
|
||||
- Arrow keys to navigate command history
|
||||
- Tab completion for commands
|
||||
- Auto-refresh for performance stats
|
||||
|
||||
C&C works like a serial port:
|
||||
1. Send command to ntfy_cc_topic
|
||||
2. Mainline receives, processes, responds to same topic
|
||||
3. Cmdline polls for response
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["FORCE_COLOR"] = "1"
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from engine import config
|
||||
from engine.terminal import CLR, CURSOR_OFF, CURSOR_ON, G_DIM, G_HI, RST, W_GHOST
|
||||
|
||||
try:
|
||||
CC_CMD_TOPIC = config.NTFY_CC_CMD_TOPIC
|
||||
CC_RESP_TOPIC = config.NTFY_CC_RESP_TOPIC
|
||||
except AttributeError:
|
||||
CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||
CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||
|
||||
|
||||
class NtfyResponsePoller:
|
||||
"""Polls ntfy for command responses."""
|
||||
|
||||
def __init__(self, cmd_topic: str, resp_topic: str, timeout: float = 10.0):
|
||||
self.cmd_topic = cmd_topic
|
||||
self.resp_topic = resp_topic
|
||||
self.timeout = timeout
|
||||
self._last_id = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _build_url(self) -> str:
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
parsed = urlparse(self.resp_topic)
|
||||
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
params["since"] = [self._last_id if self._last_id else "20s"]
|
||||
new_query = urlencode({k: v[0] for k, v in params.items()})
|
||||
return urlunparse(parsed._replace(query=new_query))
|
||||
|
||||
def send_and_wait(self, cmd: str) -> str:
|
||||
"""Send command and wait for response."""
|
||||
url = self.cmd_topic.replace("/json", "")
|
||||
data = cmd.encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"User-Agent": "mainline-cmdline/0.1",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception as e:
|
||||
return f"Error sending command: {e}"
|
||||
|
||||
return self._wait_for_response(cmd)
|
||||
|
||||
def _wait_for_response(self, expected_cmd: str = "") -> str:
|
||||
"""Poll for response message."""
|
||||
start = time.time()
|
||||
while time.time() - start < self.timeout:
|
||||
try:
|
||||
url = self._build_url()
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "mainline-cmdline/0.1"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
for line in resp:
|
||||
try:
|
||||
data = json.loads(line.decode("utf-8", errors="replace"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if data.get("event") == "message":
|
||||
self._last_id = data.get("id")
|
||||
msg = data.get("message", "")
|
||||
if msg:
|
||||
return msg
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return "Timeout waiting for response"
|
||||
|
||||
|
||||
AVAILABLE_COMMANDS = """Available commands:
|
||||
/effects list - List all effects and status
|
||||
/effects <name> on - Enable an effect
|
||||
/effects <name> off - Disable an effect
|
||||
/effects <name> intensity <0.0-1.0> - Set effect intensity
|
||||
/effects reorder <name1>,<name2>,... - Reorder pipeline
|
||||
/effects stats - Show performance statistics
|
||||
/help - Show this help
|
||||
/quit - Exit
|
||||
"""
|
||||
|
||||
|
||||
def print_header():
|
||||
w = 60
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print(f"\033[1;1H", end="")
|
||||
print(f" \033[1;38;5;231m╔{'═' * (w - 6)}╗\033[0m")
|
||||
print(
|
||||
f" \033[1;38;5;231m║\033[0m \033[1;38;5;82mMAINLINE\033[0m \033[3;38;5;245mCommand Center\033[0m \033[1;38;5;231m ║\033[0m"
|
||||
)
|
||||
print(f" \033[1;38;5;231m╚{'═' * (w - 6)}╝\033[0m")
|
||||
print(f" \033[2;38;5;37mCMD: {CC_CMD_TOPIC.split('/')[-2]}\033[0m")
|
||||
print(f" \033[2;38;5;37mRESP: {CC_RESP_TOPIC.split('/')[-2]}\033[0m")
|
||||
print()
|
||||
|
||||
|
||||
def print_response(response: str, is_error: bool = False) -> None:
|
||||
"""Print response with nice formatting."""
|
||||
print()
|
||||
if is_error:
|
||||
print(f" \033[1;38;5;196m✗ Error\033[0m")
|
||||
print(f" \033[38;5;196m{'─' * 40}\033[0m")
|
||||
else:
|
||||
print(f" \033[1;38;5;82m✓ Response\033[0m")
|
||||
print(f" \033[38;5;37m{'─' * 40}\033[0m")
|
||||
|
||||
for line in response.split("\n"):
|
||||
print(f" {line}")
|
||||
print()
|
||||
|
||||
|
||||
def interactive_mode():
|
||||
"""Interactive TUI for sending commands."""
|
||||
import readline
|
||||
|
||||
print_header()
|
||||
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||
|
||||
print(f" \033[38;5;245mType /help for commands, /quit to exit\033[0m")
|
||||
print()
|
||||
|
||||
while True:
|
||||
try:
|
||||
cmd = input(f" \033[1;38;5;82m❯\033[0m {G_HI}").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
|
||||
if not cmd:
|
||||
continue
|
||||
|
||||
if cmd.startswith("/"):
|
||||
if cmd == "/quit" or cmd == "/exit":
|
||||
print(f"\n \033[1;38;5;245mGoodbye!{RST}\n")
|
||||
break
|
||||
|
||||
if cmd == "/help":
|
||||
print(f"\n{AVAILABLE_COMMANDS}\n")
|
||||
continue
|
||||
|
||||
print(f" \033[38;5;245m⟳ Sending to mainline...{RST}")
|
||||
result = poller.send_and_wait(cmd)
|
||||
print_response(result, is_error=result.startswith("Error"))
|
||||
else:
|
||||
print(f"\n \033[1;38;5;196m⚠ Commands must start with /{RST}\n")
|
||||
|
||||
print(CURSOR_ON, end="")
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Mainline command-line interface",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=AVAILABLE_COMMANDS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Command to send (e.g., /effects list)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--watch",
|
||||
"-w",
|
||||
action="store_true",
|
||||
help="Watch mode: continuously poll for stats (Ctrl+C to exit)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command is None:
|
||||
return interactive_mode()
|
||||
|
||||
poller = NtfyResponsePoller(CC_CMD_TOPIC, CC_RESP_TOPIC)
|
||||
|
||||
if args.watch and "/effects stats" in args.command:
|
||||
import signal
|
||||
|
||||
def handle_sigterm(*_):
|
||||
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||
print(CURSOR_ON, end="")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, handle_sigterm)
|
||||
|
||||
print_header()
|
||||
print(f" \033[38;5;245mWatching /effects stats (Ctrl+C to exit)...{RST}\n")
|
||||
try:
|
||||
while True:
|
||||
result = poller.send_and_wait(args.command)
|
||||
print(f"\033[2J\033[1;1H", end="")
|
||||
print(
|
||||
f" \033[1;38;5;82m❯\033[0m Performance Stats - \033[1;38;5;245m{time.strftime('%H:%M:%S')}{RST}"
|
||||
)
|
||||
print(f" \033[38;5;37m{'─' * 44}{RST}")
|
||||
for line in result.split("\n"):
|
||||
print(f" {line}")
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n \033[1;38;5;245mStopped watching{RST}")
|
||||
return 0
|
||||
return 0
|
||||
|
||||
result = poller.send_and_wait(args.command)
|
||||
print(result)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
156
docs/ARCHITECTURE.md
Normal file
156
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Mainline Architecture Diagrams
|
||||
|
||||
> These diagrams use Mermaid. Render with: `npx @mermaid-js/mermaid-cli -i ARCHITECTURE.md` or view in GitHub/GitLab/Notion.
|
||||
|
||||
## Class Hierarchy (Mermaid)
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Stage {
|
||||
<<abstract>>
|
||||
+str name
|
||||
+set[str] capabilities
|
||||
+set[str] dependencies
|
||||
+process(data, ctx) Any
|
||||
}
|
||||
|
||||
Stage <|-- DataSourceStage
|
||||
Stage <|-- CameraStage
|
||||
Stage <|-- FontStage
|
||||
Stage <|-- ViewportFilterStage
|
||||
Stage <|-- EffectPluginStage
|
||||
Stage <|-- DisplayStage
|
||||
Stage <|-- SourceItemsToBufferStage
|
||||
Stage <|-- PassthroughStage
|
||||
Stage <|-- ImageToTextStage
|
||||
Stage <|-- CanvasStage
|
||||
|
||||
class EffectPlugin {
|
||||
<<abstract>>
|
||||
+str name
|
||||
+EffectConfig config
|
||||
+process(buf, ctx) list[str]
|
||||
+configure(config) None
|
||||
}
|
||||
|
||||
EffectPlugin <|-- NoiseEffect
|
||||
EffectPlugin <|-- FadeEffect
|
||||
EffectPlugin <|-- GlitchEffect
|
||||
EffectPlugin <|-- FirehoseEffect
|
||||
EffectPlugin <|-- CropEffect
|
||||
EffectPlugin <|-- TintEffect
|
||||
|
||||
class Display {
|
||||
<<protocol>>
|
||||
+int width
|
||||
+int height
|
||||
+init(width, height, reuse)
|
||||
+show(buffer, border)
|
||||
+clear() None
|
||||
+cleanup() None
|
||||
}
|
||||
|
||||
Display <|.. TerminalDisplay
|
||||
Display <|.. NullDisplay
|
||||
Display <|.. PygameDisplay
|
||||
Display <|.. WebSocketDisplay
|
||||
Display <|.. SixelDisplay
|
||||
|
||||
class Camera {
|
||||
+int viewport_width
|
||||
+int viewport_height
|
||||
+CameraMode mode
|
||||
+apply(buffer, width, height) list[str]
|
||||
}
|
||||
|
||||
class Pipeline {
|
||||
+dict[str, Stage] stages
|
||||
+PipelineContext context
|
||||
+execute(data) StageResult
|
||||
}
|
||||
|
||||
Pipeline --> Stage
|
||||
Stage --> Display
|
||||
```
|
||||
|
||||
## Data Flow (Mermaid)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
DataSource[Data Source] --> DataSourceStage
|
||||
DataSourceStage --> FontStage
|
||||
FontStage --> CameraStage
|
||||
CameraStage --> EffectStages
|
||||
EffectStages --> DisplayStage
|
||||
DisplayStage --> TerminalDisplay
|
||||
DisplayStage --> BrowserWebSocket
|
||||
DisplayStage --> SixelDisplay
|
||||
DisplayStage --> NullDisplay
|
||||
```
|
||||
|
||||
## Effect Chain (Mermaid)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
InputBuffer --> NoiseEffect
|
||||
NoiseEffect --> FadeEffect
|
||||
FadeEffect --> GlitchEffect
|
||||
GlitchEffect --> FirehoseEffect
|
||||
FirehoseEffect --> Output
|
||||
```
|
||||
|
||||
> **Note:** Each effect must preserve buffer dimensions (line count and visible width).
|
||||
|
||||
## Stage Capabilities
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "Capability Resolution"
|
||||
D[DataSource<br/>provides: source.*]
|
||||
C[Camera<br/>provides: render.output]
|
||||
E[Effects<br/>provides: render.effect]
|
||||
DIS[Display<br/>provides: display.output]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy ASCII Diagrams
|
||||
|
||||
### Stage Inheritance
|
||||
```
|
||||
Stage(ABC)
|
||||
├── DataSourceStage
|
||||
├── CameraStage
|
||||
├── FontStage
|
||||
├── ViewportFilterStage
|
||||
├── EffectPluginStage
|
||||
├── DisplayStage
|
||||
├── SourceItemsToBufferStage
|
||||
├── PassthroughStage
|
||||
├── ImageToTextStage
|
||||
└── CanvasStage
|
||||
```
|
||||
|
||||
### Display Backends
|
||||
```
|
||||
Display(Protocol)
|
||||
├── TerminalDisplay
|
||||
├── NullDisplay
|
||||
├── PygameDisplay
|
||||
├── WebSocketDisplay
|
||||
├── SixelDisplay
|
||||
├── KittyDisplay
|
||||
└── MultiDisplay
|
||||
```
|
||||
|
||||
### Camera Modes
|
||||
```
|
||||
Camera
|
||||
├── FEED # Static view
|
||||
├── SCROLL # Horizontal scroll
|
||||
├── VERTICAL # Vertical scroll
|
||||
├── HORIZONTAL # Same as scroll
|
||||
├── OMNI # Omnidirectional
|
||||
├── FLOATING # Floating particles
|
||||
└── BOUNCE # Bouncing camera
|
||||
199
docs/PIPELINE.md
Normal file
199
docs/PIPELINE.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# Mainline Pipeline
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Sources (static/dynamic) → Fetch → Prepare → Scroll → Effects → Render → Display
|
||||
↓
|
||||
NtfyPoller ← MicMonitor (async)
|
||||
```
|
||||
|
||||
### Data Source Abstraction (sources_v2.py)
|
||||
|
||||
- **Static sources**: Data fetched once and cached (HeadlinesDataSource, PoetryDataSource)
|
||||
- **Dynamic sources**: Idempotent fetch for runtime updates (PipelineDataSource)
|
||||
- **SourceRegistry**: Discovery and management of data sources
|
||||
|
||||
### Camera Modes
|
||||
|
||||
- **Vertical**: Scroll up (default)
|
||||
- **Horizontal**: Scroll left
|
||||
- **Omni**: Diagonal scroll
|
||||
- **Floating**: Sinusoidal bobbing
|
||||
- **Trace**: Follow network path node-by-node (for pipeline viz)
|
||||
|
||||
## Content to Display Rendering Pipeline
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Sources["Data Sources (v2)"]
|
||||
Headlines[HeadlinesDataSource]
|
||||
Poetry[PoetryDataSource]
|
||||
Pipeline[PipelineDataSource]
|
||||
Registry[SourceRegistry]
|
||||
end
|
||||
|
||||
subgraph SourcesLegacy["Data Sources (legacy)"]
|
||||
RSS[("RSS Feeds")]
|
||||
PoetryFeed[("Poetry Feed")]
|
||||
Ntfy[("Ntfy Messages")]
|
||||
Mic[("Microphone")]
|
||||
end
|
||||
|
||||
subgraph Fetch["Fetch Layer"]
|
||||
FC[fetch_all]
|
||||
FP[fetch_poetry]
|
||||
Cache[(Cache)]
|
||||
end
|
||||
|
||||
subgraph Prepare["Prepare Layer"]
|
||||
MB[make_block]
|
||||
Strip[strip_tags]
|
||||
Trans[translate]
|
||||
end
|
||||
|
||||
subgraph Scroll["Scroll Engine"]
|
||||
SC[StreamController]
|
||||
CAM[Camera]
|
||||
RTZ[render_ticker_zone]
|
||||
Msg[render_message_overlay]
|
||||
Grad[lr_gradient]
|
||||
VT[vis_trunc / vis_offset]
|
||||
end
|
||||
|
||||
subgraph Effects["Effect Pipeline"]
|
||||
subgraph EffectsPlugins["Effect Plugins"]
|
||||
Noise[NoiseEffect]
|
||||
Fade[FadeEffect]
|
||||
Glitch[GlitchEffect]
|
||||
Firehose[FirehoseEffect]
|
||||
Hud[HudEffect]
|
||||
end
|
||||
EC[EffectChain]
|
||||
ER[EffectRegistry]
|
||||
end
|
||||
|
||||
subgraph Render["Render Layer"]
|
||||
BW[big_wrap]
|
||||
RL[render_line]
|
||||
end
|
||||
|
||||
subgraph Display["Display Backends"]
|
||||
TD[TerminalDisplay]
|
||||
PD[PygameDisplay]
|
||||
SD[SixelDisplay]
|
||||
KD[KittyDisplay]
|
||||
WSD[WebSocketDisplay]
|
||||
ND[NullDisplay]
|
||||
end
|
||||
|
||||
subgraph Async["Async Sources"]
|
||||
NTFY[NtfyPoller]
|
||||
MIC[MicMonitor]
|
||||
end
|
||||
|
||||
subgraph Animation["Animation System"]
|
||||
AC[AnimationController]
|
||||
PR[Preset]
|
||||
end
|
||||
|
||||
Sources --> Fetch
|
||||
RSS --> FC
|
||||
PoetryFeed --> FP
|
||||
FC --> Cache
|
||||
FP --> Cache
|
||||
Cache --> MB
|
||||
Strip --> MB
|
||||
Trans --> MB
|
||||
MB --> SC
|
||||
NTFY --> SC
|
||||
SC --> RTZ
|
||||
CAM --> RTZ
|
||||
Grad --> RTZ
|
||||
VT --> RTZ
|
||||
RTZ --> EC
|
||||
EC --> ER
|
||||
ER --> EffectsPlugins
|
||||
EffectsPlugins --> BW
|
||||
BW --> RL
|
||||
RL --> Display
|
||||
Ntfy --> RL
|
||||
Mic --> RL
|
||||
MIC --> RL
|
||||
|
||||
style Sources fill:#f9f,stroke:#333
|
||||
style Fetch fill:#bbf,stroke:#333
|
||||
style Prepare fill:#bff,stroke:#333
|
||||
style Scroll fill:#bfb,stroke:#333
|
||||
style Effects fill:#fbf,stroke:#333
|
||||
style Render fill:#ffb,stroke:#333
|
||||
style Display fill:#bbf,stroke:#333
|
||||
style Async fill:#fbb,stroke:#333
|
||||
style Animation fill:#bfb,stroke:#333
|
||||
```
|
||||
|
||||
## Animation & Presets
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Preset["Preset"]
|
||||
PP[PipelineParams]
|
||||
AC[AnimationController]
|
||||
end
|
||||
|
||||
subgraph AnimationController["AnimationController"]
|
||||
Clock[Clock]
|
||||
Events[Events]
|
||||
Triggers[Triggers]
|
||||
end
|
||||
|
||||
subgraph Triggers["Trigger Types"]
|
||||
TIME[TIME]
|
||||
FRAME[FRAME]
|
||||
CYCLE[CYCLE]
|
||||
COND[CONDITION]
|
||||
MANUAL[MANUAL]
|
||||
end
|
||||
|
||||
PP --> AC
|
||||
Clock --> AC
|
||||
Events --> AC
|
||||
Triggers --> Events
|
||||
```
|
||||
|
||||
## Camera Modes
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Vertical
|
||||
Vertical --> Horizontal: mode change
|
||||
Horizontal --> Omni: mode change
|
||||
Omni --> Floating: mode change
|
||||
Floating --> Trace: mode change
|
||||
Trace --> Vertical: mode change
|
||||
|
||||
state Vertical {
|
||||
[*] --> ScrollUp
|
||||
ScrollUp --> ScrollUp: +y each frame
|
||||
}
|
||||
|
||||
state Horizontal {
|
||||
[*] --> ScrollLeft
|
||||
ScrollLeft --> ScrollLeft: +x each frame
|
||||
}
|
||||
|
||||
state Omni {
|
||||
[*] --> Diagonal
|
||||
Diagonal --> Diagonal: +x, +y each frame
|
||||
}
|
||||
|
||||
state Floating {
|
||||
[*] --> Bobbing
|
||||
Bobbing --> Bobbing: sin(time) for x,y
|
||||
}
|
||||
|
||||
state Trace {
|
||||
[*] --> FollowPath
|
||||
FollowPath --> FollowPath: node by node
|
||||
}
|
||||
```
|
||||
@@ -1,11 +1,18 @@
|
||||
# Refactor mainline\.py into modular package
|
||||
#
|
||||
|
||||
Refactor mainline\.py into modular package
|
||||
|
||||
## Problem
|
||||
|
||||
`mainline.py` is a single 1085\-line file with ~10 interleaved concerns\. This prevents:
|
||||
|
||||
* Reusing the ntfy doorbell interrupt in other visualizers
|
||||
* Importing the render pipeline from `serve.py` \(future ESP32 HTTP server\)
|
||||
* Testing any concern in isolation
|
||||
* Porting individual layers to Rust independently
|
||||
|
||||
## Target structure
|
||||
|
||||
```warp-runnable-command
|
||||
mainline.py # thin entrypoint: venv bootstrap → engine.app.main()
|
||||
engine/
|
||||
@@ -23,8 +30,11 @@ engine/
|
||||
scroll.py # stream() frame loop + message rendering
|
||||
app.py # main(), TITLE art, boot sequence, signal handler
|
||||
```
|
||||
|
||||
The package is named `engine/` to avoid a naming conflict with the `mainline.py` entrypoint\.
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
```warp-runnable-command
|
||||
config ← (nothing)
|
||||
sources ← (nothing)
|
||||
@@ -39,64 +49,92 @@ mic ← (nothing — sounddevice only)
|
||||
scroll ← config, terminal, render, effects, ntfy, mic
|
||||
app ← everything above
|
||||
```
|
||||
|
||||
Critical property: **ntfy\.py and mic\.py have zero internal dependencies**, making ntfy reusable by any visualizer\.
|
||||
|
||||
## Module details
|
||||
|
||||
### mainline\.py \(entrypoint — slimmed down\)
|
||||
|
||||
Keeps only the venv bootstrap \(lines 10\-38\) which must run before any third\-party imports\. After bootstrap, delegates to `engine.app.main()`\.
|
||||
|
||||
### engine/config\.py
|
||||
|
||||
From current mainline\.py:
|
||||
|
||||
* `HEADLINE_LIMIT`, `FEED_TIMEOUT`, `MIC_THRESHOLD_DB` \(lines 55\-57\)
|
||||
* `MODE`, `FIREHOSE` CLI flag parsing \(lines 58\-59\)
|
||||
* `NTFY_TOPIC`, `NTFY_POLL_INTERVAL`, `MESSAGE_DISPLAY_SECS` \(lines 62\-64\)
|
||||
* `_FONT_PATH`, `_FONT_SZ`, `_RENDER_H` \(lines 147\-150\)
|
||||
* `_SCROLL_DUR`, `_FRAME_DT`, `FIREHOSE_H` \(lines 505\-507\)
|
||||
* `GLITCH`, `KATA` glyph tables \(lines 143\-144\)
|
||||
|
||||
### engine/sources\.py
|
||||
|
||||
Pure data, no logic:
|
||||
|
||||
* `FEEDS` dict \(lines 102\-140\)
|
||||
* `POETRY_SOURCES` dict \(lines 67\-80\)
|
||||
* `SOURCE_LANGS` dict \(lines 258\-266\)
|
||||
* `_LOCATION_LANGS` dict \(lines 269\-289\)
|
||||
* `_SCRIPT_FONTS` dict \(lines 153\-165\)
|
||||
* `_NO_UPPER` set \(line 167\)
|
||||
|
||||
### engine/terminal\.py
|
||||
|
||||
ANSI primitives and terminal I/O:
|
||||
|
||||
* All ANSI constants: `RST`, `BOLD`, `DIM`, `G_HI`, `G_MID`, `G_LO`, `G_DIM`, `W_COOL`, `W_DIM`, `W_GHOST`, `C_DIM`, `CLR`, `CURSOR_OFF`, `CURSOR_ON` \(lines 83\-99\)
|
||||
* `tw()`, `th()` \(lines 223\-234\)
|
||||
* `type_out()`, `slow_print()`, `boot_ln()` \(lines 355\-386\)
|
||||
|
||||
### engine/filter\.py
|
||||
|
||||
* `_Strip` HTML parser class \(lines 205\-214\)
|
||||
* `strip_tags()` \(lines 217\-220\)
|
||||
* `_SKIP_RE` compiled regex \(lines 322\-346\)
|
||||
* `_skip()` predicate \(lines 349\-351\)
|
||||
|
||||
### engine/translate\.py
|
||||
|
||||
* `_TRANSLATE_CACHE` \(line 291\)
|
||||
* `_detect_location_language()` \(lines 294\-300\) — imports `_LOCATION_LANGS` from sources
|
||||
* `_translate_headline()` \(lines 303\-319\)
|
||||
|
||||
### engine/render\.py
|
||||
|
||||
The OTF→terminal pipeline\. This is exactly what `serve.py` will import to produce 1\-bit bitmaps for the ESP32\.
|
||||
|
||||
* `_GRAD_COLS` gradient table \(lines 169\-182\)
|
||||
* `_font()`, `_font_for_lang()` with lazy\-load \+ cache \(lines 185\-202\)
|
||||
* `_render_line()` — OTF text → half\-block terminal rows \(lines 567\-605\)
|
||||
* `_big_wrap()` — word\-wrap \+ render \(lines 608\-636\)
|
||||
* `_lr_gradient()` — apply left→right color gradient \(lines 639\-656\)
|
||||
* `_make_block()` — composite: translate → render → colorize a headline \(lines 718\-756\)\. Imports from translate, sources\.
|
||||
|
||||
### engine/effects\.py
|
||||
|
||||
Visual effects applied during the frame loop:
|
||||
|
||||
* `noise()` \(lines 237\-245\)
|
||||
* `glitch_bar()` \(lines 248\-252\)
|
||||
* `_fade_line()` — probabilistic character dissolve \(lines 659\-680\)
|
||||
* `_vis_trunc()` — ANSI\-aware width truncation \(lines 683\-701\)
|
||||
* `_firehose_line()` \(lines 759\-801\) — imports config\.MODE, sources\.FEEDS/POETRY\_SOURCES
|
||||
* `_next_headline()` — pool management \(lines 704\-715\)
|
||||
|
||||
### engine/fetch\.py
|
||||
|
||||
* `fetch_feed()` \(lines 390\-396\)
|
||||
* `fetch_all()` \(lines 399\-426\) — imports filter\.\_skip, filter\.strip\_tags, terminal\.boot\_ln
|
||||
* `_fetch_gutenberg()` \(lines 429\-456\)
|
||||
* `fetch_poetry()` \(lines 459\-472\)
|
||||
* `_cache_path()`, `_load_cache()`, `_save_cache()` \(lines 476\-501\)
|
||||
|
||||
### engine/ntfy\.py — standalone, reusable
|
||||
|
||||
Refactored from the current globals \+ thread \(lines 531\-564\) and the message rendering section of `stream()` \(lines 845\-909\) into a class:
|
||||
|
||||
```python
|
||||
class NtfyPoller:
|
||||
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
||||
@@ -108,8 +146,10 @@ class NtfyPoller:
|
||||
def dismiss(self):
|
||||
"""Manually dismiss current message."""
|
||||
```
|
||||
|
||||
Dependencies: `urllib.request`, `json`, `threading`, `time` — all stdlib\. No internal imports\.
|
||||
Other visualizers use it like:
|
||||
|
||||
```python
|
||||
from engine.ntfy import NtfyPoller
|
||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||
@@ -120,8 +160,11 @@ if msg:
|
||||
title, body, ts = msg
|
||||
render_my_message(title, body) # visualizer-specific
|
||||
```
|
||||
|
||||
### engine/mic\.py — standalone
|
||||
|
||||
Refactored from the current globals \(lines 508\-528\) into a class:
|
||||
|
||||
```python
|
||||
class MicMonitor:
|
||||
def __init__(self, threshold_db=50):
|
||||
@@ -137,41 +180,75 @@ class MicMonitor:
|
||||
def excess(self) -> float:
|
||||
"""dB above threshold (clamped to 0)."""
|
||||
```
|
||||
|
||||
Dependencies: `sounddevice`, `numpy` \(both optional — graceful fallback\)\.
|
||||
|
||||
### engine/scroll\.py
|
||||
|
||||
The `stream()` function \(lines 804\-990\)\. Receives its dependencies via arguments or imports:
|
||||
|
||||
* `stream(items, ntfy_poller, mic_monitor, config)` or similar
|
||||
* Message rendering \(lines 855\-909\) stays here since it's terminal\-display\-specific — a different visualizer would render messages differently
|
||||
|
||||
### engine/app\.py
|
||||
|
||||
The orchestrator:
|
||||
|
||||
* `TITLE` ASCII art \(lines 994\-1001\)
|
||||
* `main()` \(lines 1004\-1084\): CLI handling, signal setup, boot animation, fetch, wire up ntfy/mic/scroll
|
||||
|
||||
## Execution order
|
||||
|
||||
### Step 1: Create engine/ package skeleton
|
||||
|
||||
Create `engine/__init__.py` and all empty module files\.
|
||||
|
||||
### Step 2: Extract pure data modules \(zero\-dep\)
|
||||
|
||||
Move constants and data dicts into `config.py`, `sources.py`\. These have no logic dependencies\.
|
||||
|
||||
### Step 3: Extract terminal\.py
|
||||
|
||||
Move ANSI codes and terminal I/O helpers\. No internal deps\.
|
||||
|
||||
### Step 4: Extract filter\.py and translate\.py
|
||||
|
||||
Both are small, self\-contained\. translate imports from sources\.
|
||||
|
||||
### Step 5: Extract render\.py
|
||||
|
||||
Font loading \+ the OTF→half\-block pipeline\. Imports from config, terminal, sources\. This is the module `serve.py` will later import\.
|
||||
|
||||
### Step 6: Extract effects\.py
|
||||
|
||||
Visual effects\. Imports from config, terminal, sources\.
|
||||
|
||||
### Step 7: Extract fetch\.py
|
||||
|
||||
Feed/Gutenberg fetching \+ caching\. Imports from config, sources, filter, terminal\.
|
||||
|
||||
### Step 8: Extract ntfy\.py and mic\.py
|
||||
|
||||
Refactor globals\+threads into classes\. Zero internal deps\.
|
||||
|
||||
### Step 9: Extract scroll\.py
|
||||
|
||||
The frame loop\. Last to extract because it depends on everything above\.
|
||||
|
||||
### Step 10: Extract app\.py
|
||||
|
||||
The `main()` function, boot sequence, signal handler\. Wire up all modules\.
|
||||
|
||||
### Step 11: Slim down mainline\.py
|
||||
|
||||
Keep only venv bootstrap \+ `from engine.app import main; main()`\.
|
||||
|
||||
### Step 12: Verify
|
||||
|
||||
Run `python3 mainline.py`, `python3 mainline.py --poetry`, and `python3 mainline.py --firehose` to confirm identical behavior\. No behavioral changes in this refactor\.
|
||||
|
||||
## What this enables
|
||||
|
||||
* **serve\.py** \(future\): `from engine.render import _render_line, _big_wrap` \+ `from engine.fetch import fetch_all` — imports the pipeline directly
|
||||
* **Other visualizers**: `from engine.ntfy import NtfyPoller` — doorbell feature with no coupling to mainline's scroll engine
|
||||
* **Rust port**: Clear boundaries for what to port first \(ntfy client, render pipeline\) vs what stays in Python \(fetching, caching — the server side\)
|
||||
590
engine/app.py
590
engine/app.py
@@ -1,352 +1,282 @@
|
||||
"""
|
||||
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
||||
Application orchestrator — pipeline mode entry point.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
import tty
|
||||
|
||||
from engine import config, render
|
||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||
from engine.mic import MicMonitor
|
||||
from engine.ntfy import NtfyPoller
|
||||
from engine.scroll import stream
|
||||
from engine.terminal import (
|
||||
CLR,
|
||||
CURSOR_OFF,
|
||||
CURSOR_ON,
|
||||
G_DIM,
|
||||
G_HI,
|
||||
G_MID,
|
||||
RST,
|
||||
W_DIM,
|
||||
W_GHOST,
|
||||
boot_ln,
|
||||
slow_print,
|
||||
tw,
|
||||
import engine.effects.plugins as effects_plugins
|
||||
from engine import config
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.effects import PerformanceMonitor, get_registry, set_monitor
|
||||
from engine.fetch import fetch_all, fetch_poetry, load_cache
|
||||
from engine.pipeline import (
|
||||
Pipeline,
|
||||
PipelineConfig,
|
||||
get_preset,
|
||||
list_presets,
|
||||
)
|
||||
from engine.pipeline.adapters import (
|
||||
SourceItemsToBufferStage,
|
||||
create_stage_from_display,
|
||||
create_stage_from_effect,
|
||||
)
|
||||
|
||||
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))
|
||||
"""Main entry point - all modes now use presets."""
|
||||
if config.PIPELINE_DIAGRAM:
|
||||
try:
|
||||
from engine.pipeline import generate_pipeline_diagram
|
||||
except ImportError:
|
||||
print("Error: pipeline diagram not available")
|
||||
return
|
||||
print(generate_pipeline_diagram())
|
||||
return
|
||||
|
||||
def handle_sigint(*_):
|
||||
print(f"\n\n {G_DIM}> SIGNAL LOST{RST}")
|
||||
print(f" {W_GHOST}> connection terminated{RST}\n")
|
||||
sys.exit(0)
|
||||
preset_name = None
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
pick_font_face()
|
||||
w = tw()
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
for ln in TITLE:
|
||||
print(f"{G_HI}{ln}{RST}")
|
||||
time.sleep(0.07)
|
||||
|
||||
print()
|
||||
_subtitle = (
|
||||
"literary consciousness stream"
|
||||
if config.MODE == "poetry"
|
||||
else "digital consciousness stream"
|
||||
)
|
||||
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
cached = load_cache() if "--refresh" not in sys.argv else None
|
||||
if cached:
|
||||
items = cached
|
||||
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
|
||||
elif config.MODE == "poetry":
|
||||
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
|
||||
time.sleep(0.2)
|
||||
print()
|
||||
items, linked, failed = fetch_poetry()
|
||||
print()
|
||||
print(
|
||||
f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||
)
|
||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
||||
save_cache(items)
|
||||
if config.PRESET:
|
||||
preset_name = config.PRESET
|
||||
elif config.PIPELINE_MODE:
|
||||
preset_name = config.PIPELINE_PRESET
|
||||
else:
|
||||
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
||||
time.sleep(0.2)
|
||||
print()
|
||||
items, linked, failed = fetch_all()
|
||||
print()
|
||||
print(
|
||||
f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||
)
|
||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
|
||||
save_cache(items)
|
||||
preset_name = "demo"
|
||||
|
||||
if not items:
|
||||
print(f"\n {W_DIM}> NO SIGNAL — check network{RST}")
|
||||
available = list_presets()
|
||||
if preset_name not in available:
|
||||
print(f"Error: Unknown preset '{preset_name}'")
|
||||
print(f"Available presets: {', '.join(available)}")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||
mic_ok = mic.start()
|
||||
if mic.available:
|
||||
boot_ln(
|
||||
"Microphone",
|
||||
"ACTIVE"
|
||||
if mic_ok
|
||||
else "OFFLINE · check System Settings → Privacy → Microphone",
|
||||
bool(mic_ok),
|
||||
run_pipeline_mode(preset_name)
|
||||
|
||||
|
||||
def run_pipeline_mode(preset_name: str = "demo"):
|
||||
"""Run using the new unified pipeline architecture."""
|
||||
print(" \033[1;38;5;46mPIPELINE MODE\033[0m")
|
||||
print(" \033[38;5;245mUsing unified pipeline architecture\033[0m")
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
set_monitor(monitor)
|
||||
|
||||
preset = get_preset(preset_name)
|
||||
if not preset:
|
||||
print(f" \033[38;5;196mUnknown preset: {preset_name}\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
print(f" \033[38;5;245mPreset: {preset.name} - {preset.description}\033[0m")
|
||||
|
||||
params = preset.to_params()
|
||||
params.viewport_width = 80
|
||||
params.viewport_height = 24
|
||||
|
||||
pipeline = Pipeline(
|
||||
config=PipelineConfig(
|
||||
source=preset.source,
|
||||
display=preset.display,
|
||||
camera=preset.camera,
|
||||
effects=preset.effects,
|
||||
)
|
||||
|
||||
ntfy = NtfyPoller(
|
||||
config.NTFY_TOPIC,
|
||||
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
||||
display_secs=config.MESSAGE_DISPLAY_SECS,
|
||||
)
|
||||
ntfy_ok = ntfy.start()
|
||||
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||
|
||||
if config.FIREHOSE:
|
||||
boot_ln("Firehose", "ENGAGED", True)
|
||||
print(" \033[38;5;245mFetching content...\033[0m")
|
||||
|
||||
time.sleep(0.4)
|
||||
slow_print(" > STREAMING...\n")
|
||||
time.sleep(0.2)
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
# Handle special sources that don't need traditional fetching
|
||||
introspection_source = None
|
||||
if preset.source == "pipeline-inspect":
|
||||
items = []
|
||||
print(" \033[38;5;245mUsing pipeline introspection source\033[0m")
|
||||
elif preset.source == "empty":
|
||||
items = []
|
||||
print(" \033[38;5;245mUsing empty source (no content)\033[0m")
|
||||
else:
|
||||
cached = load_cache()
|
||||
if cached:
|
||||
items = cached
|
||||
elif preset.source == "poetry":
|
||||
items, _, _ = fetch_poetry()
|
||||
else:
|
||||
items, _, _ = fetch_all()
|
||||
|
||||
stream(items, ntfy, mic)
|
||||
if not items:
|
||||
print(" \033[38;5;196mNo content available\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||
print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
||||
print(f" {W_GHOST}> end of stream{RST}")
|
||||
print()
|
||||
print(f" \033[38;5;82mLoaded {len(items)} items\033[0m")
|
||||
|
||||
# CLI --display flag takes priority over preset
|
||||
# Check if --display was explicitly provided
|
||||
display_name = preset.display
|
||||
if "--display" in sys.argv:
|
||||
idx = sys.argv.index("--display")
|
||||
if idx + 1 < len(sys.argv):
|
||||
display_name = sys.argv[idx + 1]
|
||||
|
||||
display = DisplayRegistry.create(display_name)
|
||||
if not display and not display_name.startswith("multi"):
|
||||
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
# Handle multi display (format: "multi:terminal,pygame")
|
||||
if not display and display_name.startswith("multi"):
|
||||
parts = display_name[6:].split(
|
||||
","
|
||||
) # "multi:terminal,pygame" -> ["terminal", "pygame"]
|
||||
display = DisplayRegistry.create_multi(parts)
|
||||
if not display:
|
||||
print(f" \033[38;5;196mFailed to create multi display: {parts}\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
if not display:
|
||||
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
display.init(0, 0)
|
||||
|
||||
effect_registry = get_registry()
|
||||
|
||||
# Create source stage based on preset source type
|
||||
if preset.source == "pipeline-inspect":
|
||||
from engine.data_sources.pipeline_introspection import (
|
||||
PipelineIntrospectionSource,
|
||||
)
|
||||
from engine.pipeline.adapters import DataSourceStage
|
||||
|
||||
introspection_source = PipelineIntrospectionSource(
|
||||
pipeline=None, # Will be set after pipeline.build()
|
||||
viewport_width=80,
|
||||
viewport_height=24,
|
||||
)
|
||||
pipeline.add_stage(
|
||||
"source", DataSourceStage(introspection_source, name="pipeline-inspect")
|
||||
)
|
||||
elif preset.source == "empty":
|
||||
from engine.data_sources.sources import EmptyDataSource
|
||||
from engine.pipeline.adapters import DataSourceStage
|
||||
|
||||
empty_source = EmptyDataSource(width=80, height=24)
|
||||
pipeline.add_stage("source", DataSourceStage(empty_source, name="empty"))
|
||||
else:
|
||||
from engine.data_sources.sources import ListDataSource
|
||||
from engine.pipeline.adapters import DataSourceStage
|
||||
|
||||
list_source = ListDataSource(items, name=preset.source)
|
||||
pipeline.add_stage("source", DataSourceStage(list_source, name=preset.source))
|
||||
|
||||
# Add FontStage for headlines/poetry (default for demo)
|
||||
if preset.source in ["headlines", "poetry"]:
|
||||
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
||||
|
||||
# Add viewport filter to prevent rendering all items
|
||||
pipeline.add_stage(
|
||||
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
||||
)
|
||||
pipeline.add_stage("font", FontStage(name="font"))
|
||||
else:
|
||||
# Fallback to simple conversion for other sources
|
||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
||||
|
||||
# Add camera stage if specified in preset
|
||||
if preset.camera:
|
||||
from engine.camera import Camera
|
||||
from engine.pipeline.adapters import CameraStage
|
||||
|
||||
camera = None
|
||||
speed = getattr(preset, "camera_speed", 1.0)
|
||||
if preset.camera == "feed":
|
||||
camera = Camera.feed(speed=speed)
|
||||
elif preset.camera == "scroll":
|
||||
camera = Camera.scroll(speed=speed)
|
||||
elif preset.camera == "vertical":
|
||||
camera = Camera.scroll(speed=speed) # Backwards compat
|
||||
elif preset.camera == "horizontal":
|
||||
camera = Camera.horizontal(speed=speed)
|
||||
elif preset.camera == "omni":
|
||||
camera = Camera.omni(speed=speed)
|
||||
elif preset.camera == "floating":
|
||||
camera = Camera.floating(speed=speed)
|
||||
elif preset.camera == "bounce":
|
||||
camera = Camera.bounce(speed=speed)
|
||||
|
||||
if camera:
|
||||
pipeline.add_stage("camera", CameraStage(camera, name=preset.camera))
|
||||
|
||||
for effect_name in preset.effects:
|
||||
effect = effect_registry.get(effect_name)
|
||||
if effect:
|
||||
pipeline.add_stage(
|
||||
f"effect_{effect_name}", create_stage_from_effect(effect, effect_name)
|
||||
)
|
||||
|
||||
pipeline.add_stage("display", create_stage_from_display(display, display_name))
|
||||
|
||||
pipeline.build()
|
||||
|
||||
# For pipeline-inspect, set the pipeline after build to avoid circular dependency
|
||||
if introspection_source is not None:
|
||||
introspection_source.set_pipeline(pipeline)
|
||||
|
||||
if not pipeline.initialize():
|
||||
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
||||
sys.exit(1)
|
||||
|
||||
print(" \033[38;5;82mStarting pipeline...\033[0m")
|
||||
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
||||
|
||||
ctx = pipeline.context
|
||||
ctx.params = params
|
||||
ctx.set("display", display)
|
||||
ctx.set("items", items)
|
||||
ctx.set("pipeline", pipeline)
|
||||
ctx.set("pipeline_order", pipeline.execution_order)
|
||||
ctx.set("camera_y", 0)
|
||||
|
||||
current_width = 80
|
||||
current_height = 24
|
||||
|
||||
if hasattr(display, "get_dimensions"):
|
||||
current_width, current_height = display.get_dimensions()
|
||||
params.viewport_width = current_width
|
||||
params.viewport_height = current_height
|
||||
|
||||
try:
|
||||
frame = 0
|
||||
while True:
|
||||
params.frame_number = frame
|
||||
ctx.params = params
|
||||
|
||||
result = pipeline.execute(items)
|
||||
if result.success:
|
||||
display.show(result.data, border=params.border)
|
||||
|
||||
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
|
||||
if hasattr(display, "clear_quit_request"):
|
||||
display.clear_quit_request()
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
if hasattr(display, "get_dimensions"):
|
||||
new_w, new_h = display.get_dimensions()
|
||||
if new_w != current_width or new_h != current_height:
|
||||
current_width, current_height = new_w, new_h
|
||||
params.viewport_width = current_width
|
||||
params.viewport_height = current_height
|
||||
|
||||
time.sleep(1 / 60)
|
||||
frame += 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pipeline.cleanup()
|
||||
display.cleanup()
|
||||
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
||||
return
|
||||
|
||||
pipeline.cleanup()
|
||||
display.cleanup()
|
||||
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
73
engine/benchmark.py
Normal file
73
engine/benchmark.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Benchmark module for performance testing.
|
||||
|
||||
Usage:
|
||||
python -m engine.benchmark # Run all benchmarks
|
||||
python -m engine.benchmark --hook # Run benchmarks in hook mode (for CI)
|
||||
python -m engine.benchmark --displays null --iterations 20
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run performance benchmarks")
|
||||
parser.add_argument(
|
||||
"--hook",
|
||||
action="store_true",
|
||||
help="Run in hook mode (fail on regression)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--displays",
|
||||
default="null",
|
||||
help="Comma-separated list of displays to benchmark",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--iterations",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of iterations per benchmark",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Run pytest with benchmark markers
|
||||
pytest_args = [
|
||||
"-v",
|
||||
"-m",
|
||||
"benchmark",
|
||||
]
|
||||
|
||||
if args.hook:
|
||||
# Hook mode: stricter settings
|
||||
pytest_args.extend(
|
||||
[
|
||||
"--benchmark-only",
|
||||
"--benchmark-compare",
|
||||
"--benchmark-compare-fail=min:5%", # Fail if >5% slower
|
||||
]
|
||||
)
|
||||
|
||||
# Add display filter if specified
|
||||
if args.displays:
|
||||
pytest_args.extend(["-k", args.displays])
|
||||
|
||||
# Add iterations
|
||||
if args.iterations:
|
||||
# Set environment variable for benchmark tests
|
||||
import os
|
||||
|
||||
os.environ["BENCHMARK_ITERATIONS"] = str(args.iterations)
|
||||
|
||||
# Run pytest
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "tests/test_benchmark.py"] + pytest_args,
|
||||
cwd=None, # Current directory
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
354
engine/camera.py
Normal file
354
engine/camera.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Camera system for viewport scrolling.
|
||||
|
||||
Provides abstraction for camera motion in different modes:
|
||||
- Vertical: traditional upward scroll
|
||||
- Horizontal: left/right movement
|
||||
- Omni: combination of both
|
||||
- Floating: sinusoidal/bobbing motion
|
||||
|
||||
The camera defines a visible viewport into a larger Canvas.
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class CameraMode(Enum):
|
||||
FEED = auto() # Single item view (static or rapid cycling)
|
||||
SCROLL = auto() # Smooth vertical scrolling (movie credits style)
|
||||
HORIZONTAL = auto()
|
||||
OMNI = auto()
|
||||
FLOATING = auto()
|
||||
BOUNCE = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraViewport:
|
||||
"""Represents the visible viewport."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Camera:
|
||||
"""Camera for viewport scrolling.
|
||||
|
||||
The camera defines a visible viewport into a Canvas.
|
||||
It can be smaller than the canvas to allow scrolling,
|
||||
and supports zoom to scale the view.
|
||||
|
||||
Attributes:
|
||||
x: Current horizontal offset (positive = scroll left)
|
||||
y: Current vertical offset (positive = scroll up)
|
||||
mode: Current camera mode
|
||||
speed: Base scroll speed
|
||||
zoom: Zoom factor (1.0 = 100%, 2.0 = 200% zoom out)
|
||||
canvas_width: Width of the canvas being viewed
|
||||
canvas_height: Height of the canvas being viewed
|
||||
custom_update: Optional custom update function
|
||||
"""
|
||||
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
mode: CameraMode = CameraMode.FEED
|
||||
speed: float = 1.0
|
||||
zoom: float = 1.0
|
||||
canvas_width: int = 200 # Larger than viewport for scrolling
|
||||
canvas_height: int = 200
|
||||
custom_update: Callable[["Camera", float], None] | None = None
|
||||
_x_float: float = field(default=0.0, repr=False)
|
||||
_y_float: float = field(default=0.0, repr=False)
|
||||
_time: float = field(default=0.0, repr=False)
|
||||
|
||||
@property
|
||||
def w(self) -> int:
|
||||
"""Shorthand for viewport_width."""
|
||||
return self.viewport_width
|
||||
|
||||
@property
|
||||
def h(self) -> int:
|
||||
"""Shorthand for viewport_height."""
|
||||
return self.viewport_height
|
||||
|
||||
@property
|
||||
def viewport_width(self) -> int:
|
||||
"""Get the visible viewport width.
|
||||
|
||||
This is the canvas width divided by zoom.
|
||||
"""
|
||||
return max(1, int(self.canvas_width / self.zoom))
|
||||
|
||||
@property
|
||||
def viewport_height(self) -> int:
|
||||
"""Get the visible viewport height.
|
||||
|
||||
This is the canvas height divided by zoom.
|
||||
"""
|
||||
return max(1, int(self.canvas_height / self.zoom))
|
||||
|
||||
def get_viewport(self) -> CameraViewport:
|
||||
"""Get the current viewport bounds.
|
||||
|
||||
Returns:
|
||||
CameraViewport with position and size (clamped to canvas bounds)
|
||||
"""
|
||||
vw = self.viewport_width
|
||||
vh = self.viewport_height
|
||||
|
||||
clamped_x = max(0, min(self.x, self.canvas_width - vw))
|
||||
clamped_y = max(0, min(self.y, self.canvas_height - vh))
|
||||
|
||||
return CameraViewport(
|
||||
x=clamped_x,
|
||||
y=clamped_y,
|
||||
width=vw,
|
||||
height=vh,
|
||||
)
|
||||
|
||||
def set_zoom(self, zoom: float) -> None:
|
||||
"""Set the zoom factor.
|
||||
|
||||
Args:
|
||||
zoom: Zoom factor (1.0 = 100%, 2.0 = zoomed out 2x, 0.5 = zoomed in 2x)
|
||||
"""
|
||||
self.zoom = max(0.1, min(10.0, zoom))
|
||||
|
||||
def update(self, dt: float) -> None:
|
||||
"""Update camera position based on mode.
|
||||
|
||||
Args:
|
||||
dt: Delta time in seconds
|
||||
"""
|
||||
self._time += dt
|
||||
|
||||
if self.custom_update:
|
||||
self.custom_update(self, dt)
|
||||
return
|
||||
|
||||
if self.mode == CameraMode.FEED:
|
||||
self._update_feed(dt)
|
||||
elif self.mode == CameraMode.SCROLL:
|
||||
self._update_scroll(dt)
|
||||
elif self.mode == CameraMode.HORIZONTAL:
|
||||
self._update_horizontal(dt)
|
||||
elif self.mode == CameraMode.OMNI:
|
||||
self._update_omni(dt)
|
||||
elif self.mode == CameraMode.FLOATING:
|
||||
self._update_floating(dt)
|
||||
elif self.mode == CameraMode.BOUNCE:
|
||||
self._update_bounce(dt)
|
||||
|
||||
# Bounce mode handles its own bounds checking
|
||||
if self.mode != CameraMode.BOUNCE:
|
||||
self._clamp_to_bounds()
|
||||
|
||||
def _clamp_to_bounds(self) -> None:
|
||||
"""Clamp camera position to stay within canvas bounds.
|
||||
|
||||
Only clamps if the viewport is smaller than the canvas.
|
||||
If viewport equals canvas (no scrolling needed), allows any position
|
||||
for backwards compatibility with original behavior.
|
||||
"""
|
||||
vw = self.viewport_width
|
||||
vh = self.viewport_height
|
||||
|
||||
# Only clamp if there's room to scroll
|
||||
if vw < self.canvas_width:
|
||||
self.x = max(0, min(self.x, self.canvas_width - vw))
|
||||
if vh < self.canvas_height:
|
||||
self.y = max(0, min(self.y, self.canvas_height - vh))
|
||||
|
||||
def _update_feed(self, dt: float) -> None:
|
||||
"""Feed mode: rapid scrolling (1 row per frame at speed=1.0)."""
|
||||
self.y += int(self.speed * dt * 60)
|
||||
|
||||
def _update_scroll(self, dt: float) -> None:
|
||||
"""Scroll mode: smooth vertical scrolling with float accumulation."""
|
||||
self._y_float += self.speed * dt * 60
|
||||
self.y = int(self._y_float)
|
||||
|
||||
def _update_horizontal(self, dt: float) -> None:
|
||||
self.x += int(self.speed * dt * 60)
|
||||
|
||||
def _update_omni(self, dt: float) -> None:
|
||||
speed = self.speed * dt * 60
|
||||
self.y += int(speed)
|
||||
self.x += int(speed * 0.5)
|
||||
|
||||
def _update_floating(self, dt: float) -> None:
|
||||
base = self.speed * 30
|
||||
self.y = int(math.sin(self._time * 2) * base)
|
||||
self.x = int(math.cos(self._time * 1.5) * base * 0.5)
|
||||
|
||||
def _update_bounce(self, dt: float) -> None:
|
||||
"""Bouncing DVD-style camera that bounces off canvas edges."""
|
||||
vw = self.viewport_width
|
||||
vh = self.viewport_height
|
||||
|
||||
# Initialize direction if not set
|
||||
if not hasattr(self, "_bounce_dx"):
|
||||
self._bounce_dx = 1
|
||||
self._bounce_dy = 1
|
||||
|
||||
# Calculate max positions
|
||||
max_x = max(0, self.canvas_width - vw)
|
||||
max_y = max(0, self.canvas_height - vh)
|
||||
|
||||
# Move
|
||||
move_speed = self.speed * dt * 60
|
||||
|
||||
# Bounce off edges - reverse direction when hitting bounds
|
||||
self.x += int(move_speed * self._bounce_dx)
|
||||
self.y += int(move_speed * self._bounce_dy)
|
||||
|
||||
# Bounce horizontally
|
||||
if self.x <= 0:
|
||||
self.x = 0
|
||||
self._bounce_dx = 1
|
||||
elif self.x >= max_x:
|
||||
self.x = max_x
|
||||
self._bounce_dx = -1
|
||||
|
||||
# Bounce vertically
|
||||
if self.y <= 0:
|
||||
self.y = 0
|
||||
self._bounce_dy = 1
|
||||
elif self.y >= max_y:
|
||||
self.y = max_y
|
||||
self._bounce_dy = -1
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset camera position."""
|
||||
self.x = 0
|
||||
self.y = 0
|
||||
self._time = 0.0
|
||||
self.zoom = 1.0
|
||||
|
||||
def set_canvas_size(self, width: int, height: int) -> None:
|
||||
"""Set the canvas size and clamp position if needed.
|
||||
|
||||
Args:
|
||||
width: New canvas width
|
||||
height: New canvas height
|
||||
"""
|
||||
self.canvas_width = width
|
||||
self.canvas_height = height
|
||||
self._clamp_to_bounds()
|
||||
|
||||
def apply(
|
||||
self, buffer: list[str], viewport_width: int, viewport_height: int | None = None
|
||||
) -> list[str]:
|
||||
"""Apply camera viewport to a text buffer.
|
||||
|
||||
Slices the buffer based on camera position (x, y) and viewport dimensions.
|
||||
Handles ANSI escape codes correctly for colored/styled text.
|
||||
|
||||
Args:
|
||||
buffer: List of strings representing lines of text
|
||||
viewport_width: Width of the visible viewport in characters
|
||||
viewport_height: Height of the visible viewport (overrides camera's viewport_height if provided)
|
||||
|
||||
Returns:
|
||||
Sliced buffer containing only the visible lines and columns
|
||||
"""
|
||||
from engine.effects.legacy import vis_offset, vis_trunc
|
||||
|
||||
if not buffer:
|
||||
return buffer
|
||||
|
||||
# Get current viewport bounds (clamped to canvas size)
|
||||
viewport = self.get_viewport()
|
||||
|
||||
# Use provided viewport_height if given, otherwise use camera's viewport
|
||||
vh = viewport_height if viewport_height is not None else viewport.height
|
||||
|
||||
# Vertical slice: extract lines that fit in viewport height
|
||||
start_y = viewport.y
|
||||
end_y = min(viewport.y + vh, len(buffer))
|
||||
|
||||
if start_y >= len(buffer):
|
||||
# Scrolled past end of buffer, return empty viewport
|
||||
return [""] * vh
|
||||
|
||||
vertical_slice = buffer[start_y:end_y]
|
||||
|
||||
# Horizontal slice: apply horizontal offset and truncate to width
|
||||
horizontal_slice = []
|
||||
for line in vertical_slice:
|
||||
# Apply horizontal offset (skip first x characters, handling ANSI)
|
||||
offset_line = vis_offset(line, viewport.x)
|
||||
# Truncate to viewport width (handling ANSI)
|
||||
truncated_line = vis_trunc(offset_line, viewport_width)
|
||||
|
||||
# Pad line to full viewport width to prevent ghosting when panning
|
||||
import re
|
||||
|
||||
visible_len = len(re.sub(r"\x1b\[[0-9;]*m", "", truncated_line))
|
||||
if visible_len < viewport_width:
|
||||
truncated_line += " " * (viewport_width - visible_len)
|
||||
|
||||
horizontal_slice.append(truncated_line)
|
||||
|
||||
# Pad with empty lines if needed to fill viewport height
|
||||
while len(horizontal_slice) < vh:
|
||||
horizontal_slice.append("")
|
||||
|
||||
return horizontal_slice
|
||||
|
||||
@classmethod
|
||||
def feed(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a feed camera (rapid single-item scrolling, 1 row/frame at speed=1.0)."""
|
||||
return cls(mode=CameraMode.FEED, speed=speed, canvas_height=200)
|
||||
|
||||
@classmethod
|
||||
def scroll(cls, speed: float = 0.5) -> "Camera":
|
||||
"""Create a smooth scrolling camera (movie credits style).
|
||||
|
||||
Uses float accumulation for sub-integer speeds.
|
||||
Sets canvas_width=0 so it matches viewport_width for proper text wrapping.
|
||||
"""
|
||||
return cls(
|
||||
mode=CameraMode.SCROLL, speed=speed, canvas_width=0, canvas_height=200
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def vertical(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Deprecated: Use feed() or scroll() instead."""
|
||||
return cls(mode=CameraMode.FEED, speed=speed, canvas_height=200)
|
||||
|
||||
@classmethod
|
||||
def horizontal(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a horizontal scrolling camera."""
|
||||
return cls(mode=CameraMode.HORIZONTAL, speed=speed, canvas_width=200)
|
||||
|
||||
@classmethod
|
||||
def omni(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create an omnidirectional scrolling camera."""
|
||||
return cls(
|
||||
mode=CameraMode.OMNI, speed=speed, canvas_width=200, canvas_height=200
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def floating(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a floating/bobbing camera."""
|
||||
return cls(
|
||||
mode=CameraMode.FLOATING, speed=speed, canvas_width=200, canvas_height=200
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def bounce(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a bouncing DVD-style camera that bounces off canvas edges."""
|
||||
return cls(
|
||||
mode=CameraMode.BOUNCE, speed=speed, canvas_width=200, canvas_height=200
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
||||
"""Create a camera with custom update function."""
|
||||
return cls(custom_update=update_fn)
|
||||
186
engine/canvas.py
Normal file
186
engine/canvas.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Canvas - 2D surface for rendering.
|
||||
|
||||
The Canvas represents a full rendered surface that can be larger than the display.
|
||||
The Camera then defines the visible viewport into this canvas.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanvasRegion:
|
||||
"""A rectangular region on the canvas."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if region has positive dimensions."""
|
||||
return self.width > 0 and self.height > 0
|
||||
|
||||
def rows(self) -> set[int]:
|
||||
"""Return set of row indices in this region."""
|
||||
return set(range(self.y, self.y + self.height))
|
||||
|
||||
|
||||
class Canvas:
|
||||
"""2D canvas for rendering content.
|
||||
|
||||
The canvas is a 2D grid of cells that can hold text content.
|
||||
It can be larger than the visible viewport (display).
|
||||
|
||||
Attributes:
|
||||
width: Total width in characters
|
||||
height: Total height in characters
|
||||
"""
|
||||
|
||||
def __init__(self, width: int = 80, height: int = 24):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._grid: list[list[str]] = [
|
||||
[" " for _ in range(width)] for _ in range(height)
|
||||
]
|
||||
self._dirty_regions: list[CanvasRegion] = [] # Track dirty regions
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the entire canvas."""
|
||||
self._grid = [[" " for _ in range(self.width)] for _ in range(self.height)]
|
||||
self._dirty_regions = [CanvasRegion(0, 0, self.width, self.height)]
|
||||
|
||||
def mark_dirty(self, x: int, y: int, width: int, height: int) -> None:
|
||||
"""Mark a region as dirty (caller declares what they changed)."""
|
||||
self._dirty_regions.append(CanvasRegion(x, y, width, height))
|
||||
|
||||
def get_dirty_regions(self) -> list[CanvasRegion]:
|
||||
"""Get all dirty regions and clear the set."""
|
||||
regions = self._dirty_regions
|
||||
self._dirty_regions = []
|
||||
return regions
|
||||
|
||||
def get_dirty_rows(self) -> set[int]:
|
||||
"""Get union of all dirty rows."""
|
||||
rows: set[int] = set()
|
||||
for region in self._dirty_regions:
|
||||
rows.update(region.rows())
|
||||
return rows
|
||||
|
||||
def is_dirty(self) -> bool:
|
||||
"""Check if any region is dirty."""
|
||||
return len(self._dirty_regions) > 0
|
||||
|
||||
def get_region(self, x: int, y: int, width: int, height: int) -> list[list[str]]:
|
||||
"""Get a rectangular region from the canvas.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Top position
|
||||
width: Region width
|
||||
height: Region height
|
||||
|
||||
Returns:
|
||||
2D list of characters (height rows, width columns)
|
||||
"""
|
||||
region: list[list[str]] = []
|
||||
for py in range(y, y + height):
|
||||
row: list[str] = []
|
||||
for px in range(x, x + width):
|
||||
if 0 <= py < self.height and 0 <= px < self.width:
|
||||
row.append(self._grid[py][px])
|
||||
else:
|
||||
row.append(" ")
|
||||
region.append(row)
|
||||
return region
|
||||
|
||||
def get_region_flat(self, x: int, y: int, width: int, height: int) -> list[str]:
|
||||
"""Get a rectangular region as flat list of lines.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Top position
|
||||
width: Region width
|
||||
height: Region height
|
||||
|
||||
Returns:
|
||||
List of strings (one per row)
|
||||
"""
|
||||
region = self.get_region(x, y, width, height)
|
||||
return ["".join(row) for row in region]
|
||||
|
||||
def put_region(self, x: int, y: int, content: list[list[str]]) -> None:
|
||||
"""Put content into a rectangular region on the canvas.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Top position
|
||||
content: 2D list of characters to place
|
||||
"""
|
||||
height = len(content) if content else 0
|
||||
width = len(content[0]) if height > 0 else 0
|
||||
|
||||
for py, row in enumerate(content):
|
||||
for px, char in enumerate(row):
|
||||
canvas_x = x + px
|
||||
canvas_y = y + py
|
||||
if 0 <= canvas_y < self.height and 0 <= canvas_x < self.width:
|
||||
self._grid[canvas_y][canvas_x] = char
|
||||
|
||||
if width > 0 and height > 0:
|
||||
self.mark_dirty(x, y, width, height)
|
||||
|
||||
def put_text(self, x: int, y: int, text: str) -> None:
|
||||
"""Put a single line of text at position.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Row position
|
||||
text: Text to place
|
||||
"""
|
||||
text_len = len(text)
|
||||
for i, char in enumerate(text):
|
||||
canvas_x = x + i
|
||||
if 0 <= canvas_x < self.width and 0 <= y < self.height:
|
||||
self._grid[y][canvas_x] = char
|
||||
|
||||
if text_len > 0:
|
||||
self.mark_dirty(x, y, text_len, 1)
|
||||
|
||||
def fill(self, x: int, y: int, width: int, height: int, char: str = " ") -> None:
|
||||
"""Fill a rectangular region with a character.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Top position
|
||||
width: Region width
|
||||
height: Region height
|
||||
char: Character to fill with
|
||||
"""
|
||||
for py in range(y, y + height):
|
||||
for px in range(x, x + width):
|
||||
if 0 <= py < self.height and 0 <= px < self.width:
|
||||
self._grid[py][px] = char
|
||||
|
||||
if width > 0 and height > 0:
|
||||
self.mark_dirty(x, y, width, height)
|
||||
|
||||
def resize(self, width: int, height: int) -> None:
|
||||
"""Resize the canvas.
|
||||
|
||||
Args:
|
||||
width: New width
|
||||
height: New height
|
||||
"""
|
||||
if width == self.width and height == self.height:
|
||||
return
|
||||
|
||||
new_grid: list[list[str]] = [[" " for _ in range(width)] for _ in range(height)]
|
||||
|
||||
for py in range(min(self.height, height)):
|
||||
for px in range(min(self.width, width)):
|
||||
new_grid[py][px] = self._grid[py][px]
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._grid = new_grid
|
||||
176
engine/config.py
176
engine/config.py
@@ -1,25 +1,28 @@
|
||||
"""
|
||||
Configuration constants, CLI flags, and glyph tables.
|
||||
Supports both global constants (backward compatible) and injected config for testing.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_FONT_EXTENSIONS = {".otf", ".ttf", ".ttc"}
|
||||
|
||||
|
||||
def _arg_value(flag):
|
||||
def _arg_value(flag, argv: list[str] | None = None):
|
||||
"""Get value following a CLI flag, if present."""
|
||||
if flag not in sys.argv:
|
||||
argv = argv or sys.argv
|
||||
if flag not in argv:
|
||||
return None
|
||||
i = sys.argv.index(flag)
|
||||
return sys.argv[i + 1] if i + 1 < len(sys.argv) else None
|
||||
i = argv.index(flag)
|
||||
return argv[i + 1] if i + 1 < len(argv) else None
|
||||
|
||||
|
||||
def _arg_int(flag, default):
|
||||
def _arg_int(flag, default, argv: list[str] | None = None):
|
||||
"""Get int CLI argument with safe fallback."""
|
||||
raw = _arg_value(flag)
|
||||
raw = _arg_value(flag, argv)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
@@ -53,6 +56,145 @@ def list_repo_font_files():
|
||||
return _list_font_files(FONT_DIR)
|
||||
|
||||
|
||||
def _get_platform_font_paths() -> dict[str, str]:
|
||||
"""Get platform-appropriate font paths for non-Latin scripts."""
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
return {
|
||||
"zh-cn": "/System/Library/Fonts/STHeiti Medium.ttc",
|
||||
"ja": "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc",
|
||||
"ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
||||
"ru": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"uk": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"el": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"he": "/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"ar": "/System/Library/Fonts/GeezaPro.ttc",
|
||||
"fa": "/System/Library/Fonts/GeezaPro.ttc",
|
||||
"hi": "/System/Library/Fonts/Kohinoor.ttc",
|
||||
"th": "/System/Library/Fonts/ThonburiUI.ttc",
|
||||
}
|
||||
elif system == "Linux":
|
||||
return {
|
||||
"zh-cn": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"ja": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"ko": "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"ru": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"uk": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"el": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"he": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"ar": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"fa": "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"hi": "/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
|
||||
"th": "/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
|
||||
}
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
"""Immutable configuration container for injected config."""
|
||||
|
||||
headline_limit: int = 1000
|
||||
feed_timeout: int = 10
|
||||
mic_threshold_db: int = 50
|
||||
mode: str = "news"
|
||||
firehose: bool = False
|
||||
|
||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||
ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||
ntfy_reconnect_delay: int = 5
|
||||
message_display_secs: int = 30
|
||||
|
||||
font_dir: str = "fonts"
|
||||
font_path: str = ""
|
||||
font_index: int = 0
|
||||
font_picker: bool = True
|
||||
font_sz: int = 60
|
||||
render_h: int = 8
|
||||
|
||||
ssaa: int = 4
|
||||
|
||||
scroll_dur: float = 5.625
|
||||
frame_dt: float = 0.05
|
||||
firehose_h: int = 12
|
||||
grad_speed: float = 0.08
|
||||
|
||||
glitch_glyphs: str = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||
kata_glyphs: str = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||
|
||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||
|
||||
display: str = "pygame"
|
||||
websocket: bool = False
|
||||
websocket_port: int = 8765
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||
argv = argv or sys.argv
|
||||
|
||||
font_dir = _resolve_font_path(_arg_value("--font-dir", argv) or "fonts")
|
||||
font_file_arg = _arg_value("--font-file", argv)
|
||||
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 "")
|
||||
)
|
||||
|
||||
return cls(
|
||||
headline_limit=1000,
|
||||
feed_timeout=10,
|
||||
mic_threshold_db=50,
|
||||
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||
firehose="--firehose" in argv,
|
||||
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||
ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
|
||||
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir=font_dir,
|
||||
font_path=font_path,
|
||||
font_index=max(0, _arg_int("--font-index", 0, argv)),
|
||||
font_picker="--no-font-picker" not in argv,
|
||||
font_sz=60,
|
||||
render_h=8,
|
||||
ssaa=4,
|
||||
scroll_dur=5.625,
|
||||
frame_dt=0.05,
|
||||
firehose_h=12,
|
||||
grad_speed=0.08,
|
||||
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||
script_fonts=_get_platform_font_paths(),
|
||||
display=_arg_value("--display", argv) or "terminal",
|
||||
websocket="--websocket" in argv,
|
||||
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
||||
)
|
||||
|
||||
|
||||
_config: Config | None = None
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""Get the global config instance (lazy-loaded)."""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = Config.from_args()
|
||||
return _config
|
||||
|
||||
|
||||
def set_config(config: Config) -> None:
|
||||
"""Set the global config instance (for testing)."""
|
||||
global _config
|
||||
_config = config
|
||||
|
||||
|
||||
# ─── RUNTIME ──────────────────────────────────────────────
|
||||
HEADLINE_LIMIT = 1000
|
||||
FEED_TIMEOUT = 10
|
||||
@@ -62,6 +204,8 @@ FIREHOSE = "--firehose" in sys.argv
|
||||
|
||||
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||
NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||
|
||||
@@ -92,6 +236,26 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||
|
||||
# ─── WEBSOCKET ─────────────────────────────────────────────
|
||||
DISPLAY = _arg_value("--display", sys.argv) or "pygame"
|
||||
WEBSOCKET = "--websocket" in sys.argv
|
||||
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
||||
|
||||
# ─── DEMO MODE ────────────────────────────────────────────
|
||||
DEMO = "--demo" in sys.argv
|
||||
DEMO_EFFECT_DURATION = 5.0 # seconds per effect
|
||||
PIPELINE_DEMO = "--pipeline-demo" in sys.argv
|
||||
|
||||
# ─── PIPELINE MODE (new unified architecture) ─────────────
|
||||
PIPELINE_MODE = "--pipeline" in sys.argv
|
||||
PIPELINE_PRESET = _arg_value("--pipeline-preset", sys.argv) or "demo"
|
||||
|
||||
# ─── PRESET MODE ────────────────────────────────────────────
|
||||
PRESET = _arg_value("--preset", sys.argv)
|
||||
|
||||
# ─── PIPELINE DIAGRAM ────────────────────────────────────
|
||||
PIPELINE_DIAGRAM = "--pipeline-diagram" in sys.argv
|
||||
|
||||
|
||||
def set_font_selection(font_path=None, font_index=None):
|
||||
"""Set runtime primary font selection."""
|
||||
|
||||
12
engine/data_sources/__init__.py
Normal file
12
engine/data_sources/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Data source implementations for the pipeline architecture.
|
||||
|
||||
Import directly from submodules:
|
||||
from engine.data_sources.sources import DataSource, SourceItem, HeadlinesDataSource
|
||||
from engine.data_sources.pipeline_introspection import PipelineIntrospectionSource
|
||||
"""
|
||||
|
||||
# Re-export for convenience
|
||||
from engine.data_sources.sources import ImageItem, SourceItem
|
||||
|
||||
__all__ = ["ImageItem", "SourceItem"]
|
||||
312
engine/data_sources/pipeline_introspection.py
Normal file
312
engine/data_sources/pipeline_introspection.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Pipeline introspection source - Renders live visualization of pipeline DAG and metrics.
|
||||
|
||||
This DataSource introspects one or more Pipeline instances and renders
|
||||
an ASCII visualization showing:
|
||||
- Stage DAG with signal flow connections
|
||||
- Per-stage execution times
|
||||
- Sparkline of frame times
|
||||
- Stage breakdown bars
|
||||
|
||||
Example:
|
||||
source = PipelineIntrospectionSource(pipelines=[my_pipeline])
|
||||
items = source.fetch() # Returns ASCII visualization
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from engine.data_sources.sources import DataSource, SourceItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.controller import Pipeline
|
||||
|
||||
|
||||
SPARKLINE_CHARS = " ▁▂▃▄▅▆▇█"
|
||||
BAR_CHARS = " ▁▂▃▄▅▆▇█"
|
||||
|
||||
|
||||
class PipelineIntrospectionSource(DataSource):
|
||||
"""Data source that renders live pipeline introspection visualization.
|
||||
|
||||
Renders:
|
||||
- DAG of stages with signal flow
|
||||
- Per-stage execution times
|
||||
- Sparkline of frame history
|
||||
- Stage breakdown bars
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: "Pipeline | None" = None,
|
||||
viewport_width: int = 100,
|
||||
viewport_height: int = 35,
|
||||
):
|
||||
self._pipeline = pipeline # May be None initially, set later via set_pipeline()
|
||||
self.viewport_width = viewport_width
|
||||
self.viewport_height = viewport_height
|
||||
self.frame = 0
|
||||
self._ready = False
|
||||
|
||||
def set_pipeline(self, pipeline: "Pipeline") -> None:
|
||||
"""Set the pipeline to introspect (call after pipeline is built)."""
|
||||
self._pipeline = [pipeline] # Wrap in list for iteration
|
||||
self._ready = True
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""Check if source is ready to fetch."""
|
||||
return self._ready
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "pipeline-inspect"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.NONE}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
def add_pipeline(self, pipeline: "Pipeline") -> None:
|
||||
"""Add a pipeline to visualize."""
|
||||
if self._pipeline is None:
|
||||
self._pipeline = [pipeline]
|
||||
elif isinstance(self._pipeline, list):
|
||||
self._pipeline.append(pipeline)
|
||||
else:
|
||||
self._pipeline = [self._pipeline, pipeline]
|
||||
self._ready = True
|
||||
|
||||
def remove_pipeline(self, pipeline: "Pipeline") -> None:
|
||||
"""Remove a pipeline from visualization."""
|
||||
if self._pipeline is None:
|
||||
return
|
||||
elif isinstance(self._pipeline, list):
|
||||
self._pipeline = [p for p in self._pipeline if p is not pipeline]
|
||||
if not self._pipeline:
|
||||
self._pipeline = None
|
||||
self._ready = False
|
||||
elif self._pipeline is pipeline:
|
||||
self._pipeline = None
|
||||
self._ready = False
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
"""Fetch the introspection visualization."""
|
||||
if not self._ready:
|
||||
# Return a placeholder until ready
|
||||
return [
|
||||
SourceItem(
|
||||
content="Initializing...",
|
||||
source="pipeline-inspect",
|
||||
timestamp="init",
|
||||
)
|
||||
]
|
||||
|
||||
lines = self._render()
|
||||
self.frame += 1
|
||||
content = "\n".join(lines)
|
||||
return [
|
||||
SourceItem(
|
||||
content=content, source="pipeline-inspect", timestamp=f"f{self.frame}"
|
||||
)
|
||||
]
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
return self.fetch()
|
||||
|
||||
def _render(self) -> list[str]:
|
||||
"""Render the full visualization."""
|
||||
lines: list[str] = []
|
||||
|
||||
# Header
|
||||
lines.extend(self._render_header())
|
||||
|
||||
# Render pipeline(s) if ready
|
||||
if self._ready and self._pipeline:
|
||||
pipelines = (
|
||||
self._pipeline if isinstance(self._pipeline, list) else [self._pipeline]
|
||||
)
|
||||
for pipeline in pipelines:
|
||||
lines.extend(self._render_pipeline(pipeline))
|
||||
|
||||
# Footer with sparkline
|
||||
lines.extend(self._render_footer())
|
||||
|
||||
return lines
|
||||
|
||||
@property
|
||||
def _pipelines(self) -> list:
|
||||
"""Return pipelines as a list for iteration."""
|
||||
if self._pipeline is None:
|
||||
return []
|
||||
elif isinstance(self._pipeline, list):
|
||||
return self._pipeline
|
||||
else:
|
||||
return [self._pipeline]
|
||||
|
||||
def _render_header(self) -> list[str]:
|
||||
"""Render the header with frame info and metrics summary."""
|
||||
lines: list[str] = []
|
||||
|
||||
if not self._pipeline:
|
||||
return ["PIPELINE INTROSPECTION"]
|
||||
|
||||
# Get aggregate metrics
|
||||
total_ms = 0.0
|
||||
fps = 0.0
|
||||
frame_count = 0
|
||||
|
||||
for pipeline in self._pipelines:
|
||||
try:
|
||||
metrics = pipeline.get_metrics_summary()
|
||||
if metrics and "error" not in metrics:
|
||||
# Get avg_ms from pipeline metrics
|
||||
pipeline_avg = metrics.get("pipeline", {}).get("avg_ms", 0)
|
||||
total_ms = max(total_ms, pipeline_avg)
|
||||
# Calculate FPS from avg_ms
|
||||
if pipeline_avg > 0:
|
||||
fps = max(fps, 1000.0 / pipeline_avg)
|
||||
frame_count = max(frame_count, metrics.get("frame_count", 0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
header = f"PIPELINE INTROSPECTION -- frame: {self.frame} -- avg: {total_ms:.1f}ms -- fps: {fps:.1f}"
|
||||
lines.append(header)
|
||||
|
||||
return lines
|
||||
|
||||
def _render_pipeline(self, pipeline: "Pipeline") -> list[str]:
|
||||
"""Render a single pipeline's DAG."""
|
||||
lines: list[str] = []
|
||||
|
||||
stages = pipeline.stages
|
||||
execution_order = pipeline.execution_order
|
||||
|
||||
if not stages:
|
||||
lines.append(" (no stages)")
|
||||
return lines
|
||||
|
||||
# Build stage info
|
||||
stage_infos: list[dict] = []
|
||||
for name in execution_order:
|
||||
stage = stages.get(name)
|
||||
if not stage:
|
||||
continue
|
||||
|
||||
try:
|
||||
metrics = pipeline.get_metrics_summary()
|
||||
stage_ms = metrics.get("stages", {}).get(name, {}).get("avg_ms", 0.0)
|
||||
except Exception:
|
||||
stage_ms = 0.0
|
||||
|
||||
stage_infos.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": stage.category,
|
||||
"ms": stage_ms,
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate total time for percentages
|
||||
total_time = sum(s["ms"] for s in stage_infos) or 1.0
|
||||
|
||||
# Render DAG - group by category
|
||||
lines.append("")
|
||||
lines.append(" Signal Flow:")
|
||||
|
||||
# Group stages by category for display
|
||||
categories: dict[str, list[dict]] = {}
|
||||
for info in stage_infos:
|
||||
cat = info["category"]
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(info)
|
||||
|
||||
# Render categories in order
|
||||
cat_order = ["source", "render", "effect", "overlay", "display", "system"]
|
||||
|
||||
for cat in cat_order:
|
||||
if cat not in categories:
|
||||
continue
|
||||
|
||||
cat_stages = categories[cat]
|
||||
cat_names = [s["name"] for s in cat_stages]
|
||||
lines.append(f" {cat}: {' → '.join(cat_names)}")
|
||||
|
||||
# Render timing breakdown
|
||||
lines.append("")
|
||||
lines.append(" Stage Timings:")
|
||||
|
||||
for info in stage_infos:
|
||||
name = info["name"]
|
||||
ms = info["ms"]
|
||||
pct = (ms / total_time) * 100
|
||||
bar = self._render_bar(pct, 20)
|
||||
lines.append(f" {name:12s} {ms:6.2f}ms {bar} {pct:5.1f}%")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
def _render_footer(self) -> list[str]:
|
||||
"""Render the footer with sparkline."""
|
||||
lines: list[str] = []
|
||||
|
||||
# Get frame history from first pipeline
|
||||
pipelines = self._pipelines
|
||||
if pipelines:
|
||||
try:
|
||||
frame_times = pipelines[0].get_frame_times()
|
||||
except Exception:
|
||||
frame_times = []
|
||||
else:
|
||||
frame_times = []
|
||||
|
||||
if frame_times:
|
||||
sparkline = self._render_sparkline(frame_times[-60:], 50)
|
||||
lines.append(f" Frame Time History (last {len(frame_times[-60:])} frames)")
|
||||
lines.append(f" {sparkline}")
|
||||
else:
|
||||
lines.append(" Frame Time History")
|
||||
lines.append(" (collecting data...)")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
def _render_bar(self, percentage: float, width: int) -> str:
|
||||
"""Render a horizontal bar for percentage."""
|
||||
filled = int((percentage / 100.0) * width)
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
return bar
|
||||
|
||||
def _render_sparkline(self, values: list[float], width: int) -> str:
|
||||
"""Render a sparkline from values."""
|
||||
if not values:
|
||||
return " " * width
|
||||
|
||||
min_val = min(values)
|
||||
max_val = max(values)
|
||||
range_val = max_val - min_val or 1.0
|
||||
|
||||
result = []
|
||||
for v in values[-width:]:
|
||||
normalized = (v - min_val) / range_val
|
||||
idx = int(normalized * (len(SPARKLINE_CHARS) - 1))
|
||||
idx = max(0, min(idx, len(SPARKLINE_CHARS) - 1))
|
||||
result.append(SPARKLINE_CHARS[idx])
|
||||
|
||||
# Pad to width
|
||||
while len(result) < width:
|
||||
result.insert(0, " ")
|
||||
return "".join(result[:width])
|
||||
490
engine/data_sources/sources.py
Normal file
490
engine/data_sources/sources.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Data sources for the pipeline architecture.
|
||||
|
||||
This module contains all DataSource implementations:
|
||||
- DataSource: Abstract base class
|
||||
- SourceItem, ImageItem: Data containers
|
||||
- HeadlinesDataSource, PoetryDataSource, ImageDataSource: Concrete sources
|
||||
- SourceRegistry: Registry for source discovery
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceItem:
|
||||
"""A single item from a data source."""
|
||||
|
||||
content: str
|
||||
source: str
|
||||
timestamp: str
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageItem:
|
||||
"""An image item from a data source - wraps a PIL Image."""
|
||||
|
||||
image: Any # PIL Image
|
||||
source: str
|
||||
timestamp: str
|
||||
path: str | None = None # File path or URL if applicable
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DataSource(ABC):
|
||||
"""Abstract base class for data sources.
|
||||
|
||||
Static sources: Data fetched once and cached. Safe to call fetch() multiple times.
|
||||
Dynamic sources: Data changes over time. fetch() should be idempotent.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Display name for this source."""
|
||||
...
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
"""Whether this source updates dynamically while the app runs. Default False."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
"""Fetch fresh data from the source. Must be idempotent."""
|
||||
...
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
"""Get current items. Default implementation returns cached fetch results."""
|
||||
if not hasattr(self, "_items") or self._items is None:
|
||||
self._items = self.fetch()
|
||||
return self._items
|
||||
|
||||
def refresh(self) -> list[SourceItem]:
|
||||
"""Force refresh - clear cache and fetch fresh data."""
|
||||
self._items = self.fetch()
|
||||
return self._items
|
||||
|
||||
def stream(self):
|
||||
"""Optional: Yield items continuously. Override for streaming sources."""
|
||||
raise NotImplementedError
|
||||
|
||||
def __post_init__(self):
|
||||
self._items: list[SourceItem] | None = None
|
||||
|
||||
|
||||
class HeadlinesDataSource(DataSource):
|
||||
"""Data source for RSS feed headlines."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "headlines"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
from engine.fetch import fetch_all
|
||||
|
||||
items, _, _ = fetch_all()
|
||||
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
|
||||
|
||||
|
||||
class EmptyDataSource(DataSource):
|
||||
"""Empty data source that produces blank lines for testing.
|
||||
|
||||
Useful for testing display borders, effects, and other pipeline
|
||||
components without needing actual content.
|
||||
"""
|
||||
|
||||
def __init__(self, width: int = 80, height: int = 24):
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "empty"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return False
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
# Return empty lines as content
|
||||
content = "\n".join([" " * self.width for _ in range(self.height)])
|
||||
return [SourceItem(content=content, source="empty", timestamp="0")]
|
||||
|
||||
|
||||
class ListDataSource(DataSource):
|
||||
"""Data source that wraps a pre-fetched list of items.
|
||||
|
||||
Used for bootstrap loading when items are already available in memory.
|
||||
This is a simple wrapper for already-fetched data.
|
||||
"""
|
||||
|
||||
def __init__(self, items, name: str = "list"):
|
||||
self._raw_items = items # Store raw items separately
|
||||
self._items = None # Cache for converted SourceItem objects
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return False
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
# Convert tuple items to SourceItem if needed
|
||||
result = []
|
||||
for item in self._raw_items:
|
||||
if isinstance(item, SourceItem):
|
||||
result.append(item)
|
||||
elif isinstance(item, tuple) and len(item) >= 3:
|
||||
# Assume (content, source, timestamp) tuple format
|
||||
result.append(
|
||||
SourceItem(content=item[0], source=item[1], timestamp=str(item[2]))
|
||||
)
|
||||
else:
|
||||
# Fallback: treat as string content
|
||||
result.append(
|
||||
SourceItem(content=str(item), source="list", timestamp="0")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class PoetryDataSource(DataSource):
|
||||
"""Data source for Poetry DB."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "poetry"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
from engine.fetch import fetch_poetry
|
||||
|
||||
items, _, _ = fetch_poetry()
|
||||
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
|
||||
|
||||
|
||||
class ImageDataSource(DataSource):
|
||||
"""Data source that loads PNG images from file paths or URLs.
|
||||
|
||||
Supports:
|
||||
- Local file paths (e.g., /path/to/image.png)
|
||||
- URLs (e.g., https://example.com/image.png)
|
||||
|
||||
Yields ImageItem objects containing PIL Image objects that can be
|
||||
converted to text buffers by an ImageToTextTransform stage.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | list[str] | None = None,
|
||||
urls: str | list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
path: Single path or list of paths to PNG files
|
||||
urls: Single URL or list of URLs to PNG images
|
||||
"""
|
||||
self._paths = [path] if isinstance(path, str) else (path or [])
|
||||
self._urls = [urls] if isinstance(urls, str) else (urls or [])
|
||||
self._images: list[ImageItem] = []
|
||||
self._load_images()
|
||||
|
||||
def _load_images(self) -> None:
|
||||
"""Load all images from paths and URLs."""
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from urllib.request import urlopen
|
||||
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
for path in self._paths:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
self._images.append(
|
||||
ImageItem(
|
||||
image=img,
|
||||
source=f"file:{path}",
|
||||
timestamp=timestamp,
|
||||
path=path,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for url in self._urls:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with urlopen(url) as response:
|
||||
img = Image.open(BytesIO(response.read()))
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
self._images.append(
|
||||
ImageItem(
|
||||
image=img,
|
||||
source=f"url:{url}",
|
||||
timestamp=timestamp,
|
||||
path=url,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "image"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return False # Static images, not updating
|
||||
|
||||
def fetch(self) -> list[ImageItem]:
|
||||
"""Return loaded images as ImageItem list."""
|
||||
return self._images
|
||||
|
||||
def get_items(self) -> list[ImageItem]:
|
||||
"""Return current image items."""
|
||||
return self._images
|
||||
|
||||
|
||||
class MetricsDataSource(DataSource):
|
||||
"""Data source that renders live pipeline metrics as ASCII art.
|
||||
|
||||
Wraps a Pipeline and displays active stages with their average execution
|
||||
time and approximate FPS impact. Updates lazily when camera is about to
|
||||
focus on a new node (frame % 15 == 12).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: Any,
|
||||
viewport_width: int = 80,
|
||||
viewport_height: int = 24,
|
||||
):
|
||||
self.pipeline = pipeline
|
||||
self.viewport_width = viewport_width
|
||||
self.viewport_height = viewport_height
|
||||
self.frame = 0
|
||||
self._cached_metrics: dict | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "metrics"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return True
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
if self.frame % 15 == 12:
|
||||
self._cached_metrics = None
|
||||
|
||||
if self._cached_metrics is None:
|
||||
self._cached_metrics = self._fetch_metrics()
|
||||
|
||||
buffer = self._render_metrics(self._cached_metrics)
|
||||
self.frame += 1
|
||||
content = "\n".join(buffer)
|
||||
return [
|
||||
SourceItem(content=content, source="metrics", timestamp=f"f{self.frame}")
|
||||
]
|
||||
|
||||
def _fetch_metrics(self) -> dict:
|
||||
if hasattr(self.pipeline, "get_metrics_summary"):
|
||||
metrics = self.pipeline.get_metrics_summary()
|
||||
if "error" not in metrics:
|
||||
return metrics
|
||||
return {"stages": {}, "pipeline": {"avg_ms": 0}}
|
||||
|
||||
def _render_metrics(self, metrics: dict) -> list[str]:
|
||||
stages = metrics.get("stages", {})
|
||||
|
||||
if not stages:
|
||||
return self._render_empty()
|
||||
|
||||
active_stages = {
|
||||
name: stats for name, stats in stages.items() if stats.get("avg_ms", 0) > 0
|
||||
}
|
||||
|
||||
if not active_stages:
|
||||
return self._render_empty()
|
||||
|
||||
total_avg = sum(s["avg_ms"] for s in active_stages.values())
|
||||
if total_avg == 0:
|
||||
total_avg = 1
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("═" * self.viewport_width)
|
||||
lines.append(" PIPELINE METRICS ".center(self.viewport_width, "─"))
|
||||
lines.append("─" * self.viewport_width)
|
||||
|
||||
header = f"{'STAGE':<20} {'AVG_MS':>8} {'FPS %':>8}"
|
||||
lines.append(header)
|
||||
lines.append("─" * self.viewport_width)
|
||||
|
||||
for name, stats in sorted(active_stages.items()):
|
||||
avg_ms = stats.get("avg_ms", 0)
|
||||
fps_impact = (avg_ms / 16.67) * 100 if avg_ms > 0 else 0
|
||||
|
||||
row = f"{name:<20} {avg_ms:>7.2f} {fps_impact:>7.1f}%"
|
||||
lines.append(row[: self.viewport_width])
|
||||
|
||||
lines.append("─" * self.viewport_width)
|
||||
total_row = (
|
||||
f"{'TOTAL':<20} {total_avg:>7.2f} {(total_avg / 16.67) * 100:>7.1f}%"
|
||||
)
|
||||
lines.append(total_row[: self.viewport_width])
|
||||
lines.append("─" * self.viewport_width)
|
||||
lines.append(
|
||||
f" Frame:{self.frame:04d} Cache:{'HIT' if self._cached_metrics else 'MISS'}"
|
||||
)
|
||||
|
||||
while len(lines) < self.viewport_height:
|
||||
lines.append(" " * self.viewport_width)
|
||||
|
||||
return lines[: self.viewport_height]
|
||||
|
||||
def _render_empty(self) -> list[str]:
|
||||
lines = [" " * self.viewport_width for _ in range(self.viewport_height)]
|
||||
msg = "No metrics available"
|
||||
y = self.viewport_height // 2
|
||||
x = (self.viewport_width - len(msg)) // 2
|
||||
lines[y] = " " * x + msg + " " * (self.viewport_width - x - len(msg))
|
||||
return lines
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
return self.fetch()
|
||||
|
||||
|
||||
class CachedDataSource(DataSource):
|
||||
"""Data source that wraps another source with caching."""
|
||||
|
||||
def __init__(self, source: DataSource, max_items: int = 100):
|
||||
self.source = source
|
||||
self.max_items = max_items
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"cached:{self.source.name}"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = self.source.fetch()
|
||||
return items[: self.max_items]
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
if not hasattr(self, "_items") or self._items is None:
|
||||
self._items = self.fetch()
|
||||
return self._items
|
||||
|
||||
|
||||
class TransformDataSource(DataSource):
|
||||
"""Data source that transforms items from another source.
|
||||
|
||||
Applies optional filter and map functions to each item.
|
||||
This enables chaining: source → transform → transformed output.
|
||||
|
||||
Args:
|
||||
source: The source to fetch items from
|
||||
filter_fn: Optional function(item: SourceItem) -> bool
|
||||
map_fn: Optional function(item: SourceItem) -> SourceItem
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: DataSource,
|
||||
filter_fn: Callable[[SourceItem], bool] | None = None,
|
||||
map_fn: Callable[[SourceItem], SourceItem] | None = None,
|
||||
):
|
||||
self.source = source
|
||||
self.filter_fn = filter_fn
|
||||
self.map_fn = map_fn
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"transform:{self.source.name}"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = self.source.fetch()
|
||||
|
||||
if self.filter_fn:
|
||||
items = [item for item in items if self.filter_fn(item)]
|
||||
|
||||
if self.map_fn:
|
||||
items = [self.map_fn(item) for item in items]
|
||||
|
||||
return items
|
||||
|
||||
|
||||
class CompositeDataSource(DataSource):
|
||||
"""Data source that combines multiple sources."""
|
||||
|
||||
def __init__(self, sources: list[DataSource]):
|
||||
self.sources = sources
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "composite"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = []
|
||||
for source in self.sources:
|
||||
items.extend(source.fetch())
|
||||
return items
|
||||
|
||||
|
||||
class SourceRegistry:
|
||||
"""Registry for data sources."""
|
||||
|
||||
def __init__(self):
|
||||
self._sources: dict[str, DataSource] = {}
|
||||
self._default: str | None = None
|
||||
|
||||
def register(self, source: DataSource, default: bool = False) -> None:
|
||||
self._sources[source.name] = source
|
||||
if default or self._default is None:
|
||||
self._default = source.name
|
||||
|
||||
def get(self, name: str) -> DataSource | None:
|
||||
return self._sources.get(name)
|
||||
|
||||
def list_all(self) -> dict[str, DataSource]:
|
||||
return dict(self._sources)
|
||||
|
||||
def default(self) -> DataSource | None:
|
||||
if self._default:
|
||||
return self._sources.get(self._default)
|
||||
return None
|
||||
|
||||
def create_headlines(self) -> HeadlinesDataSource:
|
||||
return HeadlinesDataSource()
|
||||
|
||||
def create_poetry(self) -> PoetryDataSource:
|
||||
return PoetryDataSource()
|
||||
|
||||
|
||||
_global_registry: SourceRegistry | None = None
|
||||
|
||||
|
||||
def get_source_registry() -> SourceRegistry:
|
||||
global _global_registry
|
||||
if _global_registry is None:
|
||||
_global_registry = SourceRegistry()
|
||||
return _global_registry
|
||||
|
||||
|
||||
def init_default_sources() -> SourceRegistry:
|
||||
"""Initialize the default source registry with standard sources."""
|
||||
registry = get_source_registry()
|
||||
registry.register(HeadlinesDataSource(), default=True)
|
||||
registry.register(PoetryDataSource())
|
||||
return registry
|
||||
275
engine/display/__init__.py
Normal file
275
engine/display/__init__.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Display backend system with registry pattern.
|
||||
|
||||
Allows swapping output backends via the Display protocol.
|
||||
Supports auto-discovery of display backends.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from engine.display.backends.kitty import KittyDisplay
|
||||
from engine.display.backends.multi import MultiDisplay
|
||||
from engine.display.backends.null import NullDisplay
|
||||
from engine.display.backends.pygame import PygameDisplay
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
from engine.display.backends.terminal import TerminalDisplay
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
|
||||
class Display(Protocol):
|
||||
"""Protocol for display backends.
|
||||
|
||||
All display backends must implement:
|
||||
- width, height: Terminal dimensions
|
||||
- init(width, height, reuse=False): Initialize the display
|
||||
- show(buffer): Render buffer to display
|
||||
- clear(): Clear the display
|
||||
- cleanup(): Shutdown the display
|
||||
|
||||
Optional methods for keyboard input:
|
||||
- is_quit_requested(): Returns True if user pressed Ctrl+C/Q or Escape
|
||||
- clear_quit_request(): Clears the quit request flag
|
||||
|
||||
The reuse flag allows attaching to an existing display instance
|
||||
rather than creating a new window/connection.
|
||||
|
||||
Keyboard input support by backend:
|
||||
- terminal: No native input (relies on signal handler for Ctrl+C)
|
||||
- pygame: Supports Ctrl+C, Ctrl+Q, Escape for graceful shutdown
|
||||
- websocket: No native input (relies on signal handler for Ctrl+C)
|
||||
- sixel: No native input (relies on signal handler for Ctrl+C)
|
||||
- null: No native input
|
||||
- kitty: Supports Ctrl+C, Ctrl+Q, Escape (via pygame-like handling)
|
||||
"""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, attach to existing display instead of creating new
|
||||
"""
|
||||
...
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
"""Show buffer on display.
|
||||
|
||||
Args:
|
||||
buffer: Buffer to display
|
||||
border: If True, render border around buffer (default False)
|
||||
"""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear display."""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Shutdown display."""
|
||||
...
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current terminal dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
|
||||
This method is called after show() to check if the display
|
||||
was resized. The main loop should compare this to the current
|
||||
viewport dimensions and update accordingly.
|
||||
"""
|
||||
...
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
|
||||
|
||||
Returns:
|
||||
True if quit was requested, False otherwise
|
||||
|
||||
Optional method - only implemented by backends that support keyboard input.
|
||||
"""
|
||||
...
|
||||
|
||||
def clear_quit_request(self) -> None:
|
||||
"""Clear the quit request flag.
|
||||
|
||||
Optional method - only implemented by backends that support keyboard input.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class DisplayRegistry:
|
||||
"""Registry for display backends with auto-discovery."""
|
||||
|
||||
_backends: dict[str, type[Display]] = {}
|
||||
_initialized = False
|
||||
|
||||
@classmethod
|
||||
def register(cls, name: str, backend_class: type[Display]) -> None:
|
||||
"""Register a display backend."""
|
||||
cls._backends[name.lower()] = backend_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> type[Display] | None:
|
||||
"""Get a display backend class by name."""
|
||||
return cls._backends.get(name.lower())
|
||||
|
||||
@classmethod
|
||||
def list_backends(cls) -> list[str]:
|
||||
"""List all available display backend names."""
|
||||
return list(cls._backends.keys())
|
||||
|
||||
@classmethod
|
||||
def create(cls, name: str, **kwargs) -> Display | None:
|
||||
"""Create a display instance by name."""
|
||||
cls.initialize()
|
||||
backend_class = cls.get(name)
|
||||
if backend_class:
|
||||
return backend_class(**kwargs)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def initialize(cls) -> None:
|
||||
"""Initialize and register all built-in backends."""
|
||||
if cls._initialized:
|
||||
return
|
||||
|
||||
cls.register("terminal", TerminalDisplay)
|
||||
cls.register("null", NullDisplay)
|
||||
cls.register("websocket", WebSocketDisplay)
|
||||
cls.register("sixel", SixelDisplay)
|
||||
cls.register("kitty", KittyDisplay)
|
||||
cls.register("pygame", PygameDisplay)
|
||||
|
||||
cls._initialized = True
|
||||
|
||||
@classmethod
|
||||
def create_multi(cls, names: list[str]) -> "Display | None":
|
||||
"""Create a MultiDisplay from a list of backend names.
|
||||
|
||||
Args:
|
||||
names: List of display backend names (e.g., ["terminal", "pygame"])
|
||||
|
||||
Returns:
|
||||
MultiDisplay instance or None if any backend fails
|
||||
"""
|
||||
from engine.display.backends.multi import MultiDisplay
|
||||
|
||||
displays = []
|
||||
for name in names:
|
||||
backend = cls.create(name)
|
||||
if backend:
|
||||
displays.append(backend)
|
||||
else:
|
||||
return None
|
||||
|
||||
if not displays:
|
||||
return None
|
||||
|
||||
return MultiDisplay(displays)
|
||||
|
||||
|
||||
def get_monitor():
|
||||
"""Get the performance monitor."""
|
||||
try:
|
||||
from engine.effects.performance import get_monitor as _get_monitor
|
||||
|
||||
return _get_monitor()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _strip_ansi(s: str) -> str:
|
||||
"""Strip ANSI escape sequences from string for length calculation."""
|
||||
import re
|
||||
|
||||
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
|
||||
|
||||
|
||||
def render_border(
|
||||
buf: list[str], width: int, height: int, fps: float = 0.0, frame_time: float = 0.0
|
||||
) -> list[str]:
|
||||
"""Render a border around the buffer.
|
||||
|
||||
Args:
|
||||
buf: Input buffer (list of strings)
|
||||
width: Display width in characters
|
||||
height: Display height in rows
|
||||
fps: Current FPS to display in top border (optional)
|
||||
frame_time: Frame time in ms to display in bottom border (optional)
|
||||
|
||||
Returns:
|
||||
Buffer with border applied
|
||||
"""
|
||||
if not buf or width < 3 or height < 3:
|
||||
return buf
|
||||
|
||||
inner_w = width - 2
|
||||
inner_h = height - 2
|
||||
|
||||
# Crop buffer to fit inside border
|
||||
cropped = []
|
||||
for i in range(min(inner_h, len(buf))):
|
||||
line = buf[i]
|
||||
# Calculate visible width (excluding ANSI codes)
|
||||
visible_len = len(_strip_ansi(line))
|
||||
if visible_len > inner_w:
|
||||
# Truncate carefully - this is approximate for ANSI text
|
||||
cropped.append(line[:inner_w])
|
||||
else:
|
||||
cropped.append(line + " " * (inner_w - visible_len))
|
||||
|
||||
# Pad with empty lines if needed
|
||||
while len(cropped) < inner_h:
|
||||
cropped.append(" " * inner_w)
|
||||
|
||||
# Build borders
|
||||
if fps > 0:
|
||||
fps_str = f" FPS:{fps:.0f}"
|
||||
if len(fps_str) < inner_w:
|
||||
right_len = inner_w - len(fps_str)
|
||||
top_border = "┌" + "─" * right_len + fps_str + "┐"
|
||||
else:
|
||||
top_border = "┌" + "─" * inner_w + "┐"
|
||||
else:
|
||||
top_border = "┌" + "─" * inner_w + "┐"
|
||||
|
||||
if frame_time > 0:
|
||||
ft_str = f" {frame_time:.1f}ms"
|
||||
if len(ft_str) < inner_w:
|
||||
right_len = inner_w - len(ft_str)
|
||||
bottom_border = "└" + "─" * right_len + ft_str + "┘"
|
||||
else:
|
||||
bottom_border = "└" + "─" * inner_w + "┘"
|
||||
else:
|
||||
bottom_border = "└" + "─" * inner_w + "┘"
|
||||
|
||||
# Build result with left/right borders
|
||||
result = [top_border]
|
||||
for line in cropped:
|
||||
# Ensure exactly inner_w characters before adding right border
|
||||
if len(line) < inner_w:
|
||||
line = line + " " * (inner_w - len(line))
|
||||
elif len(line) > inner_w:
|
||||
line = line[:inner_w]
|
||||
result.append("│" + line + "│")
|
||||
result.append(bottom_border)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Display",
|
||||
"DisplayRegistry",
|
||||
"get_monitor",
|
||||
"render_border",
|
||||
"TerminalDisplay",
|
||||
"NullDisplay",
|
||||
"WebSocketDisplay",
|
||||
"SixelDisplay",
|
||||
"MultiDisplay",
|
||||
]
|
||||
180
engine/display/backends/kitty.py
Normal file
180
engine/display/backends/kitty.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Kitty graphics display backend - renders using kitty's native graphics protocol.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import get_default_font_path, parse_ansi
|
||||
|
||||
|
||||
def _encode_kitty_graphic(image_data: bytes, width: int, height: int) -> bytes:
|
||||
"""Encode image data using kitty's graphics protocol."""
|
||||
import base64
|
||||
|
||||
encoded = base64.b64encode(image_data).decode("ascii")
|
||||
|
||||
chunks = []
|
||||
for i in range(0, len(encoded), 4096):
|
||||
chunk = encoded[i : i + 4096]
|
||||
if i == 0:
|
||||
chunks.append(f"\x1b_Gf=100,t=d,s={width},v={height},c=1,r=1;{chunk}\x1b\\")
|
||||
else:
|
||||
chunks.append(f"\x1b_Gm={height};{chunk}\x1b\\")
|
||||
|
||||
return "".join(chunks).encode("utf-8")
|
||||
|
||||
|
||||
class KittyDisplay:
|
||||
"""Kitty graphics display backend using kitty's native protocol."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self._initialized = False
|
||||
self._font_path = None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for KittyDisplay (protocol doesn't support reuse)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._initialized = True
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path from env or detect common locations."""
|
||||
import os
|
||||
|
||||
if self._font_path:
|
||||
return self._font_path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_KITTY_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
self._font_path = env_font
|
||||
return env_font
|
||||
|
||||
font_path = get_default_font_path()
|
||||
if font_path:
|
||||
self._font_path = font_path
|
||||
|
||||
return self._font_path
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested
|
||||
if border:
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
img_width = self.width * self.cell_width
|
||||
img_height = self.height * self.cell_height
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
font_path = self._get_font_path()
|
||||
font = None
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
if font is None:
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * self.cell_height
|
||||
|
||||
for text, fg, bg, bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||
draw.rectangle(bbox, fill=(*bg, 255))
|
||||
|
||||
if bold and font:
|
||||
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
output = BytesIO()
|
||||
img.save(output, format="PNG")
|
||||
png_data = output.getvalue()
|
||||
|
||||
graphic = _encode_kitty_graphic(png_data, img_width, img_height)
|
||||
|
||||
sys.stdout.buffer.write(graphic)
|
||||
sys.stdout.flush()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("kitty_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
import sys
|
||||
|
||||
sys.stdout.buffer.write(b"\x1b_Ga=d\x1b\\")
|
||||
sys.stdout.flush()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.clear()
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
50
engine/display/backends/multi.py
Normal file
50
engine/display/backends/multi.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Multi display backend - forwards to multiple displays.
|
||||
"""
|
||||
|
||||
|
||||
class MultiDisplay:
|
||||
"""Display that forwards to multiple displays.
|
||||
|
||||
Supports reuse - passes reuse flag to all child displays.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, displays: list):
|
||||
self.displays = displays
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize all child displays with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, use reuse mode for child displays
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
for d in self.displays:
|
||||
d.init(width, height, reuse=reuse)
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
for d in self.displays:
|
||||
d.show(buffer, border=border)
|
||||
|
||||
def clear(self) -> None:
|
||||
for d in self.displays:
|
||||
d.clear()
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get dimensions from the first child display that supports it."""
|
||||
for d in self.displays:
|
||||
if hasattr(d, "get_dimensions"):
|
||||
return d.get_dimensions()
|
||||
return (self.width, self.height)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
for d in self.displays:
|
||||
d.cleanup()
|
||||
81
engine/display/backends/null.py
Normal file
81
engine/display/backends/null.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Null/headless display backend.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class NullDisplay:
|
||||
"""Headless/null display - discards all output.
|
||||
|
||||
This display does nothing - useful for headless benchmarking
|
||||
or when no display output is needed. Captures last buffer
|
||||
for testing purposes.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
_last_buffer: list[str] | None = None
|
||||
|
||||
def __init__(self):
|
||||
self._last_buffer = None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for NullDisplay (no resources to reuse)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._last_buffer = None
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
from engine.display import get_monitor, render_border
|
||||
|
||||
# Get FPS for border (if available)
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested (same as terminal display)
|
||||
if border:
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
self._last_buffer = buffer
|
||||
if monitor:
|
||||
t0 = time.perf_counter()
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if quit was requested (optional protocol method)."""
|
||||
return False
|
||||
|
||||
def clear_quit_request(self) -> None:
|
||||
"""Clear quit request (optional protocol method)."""
|
||||
pass
|
||||
289
engine/display/backends/pygame.py
Normal file
289
engine/display/backends/pygame.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
Pygame display backend - renders to a native application window.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import parse_ansi
|
||||
|
||||
|
||||
class PygameDisplay:
|
||||
"""Pygame display backend - renders to native window.
|
||||
|
||||
Supports reuse mode - when reuse=True, skips SDL initialization
|
||||
and reuses the existing pygame window from a previous instance.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
window_width: int = 800
|
||||
window_height: int = 600
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cell_width: int = 10,
|
||||
cell_height: int = 18,
|
||||
window_width: int = 800,
|
||||
window_height: int = 600,
|
||||
target_fps: float = 30.0,
|
||||
):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self.window_width = window_width
|
||||
self.window_height = window_height
|
||||
self.target_fps = target_fps
|
||||
self._initialized = False
|
||||
self._pygame = None
|
||||
self._screen = None
|
||||
self._font = None
|
||||
self._resized = False
|
||||
self._quit_requested = False
|
||||
self._last_frame_time = 0.0
|
||||
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
||||
self._glyph_cache = {}
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path for rendering."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_PYGAME_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
return env_font
|
||||
|
||||
def search_dir(base_path: str) -> str | None:
|
||||
if not os.path.exists(base_path):
|
||||
return None
|
||||
if os.path.isfile(base_path):
|
||||
return base_path
|
||||
for font_file in Path(base_path).rglob("*"):
|
||||
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
name = font_file.stem.lower()
|
||||
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||
return str(font_file)
|
||||
return None
|
||||
|
||||
search_dirs = []
|
||||
if sys.platform == "darwin":
|
||||
search_dirs.append(os.path.expanduser("~/Library/Fonts/"))
|
||||
elif sys.platform == "win32":
|
||||
search_dirs.append(
|
||||
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\")
|
||||
)
|
||||
else:
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/.local/share/fonts/"),
|
||||
os.path.expanduser("~/.fonts/"),
|
||||
"/usr/share/fonts/",
|
||||
]
|
||||
)
|
||||
|
||||
for search_dir_path in search_dirs:
|
||||
found = search_dir(search_dir_path)
|
||||
if found:
|
||||
return found
|
||||
|
||||
return None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, attach to existing pygame window instead of creating new
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
import os
|
||||
|
||||
os.environ["SDL_VIDEODRIVER"] = "x11"
|
||||
|
||||
try:
|
||||
import pygame
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
if reuse and PygameDisplay._pygame_initialized:
|
||||
self._pygame = pygame
|
||||
self._initialized = True
|
||||
return
|
||||
|
||||
pygame.init()
|
||||
pygame.display.set_caption("Mainline")
|
||||
|
||||
self._screen = pygame.display.set_mode(
|
||||
(self.window_width, self.window_height),
|
||||
pygame.RESIZABLE,
|
||||
)
|
||||
self._pygame = pygame
|
||||
PygameDisplay._pygame_initialized = True
|
||||
|
||||
# Calculate character dimensions from actual window size
|
||||
self.width = max(1, self.window_width // self.cell_width)
|
||||
self.height = max(1, self.window_height // self.cell_height)
|
||||
|
||||
font_path = self._get_font_path()
|
||||
if font_path:
|
||||
try:
|
||||
self._font = pygame.font.Font(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||
else:
|
||||
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
if not self._initialized or not self._pygame:
|
||||
return
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
for event in self._pygame.event.get():
|
||||
if event.type == self._pygame.QUIT:
|
||||
self._quit_requested = True
|
||||
elif event.type == self._pygame.KEYDOWN:
|
||||
if event.key in (self._pygame.K_ESCAPE, self._pygame.K_c):
|
||||
if event.key == self._pygame.K_c and not (
|
||||
event.mod & self._pygame.KMOD_LCTRL
|
||||
or event.mod & self._pygame.KMOD_RCTRL
|
||||
):
|
||||
continue
|
||||
self._quit_requested = True
|
||||
elif event.type == self._pygame.VIDEORESIZE:
|
||||
self.window_width = event.w
|
||||
self.window_height = event.h
|
||||
self.width = max(1, self.window_width // self.cell_width)
|
||||
self.height = max(1, self.window_height // self.cell_height)
|
||||
self._resized = True
|
||||
|
||||
# FPS limiting - skip frame if we're going too fast
|
||||
if self._frame_period > 0:
|
||||
now = time.perf_counter()
|
||||
elapsed = now - self._last_frame_time
|
||||
if elapsed < self._frame_period:
|
||||
return # Skip this frame
|
||||
self._last_frame_time = now
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested
|
||||
if border:
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
self._screen.fill((0, 0, 0))
|
||||
|
||||
blit_list = []
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
|
||||
for text, fg, bg, _bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Use None as key for no background
|
||||
bg_key = bg if bg != (0, 0, 0) else None
|
||||
cache_key = (text, fg, bg_key)
|
||||
|
||||
if cache_key not in self._glyph_cache:
|
||||
# Render and cache
|
||||
if bg_key is not None:
|
||||
self._glyph_cache[cache_key] = self._font.render(
|
||||
text, True, fg, bg_key
|
||||
)
|
||||
else:
|
||||
self._glyph_cache[cache_key] = self._font.render(text, True, fg)
|
||||
|
||||
surface = self._glyph_cache[cache_key]
|
||||
blit_list.append((surface, (x_pos, row_idx * self.cell_height)))
|
||||
x_pos += self._font.size(text)[0]
|
||||
|
||||
self._screen.blits(blit_list)
|
||||
self._pygame.display.flip()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
if self._screen and self._pygame:
|
||||
self._screen.fill((0, 0, 0))
|
||||
self._pygame.display.flip()
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current terminal dimensions based on window size.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
# Query actual window size and recalculate character cells
|
||||
if self._screen and self._pygame:
|
||||
try:
|
||||
w, h = self._screen.get_size()
|
||||
if w != self.window_width or h != self.window_height:
|
||||
self.window_width = w
|
||||
self.window_height = h
|
||||
self.width = max(1, w // self.cell_width)
|
||||
self.height = max(1, h // self.cell_height)
|
||||
except Exception:
|
||||
pass
|
||||
return self.width, self.height
|
||||
|
||||
def cleanup(self, quit_pygame: bool = True) -> None:
|
||||
"""Cleanup display resources.
|
||||
|
||||
Args:
|
||||
quit_pygame: If True, quit pygame entirely. Set to False when
|
||||
reusing the display to avoid closing shared window.
|
||||
"""
|
||||
if quit_pygame and self._pygame:
|
||||
self._pygame.quit()
|
||||
PygameDisplay._pygame_initialized = False
|
||||
|
||||
@classmethod
|
||||
def reset_state(cls) -> None:
|
||||
"""Reset pygame state - useful for testing."""
|
||||
cls._pygame_initialized = False
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
|
||||
|
||||
Returns True if the user pressed Ctrl+C, Ctrl+Q, or Escape.
|
||||
The main loop should check this and raise KeyboardInterrupt.
|
||||
"""
|
||||
return self._quit_requested
|
||||
|
||||
def clear_quit_request(self) -> bool:
|
||||
"""Clear the quit request flag after handling.
|
||||
|
||||
Returns the previous quit request state.
|
||||
"""
|
||||
was_requested = self._quit_requested
|
||||
self._quit_requested = False
|
||||
return was_requested
|
||||
228
engine/display/backends/sixel.py
Normal file
228
engine/display/backends/sixel.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Sixel graphics display backend - renders to sixel graphics in terminal.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import get_default_font_path, parse_ansi
|
||||
|
||||
|
||||
def _encode_sixel(image) -> str:
|
||||
"""Encode a PIL Image to sixel format (pure Python)."""
|
||||
img = image.convert("RGBA")
|
||||
width, height = img.size
|
||||
pixels = img.load()
|
||||
|
||||
palette = []
|
||||
pixel_palette_idx = {}
|
||||
|
||||
def get_color_idx(r, g, b, a):
|
||||
if a < 128:
|
||||
return -1
|
||||
key = (r // 32, g // 32, b // 32)
|
||||
if key not in pixel_palette_idx:
|
||||
idx = len(palette)
|
||||
if idx < 256:
|
||||
palette.append((r, g, b))
|
||||
pixel_palette_idx[key] = idx
|
||||
return pixel_palette_idx.get(key, 0)
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r, g, b, a = pixels[x, y]
|
||||
get_color_idx(r, g, b, a)
|
||||
|
||||
if not palette:
|
||||
return ""
|
||||
|
||||
if len(palette) == 1:
|
||||
palette = [palette[0], (0, 0, 0)]
|
||||
|
||||
sixel_data = []
|
||||
sixel_data.append(
|
||||
f'"{"".join(f"#{i};2;{r};{g};{b}" for i, (r, g, b) in enumerate(palette))}'
|
||||
)
|
||||
|
||||
for x in range(width):
|
||||
col_data = []
|
||||
for y in range(0, height, 6):
|
||||
bits = 0
|
||||
color_idx = -1
|
||||
for dy in range(6):
|
||||
if y + dy < height:
|
||||
r, g, b, a = pixels[x, y + dy]
|
||||
if a >= 128:
|
||||
bits |= 1 << dy
|
||||
idx = get_color_idx(r, g, b, a)
|
||||
if color_idx == -1:
|
||||
color_idx = idx
|
||||
elif color_idx != idx:
|
||||
color_idx = -2
|
||||
|
||||
if color_idx >= 0:
|
||||
col_data.append(
|
||||
chr(63 + color_idx) + chr(63 + bits)
|
||||
if bits
|
||||
else chr(63 + color_idx) + "?"
|
||||
)
|
||||
elif color_idx == -2:
|
||||
pass
|
||||
|
||||
if col_data:
|
||||
sixel_data.append("".join(col_data) + "$")
|
||||
else:
|
||||
sixel_data.append("-" if x < width - 1 else "$")
|
||||
|
||||
sixel_data.append("\x1b\\")
|
||||
|
||||
return "\x1bPq" + "".join(sixel_data)
|
||||
|
||||
|
||||
class SixelDisplay:
|
||||
"""Sixel graphics display backend - renders to sixel graphics in terminal."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self._initialized = False
|
||||
self._font_path = None
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path from env or detect common locations."""
|
||||
import os
|
||||
|
||||
if self._font_path:
|
||||
return self._font_path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_SIXEL_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
self._font_path = env_font
|
||||
return env_font
|
||||
|
||||
font_path = get_default_font_path()
|
||||
if font_path:
|
||||
self._font_path = font_path
|
||||
|
||||
return self._font_path
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for SixelDisplay
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._initialized = True
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested
|
||||
if border:
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
img_width = self.width * self.cell_width
|
||||
img_height = self.height * self.cell_height
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
font_path = self._get_font_path()
|
||||
font = None
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
if font is None:
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * self.cell_height
|
||||
|
||||
for text, fg, bg, bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||
draw.rectangle(bbox, fill=(*bg, 255))
|
||||
|
||||
if bold and font:
|
||||
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
sixel = _encode_sixel(img)
|
||||
|
||||
sys.stdout.buffer.write(sixel.encode("utf-8"))
|
||||
sys.stdout.flush()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("sixel_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
import sys
|
||||
|
||||
sys.stdout.buffer.write(b"\x1b[2J\x1b[H")
|
||||
sys.stdout.flush()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
146
engine/display/backends/terminal.py
Normal file
146
engine/display/backends/terminal.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
ANSI terminal display backend.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
class TerminalDisplay:
|
||||
"""ANSI terminal display backend.
|
||||
|
||||
Renders buffer to stdout using ANSI escape codes.
|
||||
Supports reuse - when reuse=True, skips re-initializing terminal state.
|
||||
Auto-detects terminal dimensions on init.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
_initialized: bool = False
|
||||
|
||||
def __init__(self, target_fps: float = 30.0):
|
||||
self.target_fps = target_fps
|
||||
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
||||
self._last_frame_time = 0.0
|
||||
self._cached_dimensions: tuple[int, int] | None = None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
If width/height are not provided (0/None), auto-detects terminal size.
|
||||
Otherwise uses provided dimensions or falls back to terminal size
|
||||
if the provided dimensions exceed terminal capacity.
|
||||
|
||||
Args:
|
||||
width: Desired terminal width (0 = auto-detect)
|
||||
height: Desired terminal height (0 = auto-detect)
|
||||
reuse: If True, skip terminal re-initialization
|
||||
"""
|
||||
from engine.terminal import CURSOR_OFF
|
||||
|
||||
# Auto-detect terminal size (handle case where no terminal)
|
||||
try:
|
||||
term_size = os.get_terminal_size()
|
||||
term_width = term_size.columns
|
||||
term_height = term_size.lines
|
||||
except OSError:
|
||||
# No terminal available (e.g., in tests)
|
||||
term_width = width if width > 0 else 80
|
||||
term_height = height if height > 0 else 24
|
||||
|
||||
# Use provided dimensions if valid, otherwise use terminal size
|
||||
if width > 0 and height > 0:
|
||||
self.width = min(width, term_width)
|
||||
self.height = min(height, term_height)
|
||||
else:
|
||||
self.width = term_width
|
||||
self.height = term_height
|
||||
|
||||
if not reuse or not self._initialized:
|
||||
print(CURSOR_OFF, end="", flush=True)
|
||||
self._initialized = True
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current terminal dimensions.
|
||||
|
||||
Returns cached dimensions to avoid querying terminal every frame,
|
||||
which can cause inconsistent results. Dimensions are only refreshed
|
||||
when they actually change.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
try:
|
||||
term_size = os.get_terminal_size()
|
||||
new_dims = (term_size.columns, term_size.lines)
|
||||
except OSError:
|
||||
new_dims = (self.width, self.height)
|
||||
|
||||
# Only update cached dimensions if they actually changed
|
||||
if self._cached_dimensions is None or self._cached_dimensions != new_dims:
|
||||
self._cached_dimensions = new_dims
|
||||
self.width = new_dims[0]
|
||||
self.height = new_dims[1]
|
||||
|
||||
return self._cached_dimensions
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
from engine.display import get_monitor, render_border
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# FPS limiting - skip frame if we're going too fast
|
||||
if self._frame_period > 0:
|
||||
now = time.perf_counter()
|
||||
elapsed = now - self._last_frame_time
|
||||
if elapsed < self._frame_period:
|
||||
# Skip this frame - too soon
|
||||
return
|
||||
self._last_frame_time = now
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested
|
||||
if border:
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
# Write buffer with cursor home + erase down to avoid flicker
|
||||
# \033[H = cursor home, \033[J = erase from cursor to end of screen
|
||||
output = "\033[H\033[J" + "".join(buffer)
|
||||
sys.stdout.buffer.write(output.encode())
|
||||
sys.stdout.flush()
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
from engine.terminal import CLR
|
||||
|
||||
print(CLR, end="", flush=True)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
from engine.terminal import CURSOR_ON
|
||||
|
||||
print(CURSOR_ON, end="", flush=True)
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if quit was requested (optional protocol method)."""
|
||||
return False
|
||||
|
||||
def clear_quit_request(self) -> None:
|
||||
"""Clear quit request (optional protocol method)."""
|
||||
pass
|
||||
276
engine/display/backends/websocket.py
Normal file
276
engine/display/backends/websocket.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError:
|
||||
websockets = None
|
||||
|
||||
|
||||
def get_monitor():
|
||||
"""Get the performance monitor."""
|
||||
try:
|
||||
from engine.effects.performance import get_monitor as _get_monitor
|
||||
|
||||
return _get_monitor()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class WebSocketDisplay:
|
||||
"""WebSocket display backend - broadcasts to HTML Canvas clients."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8765,
|
||||
http_port: int = 8766,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.http_port = http_port
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self._clients: set = set()
|
||||
self._server_running = False
|
||||
self._http_running = False
|
||||
self._server_thread: threading.Thread | None = None
|
||||
self._http_thread: threading.Thread | None = None
|
||||
self._available = True
|
||||
self._max_clients = 10
|
||||
self._client_connected_callback = None
|
||||
self._client_disconnected_callback = None
|
||||
self._frame_delay = 0.0
|
||||
|
||||
try:
|
||||
import websockets as _ws
|
||||
|
||||
self._available = _ws is not None
|
||||
except ImportError:
|
||||
self._available = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if WebSocket support is available."""
|
||||
return self._available
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions and start server.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, skip starting servers (assume already running)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
if not reuse or not self._server_running:
|
||||
self.start_server()
|
||||
self.start_http_server()
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
"""Broadcast buffer to all connected clients."""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested
|
||||
if border:
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
if self._clients:
|
||||
frame_data = {
|
||||
"type": "frame",
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"lines": buffer,
|
||||
}
|
||||
message = json.dumps(frame_data)
|
||||
|
||||
disconnected = set()
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
asyncio.run(client.send(message))
|
||||
except Exception:
|
||||
disconnected.add(client)
|
||||
|
||||
for client in disconnected:
|
||||
self._clients.discard(client)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(client)
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Broadcast clear command to all clients."""
|
||||
if self._clients:
|
||||
clear_data = {"type": "clear"}
|
||||
message = json.dumps(clear_data)
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
asyncio.run(client.send(message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Stop the servers."""
|
||||
self.stop_server()
|
||||
self.stop_http_server()
|
||||
|
||||
async def _websocket_handler(self, websocket):
|
||||
"""Handle WebSocket connections."""
|
||||
if len(self._clients) >= self._max_clients:
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
self._clients.add(websocket)
|
||||
if self._client_connected_callback:
|
||||
self._client_connected_callback(websocket)
|
||||
|
||||
try:
|
||||
async for message in websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
if data.get("type") == "resize":
|
||||
self.width = data.get("width", 80)
|
||||
self.height = data.get("height", 24)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._clients.discard(websocket)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(websocket)
|
||||
|
||||
async def _run_websocket_server(self):
|
||||
"""Run the WebSocket server."""
|
||||
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
||||
while self._server_running:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def _run_http_server(self):
|
||||
"""Run simple HTTP server for the client."""
|
||||
import os
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
|
||||
client_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
||||
)
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=client_dir, **kwargs)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||
while self._http_running:
|
||||
httpd.handle_request()
|
||||
|
||||
def _run_async(self, coro):
|
||||
"""Run coroutine in background."""
|
||||
try:
|
||||
asyncio.run(coro)
|
||||
except Exception as e:
|
||||
print(f"WebSocket async error: {e}")
|
||||
|
||||
def start_server(self):
|
||||
"""Start the WebSocket server in a background thread."""
|
||||
if not self._available:
|
||||
return
|
||||
if self._server_thread is not None:
|
||||
return
|
||||
|
||||
self._server_running = True
|
||||
self._server_thread = threading.Thread(
|
||||
target=self._run_async, args=(self._run_websocket_server(),), daemon=True
|
||||
)
|
||||
self._server_thread.start()
|
||||
|
||||
def stop_server(self):
|
||||
"""Stop the WebSocket server."""
|
||||
self._server_running = False
|
||||
self._server_thread = None
|
||||
|
||||
def start_http_server(self):
|
||||
"""Start the HTTP server in a background thread."""
|
||||
if not self._available:
|
||||
return
|
||||
if self._http_thread is not None:
|
||||
return
|
||||
|
||||
self._http_running = True
|
||||
|
||||
self._http_running = True
|
||||
self._http_thread = threading.Thread(
|
||||
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
||||
)
|
||||
self._http_thread.start()
|
||||
|
||||
def stop_http_server(self):
|
||||
"""Stop the HTTP server."""
|
||||
self._http_running = False
|
||||
self._http_thread = None
|
||||
|
||||
def client_count(self) -> int:
|
||||
"""Return number of connected clients."""
|
||||
return len(self._clients)
|
||||
|
||||
def get_ws_port(self) -> int:
|
||||
"""Return WebSocket port."""
|
||||
return self.port
|
||||
|
||||
def get_http_port(self) -> int:
|
||||
"""Return HTTP port."""
|
||||
return self.http_port
|
||||
|
||||
def set_frame_delay(self, delay: float) -> None:
|
||||
"""Set delay between frames in seconds."""
|
||||
self._frame_delay = delay
|
||||
|
||||
def get_frame_delay(self) -> float:
|
||||
"""Get delay between frames."""
|
||||
return self._frame_delay
|
||||
|
||||
def set_client_connected_callback(self, callback) -> None:
|
||||
"""Set callback for client connections."""
|
||||
self._client_connected_callback = callback
|
||||
|
||||
def set_client_disconnected_callback(self, callback) -> None:
|
||||
"""Set callback for client disconnections."""
|
||||
self._client_disconnected_callback = callback
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
280
engine/display/renderer.py
Normal file
280
engine/display/renderer.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Shared display rendering utilities.
|
||||
|
||||
Provides common functionality for displays that render text to images
|
||||
(Pygame, Sixel, Kitty displays).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
ANSI_COLORS = {
|
||||
0: (0, 0, 0),
|
||||
1: (205, 49, 49),
|
||||
2: (13, 188, 121),
|
||||
3: (229, 229, 16),
|
||||
4: (36, 114, 200),
|
||||
5: (188, 63, 188),
|
||||
6: (17, 168, 205),
|
||||
7: (229, 229, 229),
|
||||
8: (102, 102, 102),
|
||||
9: (241, 76, 76),
|
||||
10: (35, 209, 139),
|
||||
11: (245, 245, 67),
|
||||
12: (59, 142, 234),
|
||||
13: (214, 112, 214),
|
||||
14: (41, 184, 219),
|
||||
15: (255, 255, 255),
|
||||
}
|
||||
|
||||
|
||||
def parse_ansi(
|
||||
text: str,
|
||||
) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]:
|
||||
"""Parse ANSI escape sequences into text tokens with colors.
|
||||
|
||||
Args:
|
||||
text: Text containing ANSI escape sequences
|
||||
|
||||
Returns:
|
||||
List of (text, fg_rgb, bg_rgb, bold) tuples
|
||||
"""
|
||||
tokens = []
|
||||
current_text = ""
|
||||
fg = (204, 204, 204)
|
||||
bg = (0, 0, 0)
|
||||
bold = False
|
||||
i = 0
|
||||
|
||||
ANSI_COLORS_4BIT = {
|
||||
0: (0, 0, 0),
|
||||
1: (205, 49, 49),
|
||||
2: (13, 188, 121),
|
||||
3: (229, 229, 16),
|
||||
4: (36, 114, 200),
|
||||
5: (188, 63, 188),
|
||||
6: (17, 168, 205),
|
||||
7: (229, 229, 229),
|
||||
8: (102, 102, 102),
|
||||
9: (241, 76, 76),
|
||||
10: (35, 209, 139),
|
||||
11: (245, 245, 67),
|
||||
12: (59, 142, 234),
|
||||
13: (214, 112, 214),
|
||||
14: (41, 184, 219),
|
||||
15: (255, 255, 255),
|
||||
}
|
||||
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
|
||||
if char == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
|
||||
if current_text:
|
||||
tokens.append((current_text, fg, bg, bold))
|
||||
current_text = ""
|
||||
|
||||
i += 2
|
||||
code = ""
|
||||
while i < len(text):
|
||||
c = text[i]
|
||||
if c.isalpha():
|
||||
break
|
||||
code += c
|
||||
i += 1
|
||||
|
||||
if code:
|
||||
codes = code.split(";")
|
||||
for c in codes:
|
||||
if c == "0":
|
||||
fg = (204, 204, 204)
|
||||
bg = (0, 0, 0)
|
||||
bold = False
|
||||
elif c == "1":
|
||||
bold = True
|
||||
elif c == "22":
|
||||
bold = False
|
||||
elif c == "39":
|
||||
fg = (204, 204, 204)
|
||||
elif c == "49":
|
||||
bg = (0, 0, 0)
|
||||
elif c.isdigit():
|
||||
color_idx = int(c)
|
||||
if color_idx in ANSI_COLORS_4BIT:
|
||||
fg = ANSI_COLORS_4BIT[color_idx]
|
||||
elif 30 <= color_idx <= 37:
|
||||
fg = ANSI_COLORS_4BIT.get(color_idx - 30, fg)
|
||||
elif 40 <= color_idx <= 47:
|
||||
bg = ANSI_COLORS_4BIT.get(color_idx - 40, bg)
|
||||
elif 90 <= color_idx <= 97:
|
||||
fg = ANSI_COLORS_4BIT.get(color_idx - 90 + 8, fg)
|
||||
elif 100 <= color_idx <= 107:
|
||||
bg = ANSI_COLORS_4BIT.get(color_idx - 100 + 8, bg)
|
||||
elif c.startswith("38;5;"):
|
||||
idx = int(c.split(";")[-1])
|
||||
if idx < 256:
|
||||
if idx < 16:
|
||||
fg = ANSI_COLORS_4BIT.get(idx, fg)
|
||||
elif idx < 232:
|
||||
c_idx = idx - 16
|
||||
fg = (
|
||||
(c_idx >> 4) * 51,
|
||||
((c_idx >> 2) & 7) * 51,
|
||||
(c_idx & 3) * 85,
|
||||
)
|
||||
else:
|
||||
gray = (idx - 232) * 10 + 8
|
||||
fg = (gray, gray, gray)
|
||||
elif c.startswith("48;5;"):
|
||||
idx = int(c.split(";")[-1])
|
||||
if idx < 256:
|
||||
if idx < 16:
|
||||
bg = ANSI_COLORS_4BIT.get(idx, bg)
|
||||
elif idx < 232:
|
||||
c_idx = idx - 16
|
||||
bg = (
|
||||
(c_idx >> 4) * 51,
|
||||
((c_idx >> 2) & 7) * 51,
|
||||
(c_idx & 3) * 85,
|
||||
)
|
||||
else:
|
||||
gray = (idx - 232) * 10 + 8
|
||||
bg = (gray, gray, gray)
|
||||
i += 1
|
||||
else:
|
||||
current_text += char
|
||||
i += 1
|
||||
|
||||
if current_text:
|
||||
tokens.append((current_text, fg, bg, bold))
|
||||
|
||||
return tokens if tokens else [("", fg, bg, bold)]
|
||||
|
||||
|
||||
def get_default_font_path() -> str | None:
|
||||
"""Get the path to a default monospace font."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def search_dir(base_path: str) -> str | None:
|
||||
if not os.path.exists(base_path):
|
||||
return None
|
||||
if os.path.isfile(base_path):
|
||||
return base_path
|
||||
for font_file in Path(base_path).rglob("*"):
|
||||
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
name = font_file.stem.lower()
|
||||
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||
return str(font_file)
|
||||
if "mono" in name or "courier" in name or "terminal" in name:
|
||||
return str(font_file)
|
||||
return None
|
||||
|
||||
search_dirs = []
|
||||
if sys.platform == "darwin":
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/Library/Fonts/"),
|
||||
"/System/Library/Fonts/",
|
||||
]
|
||||
)
|
||||
elif sys.platform == "win32":
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\"),
|
||||
"C:\\Windows\\Fonts\\",
|
||||
]
|
||||
)
|
||||
else:
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/.local/share/fonts/"),
|
||||
os.path.expanduser("~/.fonts/"),
|
||||
"/usr/share/fonts/",
|
||||
]
|
||||
)
|
||||
|
||||
for search_dir_path in search_dirs:
|
||||
found = search_dir(search_dir_path)
|
||||
if found:
|
||||
return found
|
||||
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
for pattern in ["monospace", "DejaVuSansMono", "LiberationMono"]:
|
||||
result = subprocess.run(
|
||||
["fc-match", "-f", "%{file}", pattern],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
font_file = result.stdout.strip()
|
||||
if os.path.exists(font_file):
|
||||
return font_file
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def render_to_pil(
|
||||
buffer: list[str],
|
||||
width: int,
|
||||
height: int,
|
||||
cell_width: int = 10,
|
||||
cell_height: int = 18,
|
||||
font_path: str | None = None,
|
||||
) -> Any:
|
||||
"""Render buffer to a PIL Image.
|
||||
|
||||
Args:
|
||||
buffer: List of text lines to render
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
cell_width: Width of each character cell in pixels
|
||||
cell_height: Height of each character cell in pixels
|
||||
font_path: Path to TTF/OTF font file (optional)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
img_width = width * cell_width
|
||||
img_height = height * cell_height
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, cell_height - 2)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
else:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for row_idx, line in enumerate(buffer[:height]):
|
||||
if row_idx >= height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * cell_height
|
||||
|
||||
for text, fg, bg, _bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||
draw.rectangle(bbox, fill=(*bg, 255))
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
return img
|
||||
42
engine/effects/__init__.py
Normal file
42
engine/effects/__init__.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from engine.effects.chain import EffectChain
|
||||
from engine.effects.controller import handle_effects_command, show_effects_menu
|
||||
from engine.effects.legacy import (
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
next_headline,
|
||||
noise,
|
||||
vis_offset,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
||||
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||
from engine.effects.types import (
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
PipelineConfig,
|
||||
create_effect_context,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EffectChain",
|
||||
"EffectRegistry",
|
||||
"EffectConfig",
|
||||
"EffectContext",
|
||||
"PipelineConfig",
|
||||
"create_effect_context",
|
||||
"get_registry",
|
||||
"set_registry",
|
||||
"get_monitor",
|
||||
"set_monitor",
|
||||
"PerformanceMonitor",
|
||||
"handle_effects_command",
|
||||
"show_effects_menu",
|
||||
"fade_line",
|
||||
"firehose_line",
|
||||
"glitch_bar",
|
||||
"noise",
|
||||
"next_headline",
|
||||
"vis_trunc",
|
||||
"vis_offset",
|
||||
]
|
||||
87
engine/effects/chain.py
Normal file
87
engine/effects/chain.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import time
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor, get_monitor
|
||||
from engine.effects.registry import EffectRegistry
|
||||
from engine.effects.types import EffectContext, PartialUpdate
|
||||
|
||||
|
||||
class EffectChain:
|
||||
def __init__(
|
||||
self, registry: EffectRegistry, monitor: PerformanceMonitor | None = None
|
||||
):
|
||||
self._registry = registry
|
||||
self._order: list[str] = []
|
||||
self._monitor = monitor
|
||||
|
||||
def _get_monitor(self) -> PerformanceMonitor:
|
||||
if self._monitor is not None:
|
||||
return self._monitor
|
||||
return get_monitor()
|
||||
|
||||
def set_order(self, names: list[str]) -> None:
|
||||
self._order = list(names)
|
||||
|
||||
def get_order(self) -> list[str]:
|
||||
return self._order.copy()
|
||||
|
||||
def add_effect(self, name: str, position: int | None = None) -> bool:
|
||||
if name not in self._registry.list_all():
|
||||
return False
|
||||
if position is None:
|
||||
self._order.append(name)
|
||||
else:
|
||||
self._order.insert(position, name)
|
||||
return True
|
||||
|
||||
def remove_effect(self, name: str) -> bool:
|
||||
if name in self._order:
|
||||
self._order.remove(name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def reorder(self, new_order: list[str]) -> bool:
|
||||
all_plugins = set(self._registry.list_all().keys())
|
||||
if not all(name in all_plugins for name in new_order):
|
||||
return False
|
||||
self._order = list(new_order)
|
||||
return True
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
monitor = self._get_monitor()
|
||||
frame_number = ctx.frame_number
|
||||
monitor.start_frame(frame_number)
|
||||
|
||||
# Get dirty regions from canvas via context (set by CanvasStage)
|
||||
dirty_rows = ctx.get_state("canvas.dirty_rows")
|
||||
|
||||
# Create PartialUpdate for effects that support it
|
||||
full_buffer = dirty_rows is None or len(dirty_rows) == 0
|
||||
partial = PartialUpdate(
|
||||
rows=None,
|
||||
cols=None,
|
||||
dirty=dirty_rows,
|
||||
full_buffer=full_buffer,
|
||||
)
|
||||
|
||||
frame_start = time.perf_counter()
|
||||
result = list(buf)
|
||||
for name in self._order:
|
||||
plugin = self._registry.get(name)
|
||||
if plugin and plugin.config.enabled:
|
||||
chars_in = sum(len(line) for line in result)
|
||||
effect_start = time.perf_counter()
|
||||
try:
|
||||
# Use process_partial if supported, otherwise fall back to process
|
||||
if getattr(plugin, "supports_partial_updates", False):
|
||||
result = plugin.process_partial(result, ctx, partial)
|
||||
else:
|
||||
result = plugin.process(result, ctx)
|
||||
except Exception:
|
||||
plugin.config.enabled = False
|
||||
elapsed = time.perf_counter() - effect_start
|
||||
chars_out = sum(len(line) for line in result)
|
||||
monitor.record_effect(name, elapsed * 1000, chars_in, chars_out)
|
||||
|
||||
total_elapsed = time.perf_counter() - frame_start
|
||||
monitor.end_frame(frame_number, total_elapsed * 1000)
|
||||
return result
|
||||
137
engine/effects/controller.py
Normal file
137
engine/effects/controller.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from engine.effects.performance import get_monitor
|
||||
from engine.effects.registry import get_registry
|
||||
|
||||
_effect_chain_ref = None
|
||||
|
||||
|
||||
def _get_effect_chain():
|
||||
global _effect_chain_ref
|
||||
return _effect_chain_ref
|
||||
|
||||
|
||||
def set_effect_chain_ref(chain) -> None:
|
||||
global _effect_chain_ref
|
||||
_effect_chain_ref = chain
|
||||
|
||||
|
||||
def handle_effects_command(cmd: str) -> str:
|
||||
"""Handle /effects command from NTFY message.
|
||||
|
||||
Commands:
|
||||
/effects list - list all effects and their status
|
||||
/effects <name> on - enable an effect
|
||||
/effects <name> off - disable an effect
|
||||
/effects <name> intensity <0.0-1.0> - set intensity
|
||||
/effects reorder <name1>,<name2>,... - reorder pipeline
|
||||
/effects stats - show performance statistics
|
||||
"""
|
||||
parts = cmd.strip().split()
|
||||
if not parts or parts[0] != "/effects":
|
||||
return "Unknown command"
|
||||
|
||||
registry = get_registry()
|
||||
chain = _get_effect_chain()
|
||||
|
||||
if len(parts) == 1 or parts[1] == "list":
|
||||
result = ["Effects:"]
|
||||
for name, plugin in registry.list_all().items():
|
||||
status = "ON" if plugin.config.enabled else "OFF"
|
||||
intensity = plugin.config.intensity
|
||||
result.append(f" {name}: {status} (intensity={intensity})")
|
||||
if chain:
|
||||
result.append(f"Order: {chain.get_order()}")
|
||||
return "\n".join(result)
|
||||
|
||||
if parts[1] == "stats":
|
||||
return _format_stats()
|
||||
|
||||
if parts[1] == "reorder" and len(parts) >= 3:
|
||||
new_order = parts[2].split(",")
|
||||
if chain and chain.reorder(new_order):
|
||||
return f"Reordered pipeline: {new_order}"
|
||||
return "Failed to reorder pipeline"
|
||||
|
||||
if len(parts) < 3:
|
||||
return "Usage: /effects <name> on|off|intensity <value>"
|
||||
|
||||
effect_name = parts[1]
|
||||
action = parts[2]
|
||||
|
||||
if effect_name not in registry.list_all():
|
||||
return f"Unknown effect: {effect_name}"
|
||||
|
||||
if action == "on":
|
||||
registry.enable(effect_name)
|
||||
return f"Enabled: {effect_name}"
|
||||
|
||||
if action == "off":
|
||||
registry.disable(effect_name)
|
||||
return f"Disabled: {effect_name}"
|
||||
|
||||
if action == "intensity" and len(parts) >= 4:
|
||||
try:
|
||||
value = float(parts[3])
|
||||
if not 0.0 <= value <= 1.0:
|
||||
return "Intensity must be between 0.0 and 1.0"
|
||||
plugin = registry.get(effect_name)
|
||||
if plugin:
|
||||
plugin.config.intensity = value
|
||||
return f"Set {effect_name} intensity to {value}"
|
||||
except ValueError:
|
||||
return "Invalid intensity value"
|
||||
|
||||
return f"Unknown action: {action}"
|
||||
|
||||
|
||||
def _format_stats() -> str:
|
||||
monitor = get_monitor()
|
||||
stats = monitor.get_stats()
|
||||
|
||||
if "error" in stats:
|
||||
return stats["error"]
|
||||
|
||||
lines = ["Performance Stats:"]
|
||||
|
||||
pipeline = stats["pipeline"]
|
||||
lines.append(
|
||||
f" Pipeline: avg={pipeline['avg_ms']:.2f}ms min={pipeline['min_ms']:.2f}ms max={pipeline['max_ms']:.2f}ms (over {stats['frame_count']} frames)"
|
||||
)
|
||||
|
||||
if stats["effects"]:
|
||||
lines.append(" Per-effect (avg ms):")
|
||||
for name, effect_stats in stats["effects"].items():
|
||||
lines.append(
|
||||
f" {name}: avg={effect_stats['avg_ms']:.2f}ms min={effect_stats['min_ms']:.2f}ms max={effect_stats['max_ms']:.2f}ms"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def show_effects_menu() -> str:
|
||||
"""Generate effects menu text for display."""
|
||||
registry = get_registry()
|
||||
chain = _get_effect_chain()
|
||||
|
||||
lines = [
|
||||
"\033[1;38;5;231m=== EFFECTS MENU ===\033[0m",
|
||||
"",
|
||||
"Effects:",
|
||||
]
|
||||
|
||||
for name, plugin in registry.list_all().items():
|
||||
status = "ON" if plugin.config.enabled else "OFF"
|
||||
intensity = plugin.config.intensity
|
||||
lines.append(f" [{status:3}] {name}: intensity={intensity:.2f}")
|
||||
|
||||
if chain:
|
||||
lines.append("")
|
||||
lines.append(f"Pipeline order: {' -> '.join(chain.get_order())}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("Controls:")
|
||||
lines.append(" /effects <name> on|off")
|
||||
lines.append(" /effects <name> intensity <0.0-1.0>")
|
||||
lines.append(" /effects reorder name1,name2,...")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,6 +1,14 @@
|
||||
"""
|
||||
Visual effects: noise, glitch, fade, ANSI-aware truncation, firehose, headline pool.
|
||||
Depends on: config, terminal, sources.
|
||||
|
||||
These are low-level functional implementations of visual effects. They are used
|
||||
internally by the EffectPlugin system (effects_plugins/*.py) and also directly
|
||||
by layers.py and scroll.py for rendering.
|
||||
|
||||
The plugin system provides a higher-level OOP interface with configuration
|
||||
support, while these legacy functions provide direct functional access.
|
||||
Both systems coexist - there are no current plans to deprecate the legacy functions.
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -74,6 +82,37 @@ def vis_trunc(s, w):
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def vis_offset(s, offset):
|
||||
"""Offset string by skipping first offset visual characters, skipping ANSI escape codes."""
|
||||
if offset <= 0:
|
||||
return s
|
||||
result = []
|
||||
vw = 0
|
||||
i = 0
|
||||
skipping = True
|
||||
while i < len(s):
|
||||
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||
j = i + 2
|
||||
while j < len(s) and not s[j].isalpha():
|
||||
j += 1
|
||||
if skipping:
|
||||
i = j + 1
|
||||
continue
|
||||
result.append(s[i : j + 1])
|
||||
i = j + 1
|
||||
else:
|
||||
if skipping:
|
||||
if vw >= offset:
|
||||
skipping = False
|
||||
result.append(s[i])
|
||||
vw += 1
|
||||
i += 1
|
||||
else:
|
||||
result.append(s[i])
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def next_headline(pool, items, seen):
|
||||
"""Pull the next unique headline from pool, refilling as needed."""
|
||||
while True:
|
||||
103
engine/effects/performance.py
Normal file
103
engine/effects/performance.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectTiming:
|
||||
name: str
|
||||
duration_ms: float
|
||||
buffer_chars_in: int
|
||||
buffer_chars_out: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameTiming:
|
||||
frame_number: int
|
||||
total_ms: float
|
||||
effects: list[EffectTiming]
|
||||
|
||||
|
||||
class PerformanceMonitor:
|
||||
"""Collects and stores performance metrics for effect pipeline."""
|
||||
|
||||
def __init__(self, max_frames: int = 60):
|
||||
self._max_frames = max_frames
|
||||
self._frames: deque[FrameTiming] = deque(maxlen=max_frames)
|
||||
self._current_frame: list[EffectTiming] = []
|
||||
|
||||
def start_frame(self, frame_number: int) -> None:
|
||||
self._current_frame = []
|
||||
|
||||
def record_effect(
|
||||
self, name: str, duration_ms: float, chars_in: int, chars_out: int
|
||||
) -> None:
|
||||
self._current_frame.append(
|
||||
EffectTiming(
|
||||
name=name,
|
||||
duration_ms=duration_ms,
|
||||
buffer_chars_in=chars_in,
|
||||
buffer_chars_out=chars_out,
|
||||
)
|
||||
)
|
||||
|
||||
def end_frame(self, frame_number: int, total_ms: float) -> None:
|
||||
self._frames.append(
|
||||
FrameTiming(
|
||||
frame_number=frame_number,
|
||||
total_ms=total_ms,
|
||||
effects=self._current_frame,
|
||||
)
|
||||
)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
if not self._frames:
|
||||
return {"error": "No timing data available"}
|
||||
|
||||
total_times = [f.total_ms for f in self._frames]
|
||||
avg_total = sum(total_times) / len(total_times)
|
||||
min_total = min(total_times)
|
||||
max_total = max(total_times)
|
||||
|
||||
effect_stats: dict[str, dict] = {}
|
||||
for frame in self._frames:
|
||||
for effect in frame.effects:
|
||||
if effect.name not in effect_stats:
|
||||
effect_stats[effect.name] = {"times": [], "total_chars": 0}
|
||||
effect_stats[effect.name]["times"].append(effect.duration_ms)
|
||||
effect_stats[effect.name]["total_chars"] += effect.buffer_chars_out
|
||||
|
||||
for name, stats in effect_stats.items():
|
||||
times = stats["times"]
|
||||
stats["avg_ms"] = sum(times) / len(times)
|
||||
stats["min_ms"] = min(times)
|
||||
stats["max_ms"] = max(times)
|
||||
del stats["times"]
|
||||
|
||||
return {
|
||||
"frame_count": len(self._frames),
|
||||
"pipeline": {
|
||||
"avg_ms": avg_total,
|
||||
"min_ms": min_total,
|
||||
"max_ms": max_total,
|
||||
},
|
||||
"effects": effect_stats,
|
||||
}
|
||||
|
||||
def reset(self) -> None:
|
||||
self._frames.clear()
|
||||
self._current_frame = []
|
||||
|
||||
|
||||
_monitor: PerformanceMonitor | None = None
|
||||
|
||||
|
||||
def get_monitor() -> PerformanceMonitor:
|
||||
global _monitor
|
||||
if _monitor is None:
|
||||
_monitor = PerformanceMonitor()
|
||||
return _monitor
|
||||
|
||||
|
||||
def set_monitor(monitor: PerformanceMonitor) -> None:
|
||||
global _monitor
|
||||
_monitor = monitor
|
||||
38
engine/effects/plugins/__init__.py
Normal file
38
engine/effects/plugins/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def discover_plugins():
|
||||
from engine.effects.registry import get_registry
|
||||
from engine.effects.types import EffectPlugin
|
||||
|
||||
registry = get_registry()
|
||||
imported = {}
|
||||
|
||||
for file_path in PLUGIN_DIR.glob("*.py"):
|
||||
if file_path.name.startswith("_"):
|
||||
continue
|
||||
module_name = file_path.stem
|
||||
if module_name in ("base", "types"):
|
||||
continue
|
||||
|
||||
try:
|
||||
module = __import__(f"engine.effects.plugins.{module_name}", fromlist=[""])
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and issubclass(attr, EffectPlugin)
|
||||
and attr is not EffectPlugin
|
||||
and attr_name.endswith("Effect")
|
||||
):
|
||||
plugin = attr()
|
||||
if not isinstance(plugin, EffectPlugin):
|
||||
continue
|
||||
registry.register(plugin)
|
||||
imported[plugin.name] = plugin
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return imported
|
||||
105
engine/effects/plugins/border.py
Normal file
105
engine/effects/plugins/border.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class BorderEffect(EffectPlugin):
|
||||
"""Simple border effect for terminal display.
|
||||
|
||||
Draws a border around the buffer and optionally displays
|
||||
performance metrics in the border corners.
|
||||
|
||||
Internally crops to display dimensions to ensure border fits.
|
||||
"""
|
||||
|
||||
name = "border"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
|
||||
# Get actual display dimensions from context
|
||||
display_w = ctx.terminal_width
|
||||
display_h = ctx.terminal_height
|
||||
|
||||
# If dimensions are reasonable, crop first - use slightly smaller to ensure fit
|
||||
if display_w >= 10 and display_h >= 3:
|
||||
# Subtract 2 for border characters (left and right)
|
||||
crop_w = display_w - 2
|
||||
crop_h = display_h - 2
|
||||
buf = self._crop_to_size(buf, crop_w, crop_h)
|
||||
w = display_w
|
||||
h = display_h
|
||||
else:
|
||||
# Use buffer dimensions
|
||||
h = len(buf)
|
||||
w = max(len(line) for line in buf) if buf else 0
|
||||
|
||||
if w < 3 or h < 3:
|
||||
return buf
|
||||
|
||||
inner_w = w - 2
|
||||
|
||||
# Get metrics from context
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
metrics = ctx.get_state("metrics")
|
||||
if metrics:
|
||||
avg_ms = metrics.get("avg_ms")
|
||||
frame_count = metrics.get("frame_count", 0)
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Build borders
|
||||
# Top border: ┌────────────────────┐ or with FPS
|
||||
if fps > 0:
|
||||
fps_str = f" FPS:{fps:.0f}"
|
||||
if len(fps_str) < inner_w:
|
||||
right_len = inner_w - len(fps_str)
|
||||
top_border = "┌" + "─" * right_len + fps_str + "┐"
|
||||
else:
|
||||
top_border = "┌" + "─" * inner_w + "┐"
|
||||
else:
|
||||
top_border = "┌" + "─" * inner_w + "┐"
|
||||
|
||||
# Bottom border: └────────────────────┘ or with frame time
|
||||
if frame_time > 0:
|
||||
ft_str = f" {frame_time:.1f}ms"
|
||||
if len(ft_str) < inner_w:
|
||||
right_len = inner_w - len(ft_str)
|
||||
bottom_border = "└" + "─" * right_len + ft_str + "┘"
|
||||
else:
|
||||
bottom_border = "└" + "─" * inner_w + "┘"
|
||||
else:
|
||||
bottom_border = "└" + "─" * inner_w + "┘"
|
||||
|
||||
# Build result with left/right borders
|
||||
result = [top_border]
|
||||
for line in buf[: h - 2]:
|
||||
if len(line) >= inner_w:
|
||||
result.append("│" + line[:inner_w] + "│")
|
||||
else:
|
||||
result.append("│" + line + " " * (inner_w - len(line)) + "│")
|
||||
|
||||
result.append(bottom_border)
|
||||
|
||||
return result
|
||||
|
||||
def _crop_to_size(self, buf: list[str], w: int, h: int) -> list[str]:
|
||||
"""Crop buffer to fit within w x h."""
|
||||
result = []
|
||||
for i in range(min(h, len(buf))):
|
||||
line = buf[i]
|
||||
if len(line) > w:
|
||||
result.append(line[:w])
|
||||
else:
|
||||
result.append(line + " " * (w - len(line)))
|
||||
|
||||
# Pad with empty lines if needed (for border)
|
||||
while len(result) < h:
|
||||
result.append(" " * w)
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
42
engine/effects/plugins/crop.py
Normal file
42
engine/effects/plugins/crop.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class CropEffect(EffectPlugin):
|
||||
"""Crop effect that crops the input buffer to fit the display.
|
||||
|
||||
This ensures the output buffer matches the actual display dimensions,
|
||||
useful when the source produces a buffer larger than the viewport.
|
||||
"""
|
||||
|
||||
name = "crop"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
|
||||
# Get actual display dimensions from context
|
||||
w = (
|
||||
ctx.terminal_width
|
||||
if ctx.terminal_width > 0
|
||||
else max(len(line) for line in buf)
|
||||
)
|
||||
h = ctx.terminal_height if ctx.terminal_height > 0 else len(buf)
|
||||
|
||||
# Crop buffer to fit
|
||||
result = []
|
||||
for i in range(min(h, len(buf))):
|
||||
line = buf[i]
|
||||
if len(line) > w:
|
||||
result.append(line[:w])
|
||||
else:
|
||||
result.append(line + " " * (w - len(line)))
|
||||
|
||||
# Pad with empty lines if needed
|
||||
while len(result) < h:
|
||||
result.append(" " * w)
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
58
engine/effects/plugins/fade.py
Normal file
58
engine/effects/plugins/fade.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import random
|
||||
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class FadeEffect(EffectPlugin):
|
||||
name = "fade"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not ctx.ticker_height:
|
||||
return buf
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
|
||||
top_zone = max(1, int(ctx.ticker_height * 0.25))
|
||||
bot_zone = max(1, int(ctx.ticker_height * 0.10))
|
||||
|
||||
for r in range(len(result)):
|
||||
if r >= ctx.ticker_height:
|
||||
continue
|
||||
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||
bot_f = (
|
||||
min(1.0, (ctx.ticker_height - 1 - r) / bot_zone)
|
||||
if bot_zone > 0
|
||||
else 1.0
|
||||
)
|
||||
row_fade = min(top_f, bot_f) * intensity
|
||||
|
||||
if row_fade < 1.0 and result[r].strip():
|
||||
result[r] = self._fade_line(result[r], row_fade)
|
||||
|
||||
return result
|
||||
|
||||
def _fade_line(self, s: str, fade: float) -> str:
|
||||
if fade >= 1.0:
|
||||
return s
|
||||
if fade <= 0.0:
|
||||
return s # Preserve original line length - don't return empty
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||
j = i + 2
|
||||
while j < len(s) and not s[j].isalpha():
|
||||
j += 1
|
||||
result.append(s[i : j + 1])
|
||||
i = j + 1
|
||||
elif s[i] == " ":
|
||||
result.append(" ")
|
||||
i += 1
|
||||
else:
|
||||
result.append(s[i] if random.random() < fade else " ")
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
72
engine/effects/plugins/firehose.py
Normal file
72
engine/effects/plugins/firehose.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.sources import FEEDS, POETRY_SOURCES
|
||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
|
||||
class FirehoseEffect(EffectPlugin):
|
||||
name = "firehose"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
if firehose_h <= 0 or not ctx.items:
|
||||
return buf
|
||||
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
h = ctx.terminal_height
|
||||
|
||||
for fr in range(firehose_h):
|
||||
scr_row = h - firehose_h + fr + 1
|
||||
fline = self._firehose_line(ctx.items, ctx.terminal_width, intensity)
|
||||
result.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||
return result
|
||||
|
||||
def _firehose_line(self, items: list, w: int, intensity: float) -> str:
|
||||
r = random.random()
|
||||
if r < 0.35 * intensity:
|
||||
title, src, ts = random.choice(items)
|
||||
text = title[: w - 1]
|
||||
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||
return f"{color}{text}{RST}"
|
||||
elif r < 0.55 * intensity:
|
||||
d = random.choice([0.45, 0.55, 0.65, 0.75])
|
||||
return "".join(
|
||||
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||
if random.random() < d
|
||||
else " "
|
||||
for _ in range(w)
|
||||
)
|
||||
elif r < 0.78 * intensity:
|
||||
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
|
||||
src = random.choice(list(sources.keys()))
|
||||
msgs = [
|
||||
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||
f" ░░ FEED ACTIVE :: {src}",
|
||||
f" >> DECODE 0x{random.randint(0x1000, 0xFFFF):04X} :: {src[:24]}",
|
||||
f" ▒▒ ACQUIRE :: {random.choice(['TCP', 'UDP', 'RSS', 'ATOM', 'XML'])} :: {src}",
|
||||
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||
]
|
||||
text = random.choice(msgs)[: w - 1]
|
||||
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||
return f"{color}{text}{RST}"
|
||||
else:
|
||||
title, _, _ = random.choice(items)
|
||||
start = random.randint(0, max(0, len(title) - 20))
|
||||
frag = title[start : start + random.randint(10, 35)]
|
||||
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||
gp = "".join(
|
||||
random.choice(config.GLITCH) for _ in range(random.randint(1, 3))
|
||||
)
|
||||
text = (" " * pad + gp + " " + frag)[: w - 1]
|
||||
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||
return f"{color}{text}{RST}"
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
52
engine/effects/plugins/glitch.py
Normal file
52
engine/effects/plugins/glitch.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import random
|
||||
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import DIM, G_LO, RST
|
||||
|
||||
|
||||
class GlitchEffect(EffectPlugin):
|
||||
name = "glitch"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
|
||||
glitch_prob = 0.32 + min(0.9, ctx.mic_excess * 0.16)
|
||||
glitch_prob = glitch_prob * intensity
|
||||
n_hits = 4 + int(ctx.mic_excess / 2)
|
||||
n_hits = int(n_hits * intensity)
|
||||
|
||||
if random.random() < glitch_prob:
|
||||
# Store original visible lengths before any modifications
|
||||
# Strip ANSI codes to get visible length
|
||||
import re
|
||||
|
||||
ansi_pattern = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
||||
original_lengths = [len(ansi_pattern.sub("", line)) for line in result]
|
||||
for _ in range(min(n_hits, len(result))):
|
||||
gi = random.randint(0, len(result) - 1)
|
||||
result[gi]
|
||||
target_len = original_lengths[gi] # Use stored original length
|
||||
glitch_bar = self._glitch_bar(target_len)
|
||||
result[gi] = glitch_bar
|
||||
return result
|
||||
|
||||
def _glitch_bar(self, target_len: int) -> str:
|
||||
c = random.choice(["░", "▒", "─", "\xc2"])
|
||||
n = random.randint(3, max(3, target_len // 2))
|
||||
o = random.randint(0, max(0, target_len - n))
|
||||
|
||||
glitch_chars = c * n
|
||||
trailing_spaces = target_len - o - n
|
||||
trailing_spaces = max(0, trailing_spaces)
|
||||
|
||||
glitch_part = f"{G_LO}{DIM}" + glitch_chars + RST
|
||||
result = " " * o + glitch_part + " " * trailing_spaces
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
102
engine/effects/plugins/hud.py
Normal file
102
engine/effects/plugins/hud.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from engine.effects.types import (
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectPlugin,
|
||||
PartialUpdate,
|
||||
)
|
||||
|
||||
|
||||
class HudEffect(EffectPlugin):
|
||||
name = "hud"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
supports_partial_updates = True # Enable partial update optimization
|
||||
|
||||
# Cache last HUD content to detect changes
|
||||
_last_hud_content: tuple | None = None
|
||||
|
||||
def process_partial(
|
||||
self, buf: list[str], ctx: EffectContext, partial: PartialUpdate
|
||||
) -> list[str]:
|
||||
# If full buffer requested, process normally
|
||||
if partial.full_buffer:
|
||||
return self.process(buf, ctx)
|
||||
|
||||
# If HUD rows (0, 1, 2) aren't dirty, skip processing
|
||||
if partial.dirty:
|
||||
hud_rows = {0, 1, 2}
|
||||
dirty_hud_rows = partial.dirty & hud_rows
|
||||
if not dirty_hud_rows:
|
||||
return buf # Nothing for HUD to do
|
||||
|
||||
# Proceed with full processing
|
||||
return self.process(buf, ctx)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
result = list(buf)
|
||||
|
||||
# Read metrics from pipeline context (first-class citizen)
|
||||
# Falls back to global monitor for backwards compatibility
|
||||
metrics = ctx.get_state("metrics")
|
||||
if not metrics:
|
||||
# Fallback to global monitor for backwards compatibility
|
||||
from engine.effects.performance import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
if stats and "pipeline" in stats:
|
||||
metrics = stats
|
||||
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
if metrics:
|
||||
if "error" in metrics:
|
||||
pass # No metrics available yet
|
||||
elif "pipeline" in metrics:
|
||||
frame_time = metrics["pipeline"].get("avg_ms", 0.0)
|
||||
frame_count = metrics.get("frame_count", 0)
|
||||
if frame_count > 0 and frame_time > 0:
|
||||
fps = 1000.0 / frame_time
|
||||
elif "avg_ms" in metrics:
|
||||
# Direct metrics format
|
||||
frame_time = metrics.get("avg_ms", 0.0)
|
||||
frame_count = metrics.get("frame_count", 0)
|
||||
if frame_count > 0 and frame_time > 0:
|
||||
fps = 1000.0 / frame_time
|
||||
|
||||
effect_name = self.config.params.get("display_effect", "none")
|
||||
effect_intensity = self.config.params.get("display_intensity", 0.0)
|
||||
|
||||
hud_lines = []
|
||||
hud_lines.append(
|
||||
f"\033[1;1H\033[38;5;46mMAINLINE DEMO\033[0m \033[38;5;245m|\033[0m \033[38;5;39mFPS: {fps:.1f}\033[0m \033[38;5;245m|\033[0m \033[38;5;208m{frame_time:.1f}ms\033[0m"
|
||||
)
|
||||
|
||||
bar_width = 20
|
||||
filled = int(bar_width * effect_intensity)
|
||||
bar = (
|
||||
"\033[38;5;82m"
|
||||
+ "█" * filled
|
||||
+ "\033[38;5;240m"
|
||||
+ "░" * (bar_width - filled)
|
||||
+ "\033[0m"
|
||||
)
|
||||
hud_lines.append(
|
||||
f"\033[2;1H\033[38;5;45mEFFECT:\033[0m \033[1;38;5;227m{effect_name:12s}\033[0m \033[38;5;245m|\033[0m {bar} \033[38;5;245m|\033[0m \033[38;5;219m{effect_intensity * 100:.0f}%\033[0m"
|
||||
)
|
||||
|
||||
# Get pipeline order from context
|
||||
pipeline_order = ctx.get_state("pipeline_order")
|
||||
pipeline_str = ",".join(pipeline_order) if pipeline_order else "(none)"
|
||||
hud_lines.append(f"\033[3;1H\033[38;5;44mPIPELINE:\033[0m {pipeline_str}")
|
||||
|
||||
for i, line in enumerate(hud_lines):
|
||||
if i < len(result):
|
||||
result[i] = line + result[i][len(line) :]
|
||||
else:
|
||||
result.append(line)
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
37
engine/effects/plugins/noise.py
Normal file
37
engine/effects/plugins/noise.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import random
|
||||
|
||||
from engine import config
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
|
||||
class NoiseEffect(EffectPlugin):
|
||||
name = "noise"
|
||||
config = EffectConfig(enabled=True, intensity=0.15)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not ctx.ticker_height:
|
||||
return buf
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
probability = intensity * 0.15
|
||||
|
||||
for r in range(len(result)):
|
||||
cy = ctx.scroll_cam + r
|
||||
if random.random() < probability:
|
||||
original_line = result[r]
|
||||
result[r] = self._generate_noise(len(original_line), cy)
|
||||
return result
|
||||
|
||||
def _generate_noise(self, w: int, cy: int) -> str:
|
||||
d = random.choice([0.15, 0.25, 0.35, 0.12])
|
||||
return "".join(
|
||||
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||
if random.random() < d
|
||||
else " "
|
||||
for _ in range(w)
|
||||
)
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
99
engine/effects/plugins/tint.py
Normal file
99
engine/effects/plugins/tint.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class TintEffect(EffectPlugin):
|
||||
"""Tint effect that applies an RGB color overlay to the buffer.
|
||||
|
||||
Uses ANSI escape codes to tint text with the specified RGB values.
|
||||
Supports transparency (0-100%) for blending.
|
||||
|
||||
Inlets:
|
||||
- r: Red component (0-255)
|
||||
- g: Green component (0-255)
|
||||
- b: Blue component (0-255)
|
||||
- a: Alpha/transparency (0.0-1.0, where 0.0 = fully transparent)
|
||||
"""
|
||||
|
||||
name = "tint"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
# Define inlet types for PureData-style typing
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
|
||||
# Get tint values from effect params or sensors
|
||||
r = self.config.params.get("r", 255)
|
||||
g = self.config.params.get("g", 255)
|
||||
b = self.config.params.get("b", 255)
|
||||
a = self.config.params.get("a", 0.3) # Default 30% tint
|
||||
|
||||
# Clamp values
|
||||
r = max(0, min(255, int(r)))
|
||||
g = max(0, min(255, int(g)))
|
||||
b = max(0, min(255, int(b)))
|
||||
a = max(0.0, min(1.0, float(a)))
|
||||
|
||||
if a <= 0:
|
||||
return buf
|
||||
|
||||
# Convert RGB to ANSI 256 color
|
||||
ansi_color = self._rgb_to_ansi256(r, g, b)
|
||||
|
||||
# Apply tint with transparency effect
|
||||
result = []
|
||||
for line in buf:
|
||||
if not line.strip():
|
||||
result.append(line)
|
||||
continue
|
||||
|
||||
# Check if line already has ANSI codes
|
||||
if "\033[" in line:
|
||||
# For lines with existing colors, wrap the whole line
|
||||
result.append(f"\033[38;5;{ansi_color}m{line}\033[0m")
|
||||
else:
|
||||
# Apply tint to plain text lines
|
||||
result.append(f"\033[38;5;{ansi_color}m{line}\033[0m")
|
||||
|
||||
return result
|
||||
|
||||
def _rgb_to_ansi256(self, r: int, g: int, b: int) -> int:
|
||||
"""Convert RGB (0-255 each) to ANSI 256 color code."""
|
||||
if r == g == b == 0:
|
||||
return 16
|
||||
if r == g == b == 255:
|
||||
return 231
|
||||
|
||||
# Calculate grayscale
|
||||
gray = int((0.299 * r + 0.587 * g + 0.114 * b) / 255 * 24) + 232
|
||||
|
||||
# Calculate color cube
|
||||
ri = int(r / 51)
|
||||
gi = int(g / 51)
|
||||
bi = int(b / 51)
|
||||
color = 16 + 36 * ri + 6 * gi + bi
|
||||
|
||||
# Use whichever is closer - gray or color
|
||||
gray_dist = abs(r - gray)
|
||||
color_dist = (
|
||||
(r - ri * 51) ** 2 + (g - gi * 51) ** 2 + (b - bi * 51) ** 2
|
||||
) ** 0.5
|
||||
|
||||
if gray_dist < color_dist:
|
||||
return gray
|
||||
return color
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
59
engine/effects/registry.py
Normal file
59
engine/effects/registry.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from engine.effects.types import EffectConfig, EffectPlugin
|
||||
|
||||
|
||||
class EffectRegistry:
|
||||
def __init__(self):
|
||||
self._plugins: dict[str, EffectPlugin] = {}
|
||||
self._discovered: bool = False
|
||||
|
||||
def register(self, plugin: EffectPlugin) -> None:
|
||||
self._plugins[plugin.name] = plugin
|
||||
|
||||
def get(self, name: str) -> EffectPlugin | None:
|
||||
return self._plugins.get(name)
|
||||
|
||||
def list_all(self) -> dict[str, EffectPlugin]:
|
||||
return self._plugins.copy()
|
||||
|
||||
def list_enabled(self) -> list[EffectPlugin]:
|
||||
return [p for p in self._plugins.values() if p.config.enabled]
|
||||
|
||||
def enable(self, name: str) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
if plugin:
|
||||
plugin.config.enabled = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable(self, name: str) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
if plugin:
|
||||
plugin.config.enabled = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def configure(self, name: str, config: EffectConfig) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
if plugin:
|
||||
plugin.configure(config)
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_enabled(self, name: str) -> bool:
|
||||
plugin = self._plugins.get(name)
|
||||
return plugin.config.enabled if plugin else False
|
||||
|
||||
|
||||
_registry: EffectRegistry | None = None
|
||||
|
||||
|
||||
def get_registry() -> EffectRegistry:
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = EffectRegistry()
|
||||
return _registry
|
||||
|
||||
|
||||
def set_registry(registry: EffectRegistry) -> None:
|
||||
global _registry
|
||||
_registry = registry
|
||||
250
engine/effects/types.py
Normal file
250
engine/effects/types.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
Visual effects type definitions and base classes.
|
||||
|
||||
EffectPlugin Architecture:
|
||||
- Uses ABC (Abstract Base Class) for interface enforcement
|
||||
- Runtime discovery via directory scanning (effects_plugins/)
|
||||
- Configuration via EffectConfig dataclass
|
||||
- Context passed through EffectContext dataclass
|
||||
|
||||
Plugin System Research (see AGENTS.md for references):
|
||||
- VST: Standardized audio interfaces, chaining, presets (FXP/FXB)
|
||||
- Python Entry Points: Namespace packages, importlib.metadata discovery
|
||||
- Shadertoy: Shader-based with uniforms as context
|
||||
|
||||
Current gaps vs industry patterns:
|
||||
- No preset save/load system
|
||||
- No external plugin distribution via entry points
|
||||
- No plugin metadata (version, author, description)
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class PartialUpdate:
|
||||
"""Represents a partial buffer update for optimized rendering.
|
||||
|
||||
Instead of processing the full buffer every frame, effects that support
|
||||
partial updates can process only changed regions.
|
||||
|
||||
Attributes:
|
||||
rows: Row indices that changed (None = all rows)
|
||||
cols: Column range that changed (None = full width)
|
||||
dirty: Set of dirty row indices
|
||||
"""
|
||||
|
||||
rows: tuple[int, int] | None = None # (start, end) inclusive
|
||||
cols: tuple[int, int] | None = None # (start, end) inclusive
|
||||
dirty: set[int] | None = None # Set of dirty row indices
|
||||
full_buffer: bool = True # If True, process entire buffer
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectContext:
|
||||
terminal_width: int
|
||||
terminal_height: int
|
||||
scroll_cam: int
|
||||
ticker_height: int
|
||||
camera_x: int = 0
|
||||
mic_excess: float = 0.0
|
||||
grad_offset: float = 0.0
|
||||
frame_number: int = 0
|
||||
has_message: bool = False
|
||||
items: list = field(default_factory=list)
|
||||
_state: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
|
||||
def get_sensor_value(self, sensor_name: str) -> float | None:
|
||||
"""Get a sensor value from context state.
|
||||
|
||||
Args:
|
||||
sensor_name: Name of the sensor (e.g., "mic", "camera")
|
||||
|
||||
Returns:
|
||||
Sensor value as float, or None if not available.
|
||||
"""
|
||||
return self._state.get(f"sensor.{sensor_name}")
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a state value in the context."""
|
||||
self._state[key] = value
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a state value from the context."""
|
||||
return self._state.get(key, default)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectConfig:
|
||||
enabled: bool = True
|
||||
intensity: float = 1.0
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class EffectPlugin(ABC):
|
||||
"""Abstract base class for effect plugins.
|
||||
|
||||
Subclasses must define:
|
||||
- name: str - unique identifier for the effect
|
||||
- config: EffectConfig - current configuration
|
||||
|
||||
Optional class attribute:
|
||||
- param_bindings: dict - Declarative sensor-to-param bindings
|
||||
Example:
|
||||
param_bindings = {
|
||||
"intensity": {"sensor": "mic", "transform": "linear"},
|
||||
"rate": {"sensor": "mic", "transform": "exponential"},
|
||||
}
|
||||
|
||||
And implement:
|
||||
- process(buf, ctx) -> list[str]
|
||||
- configure(config) -> None
|
||||
|
||||
Effect Behavior with ticker_height=0:
|
||||
- NoiseEffect: Returns buffer unchanged (no ticker to apply noise to)
|
||||
- FadeEffect: Returns buffer unchanged (no ticker to fade)
|
||||
- GlitchEffect: Processes normally (doesn't depend on ticker_height)
|
||||
- FirehoseEffect: Returns buffer unchanged if no items in context
|
||||
|
||||
Effects should handle missing or zero context values gracefully by
|
||||
returning the input buffer unchanged rather than raising errors.
|
||||
|
||||
The param_bindings system enables PureData-style signal routing:
|
||||
- Sensors emit values that effects can bind to
|
||||
- Transform functions map sensor values to param ranges
|
||||
- Effects read bound values from context.state["sensor.{name}"]
|
||||
"""
|
||||
|
||||
name: str
|
||||
config: EffectConfig
|
||||
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||
supports_partial_updates: bool = False # Override in subclasses for optimization
|
||||
|
||||
@abstractmethod
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
"""Process the buffer with this effect applied.
|
||||
|
||||
Args:
|
||||
buf: List of lines to process
|
||||
ctx: Effect context with terminal state
|
||||
|
||||
Returns:
|
||||
Processed buffer (may be same object or new list)
|
||||
"""
|
||||
...
|
||||
|
||||
def process_partial(
|
||||
self, buf: list[str], ctx: EffectContext, partial: PartialUpdate
|
||||
) -> list[str]:
|
||||
"""Process a partial buffer for optimized rendering.
|
||||
|
||||
Override this in subclasses that support partial updates for performance.
|
||||
Default implementation falls back to full buffer processing.
|
||||
|
||||
Args:
|
||||
buf: List of lines to process
|
||||
ctx: Effect context with terminal state
|
||||
partial: PartialUpdate indicating which regions changed
|
||||
|
||||
Returns:
|
||||
Processed buffer (may be same object or new list)
|
||||
"""
|
||||
# Default: fall back to full processing
|
||||
return self.process(buf, ctx)
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
"""Configure the effect with new settings.
|
||||
|
||||
Args:
|
||||
config: New configuration to apply
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def create_effect_context(
|
||||
terminal_width: int = 80,
|
||||
terminal_height: int = 24,
|
||||
scroll_cam: int = 0,
|
||||
ticker_height: int = 0,
|
||||
mic_excess: float = 0.0,
|
||||
grad_offset: float = 0.0,
|
||||
frame_number: int = 0,
|
||||
has_message: bool = False,
|
||||
items: list | None = None,
|
||||
) -> EffectContext:
|
||||
"""Factory function to create EffectContext with sensible defaults."""
|
||||
return EffectContext(
|
||||
terminal_width=terminal_width,
|
||||
terminal_height=terminal_height,
|
||||
scroll_cam=scroll_cam,
|
||||
ticker_height=ticker_height,
|
||||
mic_excess=mic_excess,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=has_message,
|
||||
items=items or [],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineConfig:
|
||||
order: list[str] = field(default_factory=list)
|
||||
effects: dict[str, EffectConfig] = field(default_factory=dict)
|
||||
|
||||
|
||||
def apply_param_bindings(
|
||||
effect: "EffectPlugin",
|
||||
ctx: EffectContext,
|
||||
) -> EffectConfig:
|
||||
"""Apply sensor bindings to effect config.
|
||||
|
||||
This resolves param_bindings declarations by reading sensor values
|
||||
from the context and applying transform functions.
|
||||
|
||||
Args:
|
||||
effect: The effect with param_bindings to apply
|
||||
ctx: EffectContext containing sensor values
|
||||
|
||||
Returns:
|
||||
Modified EffectConfig with sensor-driven values applied.
|
||||
"""
|
||||
import copy
|
||||
|
||||
if not effect.param_bindings:
|
||||
return effect.config
|
||||
|
||||
config = copy.deepcopy(effect.config)
|
||||
|
||||
for param_name, binding in effect.param_bindings.items():
|
||||
sensor_name: str = binding.get("sensor", "")
|
||||
transform: str = binding.get("transform", "linear")
|
||||
|
||||
if not sensor_name:
|
||||
continue
|
||||
|
||||
sensor_value = ctx.get_sensor_value(sensor_name)
|
||||
if sensor_value is None:
|
||||
continue
|
||||
|
||||
if transform == "linear":
|
||||
applied_value: float = sensor_value
|
||||
elif transform == "exponential":
|
||||
applied_value = sensor_value**2
|
||||
elif transform == "threshold":
|
||||
threshold = float(binding.get("threshold", 0.5))
|
||||
applied_value = 1.0 if sensor_value > threshold else 0.0
|
||||
elif transform == "inverse":
|
||||
applied_value = 1.0 - sensor_value
|
||||
else:
|
||||
applied_value = sensor_value
|
||||
|
||||
config.params[f"{param_name}_sensor"] = applied_value
|
||||
|
||||
if param_name == "intensity":
|
||||
base_intensity = effect.config.intensity
|
||||
config.intensity = base_intensity * (0.5 + applied_value * 0.5)
|
||||
|
||||
return config
|
||||
72
engine/eventbus.py
Normal file
72
engine/eventbus.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Event bus - pub/sub messaging for decoupled component communication.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from engine.events import EventType
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Thread-safe event bus for publish-subscribe messaging."""
|
||||
|
||||
def __init__(self):
|
||||
self._subscribers: dict[EventType, list[Callable[[Any], None]]] = defaultdict(
|
||||
list
|
||||
)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def subscribe(self, event_type: EventType, callback: Callable[[Any], None]) -> None:
|
||||
"""Register a callback for a specific event type."""
|
||||
with self._lock:
|
||||
self._subscribers[event_type].append(callback)
|
||||
|
||||
def unsubscribe(
|
||||
self, event_type: EventType, callback: Callable[[Any], None]
|
||||
) -> None:
|
||||
"""Remove a callback for a specific event type."""
|
||||
with self._lock:
|
||||
if callback in self._subscribers[event_type]:
|
||||
self._subscribers[event_type].remove(callback)
|
||||
|
||||
def publish(self, event_type: EventType, event: Any = None) -> None:
|
||||
"""Publish an event to all subscribers."""
|
||||
with self._lock:
|
||||
callbacks = list(self._subscribers.get(event_type, []))
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Remove all subscribers."""
|
||||
with self._lock:
|
||||
self._subscribers.clear()
|
||||
|
||||
def subscriber_count(self, event_type: EventType | None = None) -> int:
|
||||
"""Get subscriber count for an event type, or total if None."""
|
||||
with self._lock:
|
||||
if event_type is None:
|
||||
return sum(len(cb) for cb in self._subscribers.values())
|
||||
return len(self._subscribers.get(event_type, []))
|
||||
|
||||
|
||||
_event_bus: EventBus | None = None
|
||||
|
||||
|
||||
def get_event_bus() -> EventBus:
|
||||
"""Get the global event bus instance."""
|
||||
global _event_bus
|
||||
if _event_bus is None:
|
||||
_event_bus = EventBus()
|
||||
return _event_bus
|
||||
|
||||
|
||||
def set_event_bus(bus: EventBus) -> None:
|
||||
"""Set the global event bus instance (for testing)."""
|
||||
global _event_bus
|
||||
_event_bus = bus
|
||||
67
engine/events.py
Normal file
67
engine/events.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Event types for the mainline application.
|
||||
Defines the core events that flow through the system.
|
||||
These types support a future migration to an event-driven architecture.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
"""Core event types in the mainline application."""
|
||||
|
||||
NEW_HEADLINE = auto()
|
||||
FRAME_TICK = auto()
|
||||
MIC_LEVEL = auto()
|
||||
NTFY_MESSAGE = auto()
|
||||
STREAM_START = auto()
|
||||
STREAM_END = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeadlineEvent:
|
||||
"""Event emitted when a new headline is ready for display."""
|
||||
|
||||
title: str
|
||||
source: str
|
||||
timestamp: str
|
||||
language: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameTickEvent:
|
||||
"""Event emitted on each render frame."""
|
||||
|
||||
frame_number: int
|
||||
timestamp: datetime
|
||||
delta_seconds: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class MicLevelEvent:
|
||||
"""Event emitted when microphone level changes significantly."""
|
||||
|
||||
db_level: float
|
||||
excess_above_threshold: float
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class NtfyMessageEvent:
|
||||
"""Event emitted when an ntfy message is received."""
|
||||
|
||||
title: str
|
||||
body: str
|
||||
message_id: str | None = None
|
||||
timestamp: datetime | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamEvent:
|
||||
"""Event emitted when stream starts or ends."""
|
||||
|
||||
event_type: EventType
|
||||
headline_count: int = 0
|
||||
timestamp: datetime | None = None
|
||||
@@ -8,6 +8,7 @@ import pathlib
|
||||
import re
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import feedparser
|
||||
|
||||
@@ -16,9 +17,13 @@ from engine.filter import skip, strip_tags
|
||||
from engine.sources import FEEDS, POETRY_SOURCES
|
||||
from engine.terminal import boot_ln
|
||||
|
||||
# Type alias for headline items
|
||||
HeadlineTuple = tuple[str, str, str]
|
||||
|
||||
|
||||
# ─── SINGLE FEED ──────────────────────────────────────────
|
||||
def fetch_feed(url):
|
||||
def fetch_feed(url: str) -> Any | None:
|
||||
"""Fetch and parse a single RSS feed URL."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
||||
@@ -28,8 +33,9 @@ def fetch_feed(url):
|
||||
|
||||
|
||||
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
||||
def fetch_all():
|
||||
items = []
|
||||
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
||||
"""Fetch all RSS feeds and return items, linked count, failed count."""
|
||||
items: list[HeadlineTuple] = []
|
||||
linked = failed = 0
|
||||
for src, url in FEEDS.items():
|
||||
feed = fetch_feed(url)
|
||||
@@ -59,7 +65,7 @@ def fetch_all():
|
||||
|
||||
|
||||
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
||||
def _fetch_gutenberg(url, label):
|
||||
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
||||
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||
|
||||
57
engine/frame.py
Normal file
57
engine/frame.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Frame timing utilities — FPS control and precise timing.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class FrameTimer:
|
||||
"""Frame timer for consistent render loop timing."""
|
||||
|
||||
def __init__(self, target_frame_dt: float = 0.05):
|
||||
self.target_frame_dt = target_frame_dt
|
||||
self._frame_count = 0
|
||||
self._start_time = time.monotonic()
|
||||
self._last_frame_time = self._start_time
|
||||
|
||||
@property
|
||||
def fps(self) -> float:
|
||||
"""Current FPS based on elapsed frames."""
|
||||
elapsed = time.monotonic() - self._start_time
|
||||
if elapsed > 0:
|
||||
return self._frame_count / elapsed
|
||||
return 0.0
|
||||
|
||||
def sleep_until_next_frame(self) -> float:
|
||||
"""Sleep to maintain target frame rate. Returns actual elapsed time."""
|
||||
now = time.monotonic()
|
||||
elapsed = now - self._last_frame_time
|
||||
self._last_frame_time = now
|
||||
self._frame_count += 1
|
||||
|
||||
sleep_time = max(0, self.target_frame_dt - elapsed)
|
||||
if sleep_time > 0:
|
||||
time.sleep(sleep_time)
|
||||
return elapsed
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset frame counter and start time."""
|
||||
self._frame_count = 0
|
||||
self._start_time = time.monotonic()
|
||||
self._last_frame_time = self._start_time
|
||||
|
||||
|
||||
def calculate_scroll_step(
|
||||
scroll_dur: float, view_height: int, padding: int = 15
|
||||
) -> float:
|
||||
"""Calculate scroll step interval for smooth scrolling.
|
||||
|
||||
Args:
|
||||
scroll_dur: Duration in seconds for one headline to scroll through view
|
||||
view_height: Terminal height in rows
|
||||
padding: Extra rows for off-screen content
|
||||
|
||||
Returns:
|
||||
Time in seconds between scroll steps
|
||||
"""
|
||||
return scroll_dur / (view_height + padding) * 2
|
||||
73
engine/interfaces/__init__.py
Normal file
73
engine/interfaces/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Core interfaces for the mainline pipeline architecture.
|
||||
|
||||
This module provides all abstract base classes and protocols that define
|
||||
the contracts between pipeline components:
|
||||
|
||||
- Stage: Base class for pipeline components (imported from pipeline.core)
|
||||
- DataSource: Abstract data providers (imported from data_sources.sources)
|
||||
- EffectPlugin: Visual effects interface (imported from effects.types)
|
||||
- Sensor: Real-time input interface (imported from sensors)
|
||||
- Display: Output backend protocol (imported from display)
|
||||
|
||||
This module provides a centralized import location for all interfaces.
|
||||
"""
|
||||
|
||||
from engine.data_sources.sources import (
|
||||
DataSource,
|
||||
ImageItem,
|
||||
SourceItem,
|
||||
)
|
||||
from engine.display import Display
|
||||
from engine.effects.types import (
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectPlugin,
|
||||
PartialUpdate,
|
||||
PipelineConfig,
|
||||
apply_param_bindings,
|
||||
create_effect_context,
|
||||
)
|
||||
from engine.pipeline.core import (
|
||||
DataType,
|
||||
Stage,
|
||||
StageConfig,
|
||||
StageError,
|
||||
StageResult,
|
||||
create_stage_error,
|
||||
)
|
||||
from engine.sensors import (
|
||||
Sensor,
|
||||
SensorStage,
|
||||
SensorValue,
|
||||
create_sensor_stage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Stage interfaces
|
||||
"DataType",
|
||||
"Stage",
|
||||
"StageConfig",
|
||||
"StageError",
|
||||
"StageResult",
|
||||
"create_stage_error",
|
||||
# Data source interfaces
|
||||
"DataSource",
|
||||
"ImageItem",
|
||||
"SourceItem",
|
||||
# Effect interfaces
|
||||
"EffectConfig",
|
||||
"EffectContext",
|
||||
"EffectPlugin",
|
||||
"PartialUpdate",
|
||||
"PipelineConfig",
|
||||
"apply_param_bindings",
|
||||
"create_effect_context",
|
||||
# Sensor interfaces
|
||||
"Sensor",
|
||||
"SensorStage",
|
||||
"SensorValue",
|
||||
"create_sensor_stage",
|
||||
# Display protocol
|
||||
"Display",
|
||||
]
|
||||
@@ -1,66 +0,0 @@
|
||||
"""
|
||||
Microphone input monitor — standalone, no internal dependencies.
|
||||
Gracefully degrades if sounddevice/numpy are unavailable.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
|
||||
try:
|
||||
import numpy as _np
|
||||
import sounddevice as _sd
|
||||
|
||||
_HAS_MIC = True
|
||||
except Exception:
|
||||
_HAS_MIC = False
|
||||
|
||||
|
||||
class MicMonitor:
|
||||
"""Background mic stream that exposes current RMS dB level."""
|
||||
|
||||
def __init__(self, threshold_db=50):
|
||||
self.threshold_db = threshold_db
|
||||
self._db = -99.0
|
||||
self._stream = None
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""True if sounddevice is importable."""
|
||||
return _HAS_MIC
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
"""Current RMS dB level."""
|
||||
return self._db
|
||||
|
||||
@property
|
||||
def excess(self):
|
||||
"""dB above threshold (clamped to 0)."""
|
||||
return max(0.0, self._db - self.threshold_db)
|
||||
|
||||
def start(self):
|
||||
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
||||
if not _HAS_MIC:
|
||||
return None
|
||||
|
||||
def _cb(indata, frames, t, status):
|
||||
rms = float(_np.sqrt(_np.mean(indata**2)))
|
||||
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
||||
|
||||
try:
|
||||
self._stream = _sd.InputStream(
|
||||
callback=_cb, channels=1, samplerate=44100, blocksize=2048
|
||||
)
|
||||
self._stream.start()
|
||||
atexit.register(self.stop)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
"""Stop the mic stream if running."""
|
||||
if self._stream:
|
||||
try:
|
||||
self._stream.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._stream = None
|
||||
@@ -16,8 +16,12 @@ import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from engine.events import NtfyMessageEvent
|
||||
|
||||
|
||||
class NtfyPoller:
|
||||
"""SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT)."""
|
||||
@@ -28,6 +32,24 @@ class NtfyPoller:
|
||||
self.display_secs = display_secs
|
||||
self._message = None # (title, body, monotonic_timestamp) or None
|
||||
self._lock = threading.Lock()
|
||||
self._subscribers: list[Callable[[NtfyMessageEvent], None]] = []
|
||||
|
||||
def subscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None:
|
||||
"""Register a callback to be called when a message is received."""
|
||||
self._subscribers.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None:
|
||||
"""Remove a registered callback."""
|
||||
if callback in self._subscribers:
|
||||
self._subscribers.remove(callback)
|
||||
|
||||
def _emit(self, event: NtfyMessageEvent) -> None:
|
||||
"""Emit an event to all subscribers."""
|
||||
for cb in self._subscribers:
|
||||
try:
|
||||
cb(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
"""Start background stream thread. Returns True."""
|
||||
@@ -88,6 +110,13 @@ class NtfyPoller:
|
||||
data.get("message", ""),
|
||||
time.monotonic(),
|
||||
)
|
||||
event = NtfyMessageEvent(
|
||||
title=data.get("title", ""),
|
||||
body=data.get("message", ""),
|
||||
message_id=data.get("id"),
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
self._emit(event)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(self.reconnect_delay)
|
||||
|
||||
107
engine/pipeline/__init__.py
Normal file
107
engine/pipeline/__init__.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Unified Pipeline Architecture.
|
||||
|
||||
This module provides a clean, dependency-managed pipeline system:
|
||||
- Stage: Base class for all pipeline components
|
||||
- Pipeline: DAG-based execution orchestrator
|
||||
- PipelineParams: Runtime configuration for animation
|
||||
- PipelinePreset: Pre-configured pipeline configurations
|
||||
- StageRegistry: Unified registration for all stage types
|
||||
|
||||
The pipeline architecture supports:
|
||||
- Sources: Data providers (headlines, poetry, pipeline viz)
|
||||
- Effects: Post-processors (noise, fade, glitch, hud)
|
||||
- Displays: Output backends (terminal, pygame, websocket)
|
||||
- Cameras: Viewport controllers (vertical, horizontal, omni)
|
||||
|
||||
Example:
|
||||
from engine.pipeline import Pipeline, PipelineConfig, StageRegistry
|
||||
|
||||
pipeline = Pipeline(PipelineConfig(source="headlines", display="terminal"))
|
||||
pipeline.add_stage("source", StageRegistry.create("source", "headlines"))
|
||||
pipeline.add_stage("display", StageRegistry.create("display", "terminal"))
|
||||
pipeline.build().initialize()
|
||||
|
||||
result = pipeline.execute(initial_data)
|
||||
"""
|
||||
|
||||
from engine.pipeline.controller import (
|
||||
Pipeline,
|
||||
PipelineConfig,
|
||||
PipelineRunner,
|
||||
create_default_pipeline,
|
||||
create_pipeline_from_params,
|
||||
)
|
||||
from engine.pipeline.core import (
|
||||
PipelineContext,
|
||||
Stage,
|
||||
StageConfig,
|
||||
StageError,
|
||||
StageResult,
|
||||
)
|
||||
from engine.pipeline.params import (
|
||||
DEFAULT_HEADLINE_PARAMS,
|
||||
DEFAULT_PIPELINE_PARAMS,
|
||||
DEFAULT_PYGAME_PARAMS,
|
||||
PipelineParams,
|
||||
)
|
||||
from engine.pipeline.presets import (
|
||||
DEMO_PRESET,
|
||||
FIREHOSE_PRESET,
|
||||
PIPELINE_VIZ_PRESET,
|
||||
POETRY_PRESET,
|
||||
PRESETS,
|
||||
SIXEL_PRESET,
|
||||
WEBSOCKET_PRESET,
|
||||
PipelinePreset,
|
||||
create_preset_from_params,
|
||||
get_preset,
|
||||
list_presets,
|
||||
)
|
||||
from engine.pipeline.registry import (
|
||||
StageRegistry,
|
||||
discover_stages,
|
||||
register_camera,
|
||||
register_display,
|
||||
register_effect,
|
||||
register_source,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core
|
||||
"Stage",
|
||||
"StageConfig",
|
||||
"StageError",
|
||||
"StageResult",
|
||||
"PipelineContext",
|
||||
# Controller
|
||||
"Pipeline",
|
||||
"PipelineConfig",
|
||||
"PipelineRunner",
|
||||
"create_default_pipeline",
|
||||
"create_pipeline_from_params",
|
||||
# Params
|
||||
"PipelineParams",
|
||||
"DEFAULT_HEADLINE_PARAMS",
|
||||
"DEFAULT_PIPELINE_PARAMS",
|
||||
"DEFAULT_PYGAME_PARAMS",
|
||||
# Presets
|
||||
"PipelinePreset",
|
||||
"PRESETS",
|
||||
"DEMO_PRESET",
|
||||
"POETRY_PRESET",
|
||||
"PIPELINE_VIZ_PRESET",
|
||||
"WEBSOCKET_PRESET",
|
||||
"SIXEL_PRESET",
|
||||
"FIREHOSE_PRESET",
|
||||
"get_preset",
|
||||
"list_presets",
|
||||
"create_preset_from_params",
|
||||
# Registry
|
||||
"StageRegistry",
|
||||
"discover_stages",
|
||||
"register_source",
|
||||
"register_effect",
|
||||
"register_display",
|
||||
"register_camera",
|
||||
]
|
||||
845
engine/pipeline/adapters.py
Normal file
845
engine/pipeline/adapters.py
Normal file
@@ -0,0 +1,845 @@
|
||||
"""
|
||||
Stage adapters - Bridge existing components to the Stage interface.
|
||||
|
||||
This module provides adapters that wrap existing components
|
||||
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.pipeline.core import PipelineContext, Stage
|
||||
|
||||
|
||||
class EffectPluginStage(Stage):
|
||||
"""Adapter wrapping EffectPlugin as a Stage."""
|
||||
|
||||
def __init__(self, effect_plugin, name: str = "effect"):
|
||||
self._effect = effect_plugin
|
||||
self.name = name
|
||||
self.category = "effect"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
"""Return stage_type based on effect name.
|
||||
|
||||
HUD effects are overlays.
|
||||
"""
|
||||
if self.name == "hud":
|
||||
return "overlay"
|
||||
return self.category
|
||||
|
||||
@property
|
||||
def render_order(self) -> int:
|
||||
"""Return render_order based on effect type.
|
||||
|
||||
HUD effects have high render_order to appear on top.
|
||||
"""
|
||||
if self.name == "hud":
|
||||
return 100 # High order for overlays
|
||||
return 0
|
||||
|
||||
@property
|
||||
def is_overlay(self) -> bool:
|
||||
"""Return True for HUD effects.
|
||||
|
||||
HUD is an overlay - it composes on top of the buffer
|
||||
rather than transforming it for the next stage.
|
||||
"""
|
||||
return self.name == "hud"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"effect.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Process data through the effect."""
|
||||
if data is None:
|
||||
return None
|
||||
from engine.effects.types import EffectContext, apply_param_bindings
|
||||
|
||||
w = ctx.params.viewport_width if ctx.params else 80
|
||||
h = ctx.params.viewport_height if ctx.params else 24
|
||||
frame = ctx.params.frame_number if ctx.params else 0
|
||||
|
||||
effect_ctx = EffectContext(
|
||||
terminal_width=w,
|
||||
terminal_height=h,
|
||||
scroll_cam=0,
|
||||
ticker_height=h,
|
||||
camera_x=0,
|
||||
mic_excess=0.0,
|
||||
grad_offset=(frame * 0.01) % 1.0,
|
||||
frame_number=frame,
|
||||
has_message=False,
|
||||
items=ctx.get("items", []),
|
||||
)
|
||||
|
||||
# Copy sensor state from PipelineContext to EffectContext
|
||||
for key, value in ctx.state.items():
|
||||
if key.startswith("sensor."):
|
||||
effect_ctx.set_state(key, value)
|
||||
|
||||
# Copy metrics from PipelineContext to EffectContext
|
||||
if "metrics" in ctx.state:
|
||||
effect_ctx.set_state("metrics", ctx.state["metrics"])
|
||||
|
||||
# Apply sensor param bindings if effect has them
|
||||
if hasattr(self._effect, "param_bindings") and self._effect.param_bindings:
|
||||
bound_config = apply_param_bindings(self._effect, effect_ctx)
|
||||
self._effect.configure(bound_config)
|
||||
|
||||
return self._effect.process(data, effect_ctx)
|
||||
|
||||
|
||||
class DisplayStage(Stage):
|
||||
"""Adapter wrapping Display as a Stage."""
|
||||
|
||||
def __init__(self, display, name: str = "terminal"):
|
||||
self._display = display
|
||||
self.name = name
|
||||
self.category = "display"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"display.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"render.output"} # Display needs rendered content
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER} # Display consumes rendered text
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.NONE} # Display is a terminal stage (no output)
|
||||
|
||||
def init(self, ctx: PipelineContext) -> bool:
|
||||
w = ctx.params.viewport_width if ctx.params else 80
|
||||
h = ctx.params.viewport_height if ctx.params else 24
|
||||
result = self._display.init(w, h, reuse=False)
|
||||
return result is not False
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Output data to display."""
|
||||
if data is not None:
|
||||
self._display.show(data)
|
||||
return data
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self._display.cleanup()
|
||||
|
||||
|
||||
class DataSourceStage(Stage):
|
||||
"""Adapter wrapping DataSource as a Stage."""
|
||||
|
||||
def __init__(self, data_source, name: str = "headlines"):
|
||||
self._source = data_source
|
||||
self.name = name
|
||||
self.category = "source"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"source.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.NONE} # Sources don't take input
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Fetch data from source."""
|
||||
if hasattr(self._source, "get_items"):
|
||||
return self._source.get_items()
|
||||
return data
|
||||
|
||||
|
||||
class PassthroughStage(Stage):
|
||||
"""Simple stage that passes data through unchanged.
|
||||
|
||||
Used for sources that already provide the data in the correct format
|
||||
(e.g., pipeline introspection that outputs text directly).
|
||||
"""
|
||||
|
||||
def __init__(self, name: str = "passthrough"):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "render"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Pass data through unchanged."""
|
||||
return data
|
||||
|
||||
|
||||
class SourceItemsToBufferStage(Stage):
|
||||
"""Convert SourceItem objects to text buffer.
|
||||
|
||||
Takes a list of SourceItem objects and extracts their content,
|
||||
splitting on newlines to create a proper text buffer for display.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str = "items-to-buffer"):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "render"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Convert SourceItem list to text buffer."""
|
||||
if data is None:
|
||||
return []
|
||||
|
||||
# If already a list of strings, return as-is
|
||||
if isinstance(data, list) and data and isinstance(data[0], str):
|
||||
return data
|
||||
|
||||
# If it's a list of SourceItem, extract content
|
||||
from engine.data_sources import SourceItem
|
||||
|
||||
if isinstance(data, list):
|
||||
result = []
|
||||
for item in data:
|
||||
if isinstance(item, SourceItem):
|
||||
# Split content by newline to get individual lines
|
||||
lines = item.content.split("\n")
|
||||
result.extend(lines)
|
||||
elif hasattr(item, "content"): # Has content attribute
|
||||
lines = str(item.content).split("\n")
|
||||
result.extend(lines)
|
||||
else:
|
||||
result.append(str(item))
|
||||
return result
|
||||
|
||||
# Single item
|
||||
if isinstance(data, SourceItem):
|
||||
return data.content.split("\n")
|
||||
|
||||
return [str(data)]
|
||||
|
||||
|
||||
class CameraStage(Stage):
|
||||
"""Adapter wrapping Camera as a Stage."""
|
||||
|
||||
def __init__(self, camera, name: str = "vertical"):
|
||||
self._camera = camera
|
||||
self.name = name
|
||||
self.category = "camera"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"camera"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"render.output"} # Depend on rendered output from font or render stage
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER} # Camera works on rendered text
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Apply camera transformation to data."""
|
||||
if data is None or (isinstance(data, list) and len(data) == 0):
|
||||
return data
|
||||
if hasattr(self._camera, "apply"):
|
||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||
buffer_height = len(data) if isinstance(data, list) else 0
|
||||
|
||||
# Get global layout height for canvas (enables full scrolling range)
|
||||
total_layout_height = ctx.get("total_layout_height", buffer_height)
|
||||
|
||||
# Preserve camera's configured canvas width, but ensure it's at least viewport_width
|
||||
# This allows horizontal/omni/floating/bounce cameras to scroll properly
|
||||
canvas_width = max(
|
||||
viewport_width, getattr(self._camera, "canvas_width", viewport_width)
|
||||
)
|
||||
|
||||
# Update camera's viewport dimensions so it knows its actual bounds
|
||||
# Set canvas size to achieve desired viewport (viewport = canvas / zoom)
|
||||
if hasattr(self._camera, "set_canvas_size"):
|
||||
self._camera.set_canvas_size(
|
||||
width=int(viewport_width * self._camera.zoom),
|
||||
height=int(viewport_height * self._camera.zoom),
|
||||
)
|
||||
|
||||
# Set canvas to full layout height so camera can scroll through all content
|
||||
self._camera.set_canvas_size(width=canvas_width, height=total_layout_height)
|
||||
|
||||
# Update camera position (scroll) - uses global canvas for clamping
|
||||
if hasattr(self._camera, "update"):
|
||||
self._camera.update(1 / 60)
|
||||
|
||||
# Store camera_y in context for ViewportFilterStage (global y position)
|
||||
ctx.set("camera_y", self._camera.y)
|
||||
|
||||
# Apply camera viewport slicing to the partial buffer
|
||||
# The buffer starts at render_offset_y in global coordinates
|
||||
render_offset_y = ctx.get("render_offset_y", 0)
|
||||
|
||||
# Temporarily shift camera to local buffer coordinates for apply()
|
||||
real_y = self._camera.y
|
||||
local_y = max(0, real_y - render_offset_y)
|
||||
|
||||
# Temporarily shrink canvas to local buffer size so apply() works correctly
|
||||
self._camera.set_canvas_size(width=canvas_width, height=buffer_height)
|
||||
self._camera.y = local_y
|
||||
|
||||
# Apply slicing
|
||||
result = self._camera.apply(data, viewport_width, viewport_height)
|
||||
|
||||
# Restore global canvas and camera position for next frame
|
||||
self._camera.set_canvas_size(width=canvas_width, height=total_layout_height)
|
||||
self._camera.y = real_y
|
||||
|
||||
return result
|
||||
return data
|
||||
|
||||
def cleanup(self) -> None:
|
||||
if hasattr(self._camera, "reset"):
|
||||
self._camera.reset()
|
||||
|
||||
|
||||
class ViewportFilterStage(Stage):
|
||||
"""Stage that limits items based on layout calculation.
|
||||
|
||||
Computes cumulative y-offsets for all items using cheap height estimation,
|
||||
then returns only items that overlap the camera's viewport window.
|
||||
This prevents FontStage from rendering thousands of items when only a few
|
||||
are visible, while still allowing camera scrolling through all content.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str = "viewport-filter"):
|
||||
self.name = name
|
||||
self.category = "filter"
|
||||
self.optional = False
|
||||
self._cached_count = 0
|
||||
self._layout: list[tuple[int, int]] = []
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "filter"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"filter.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Filter items based on layout and camera position."""
|
||||
if data is None or not isinstance(data, list):
|
||||
return data
|
||||
|
||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||
camera_y = ctx.get("camera_y", 0)
|
||||
|
||||
# Recompute layout when item count OR viewport width changes
|
||||
cached_width = getattr(self, "_cached_width", None)
|
||||
if len(data) != self._cached_count or cached_width != viewport_width:
|
||||
self._layout = []
|
||||
y = 0
|
||||
from engine.render.blocks import estimate_block_height
|
||||
|
||||
for item in data:
|
||||
if hasattr(item, "content"):
|
||||
title = item.content
|
||||
elif isinstance(item, tuple):
|
||||
title = str(item[0]) if item else ""
|
||||
else:
|
||||
title = str(item)
|
||||
h = estimate_block_height(title, viewport_width)
|
||||
self._layout.append((y, h))
|
||||
y += h
|
||||
self._cached_count = len(data)
|
||||
self._cached_width = viewport_width
|
||||
|
||||
# Find items visible in [camera_y - buffer, camera_y + viewport_height + buffer]
|
||||
buffer_zone = viewport_height
|
||||
vis_start = max(0, camera_y - buffer_zone)
|
||||
vis_end = camera_y + viewport_height + buffer_zone
|
||||
|
||||
visible_items = []
|
||||
render_offset_y = 0
|
||||
first_visible_found = False
|
||||
for i, (start_y, height) in enumerate(self._layout):
|
||||
item_end = start_y + height
|
||||
if item_end > vis_start and start_y < vis_end:
|
||||
if not first_visible_found:
|
||||
render_offset_y = start_y
|
||||
first_visible_found = True
|
||||
visible_items.append(data[i])
|
||||
|
||||
# Compute total layout height for the canvas
|
||||
total_layout_height = 0
|
||||
if self._layout:
|
||||
last_start, last_height = self._layout[-1]
|
||||
total_layout_height = last_start + last_height
|
||||
|
||||
# Store metadata for CameraStage
|
||||
ctx.set("render_offset_y", render_offset_y)
|
||||
ctx.set("total_layout_height", total_layout_height)
|
||||
|
||||
# Always return at least one item to avoid empty buffer errors
|
||||
return visible_items if visible_items else data[:1]
|
||||
|
||||
|
||||
class FontStage(Stage):
|
||||
"""Stage that applies font rendering to content.
|
||||
|
||||
FontStage is a Transform that takes raw content (text, headlines)
|
||||
and renders it to an ANSI-formatted buffer using the configured font.
|
||||
|
||||
This decouples font rendering from data sources, allowing:
|
||||
- Different fonts per source
|
||||
- Runtime font swapping
|
||||
- Font as a pipeline stage
|
||||
|
||||
Attributes:
|
||||
font_path: Path to font file (None = use config default)
|
||||
font_size: Font size in points (None = use config default)
|
||||
font_ref: Reference name for registered font ("default", "cjk", etc.)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
font_path: str | None = None,
|
||||
font_size: int | None = None,
|
||||
font_ref: str | None = "default",
|
||||
name: str = "font",
|
||||
):
|
||||
self.name = name
|
||||
self.category = "transform"
|
||||
self.optional = False
|
||||
self._font_path = font_path
|
||||
self._font_size = font_size
|
||||
self._font_ref = font_ref
|
||||
self._font = None
|
||||
self._render_cache: dict[tuple[str, str, str, int], list[str]] = {}
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "transform"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"transform.{self.name}", "render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def init(self, ctx: PipelineContext) -> bool:
|
||||
"""Initialize font from config or path."""
|
||||
from engine import config
|
||||
|
||||
if self._font_path:
|
||||
try:
|
||||
from PIL import ImageFont
|
||||
|
||||
size = self._font_size or config.FONT_SZ
|
||||
self._font = ImageFont.truetype(self._font_path, size)
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Render content with font to buffer."""
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
from engine.render import make_block
|
||||
|
||||
w = ctx.params.viewport_width if ctx.params else 80
|
||||
|
||||
# If data is already a list of strings (buffer), return as-is
|
||||
if isinstance(data, list) and data and isinstance(data[0], str):
|
||||
return data
|
||||
|
||||
# If data is a list of items, render each with font
|
||||
if isinstance(data, list):
|
||||
result = []
|
||||
for item in data:
|
||||
# Handle SourceItem or tuple (title, source, timestamp)
|
||||
if hasattr(item, "content"):
|
||||
title = item.content
|
||||
src = getattr(item, "source", "unknown")
|
||||
ts = getattr(item, "timestamp", "0")
|
||||
elif isinstance(item, tuple):
|
||||
title = item[0] if len(item) > 0 else ""
|
||||
src = item[1] if len(item) > 1 else "unknown"
|
||||
ts = str(item[2]) if len(item) > 2 else "0"
|
||||
else:
|
||||
title = str(item)
|
||||
src = "unknown"
|
||||
ts = "0"
|
||||
|
||||
# Check cache first
|
||||
cache_key = (title, src, ts, w)
|
||||
if cache_key in self._render_cache:
|
||||
result.extend(self._render_cache[cache_key])
|
||||
continue
|
||||
|
||||
try:
|
||||
block_lines, color_code, meta_idx = make_block(title, src, ts, w)
|
||||
self._render_cache[cache_key] = block_lines
|
||||
result.extend(block_lines)
|
||||
except Exception:
|
||||
result.append(title)
|
||||
|
||||
return result
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ImageToTextStage(Stage):
|
||||
"""Transform that converts PIL Image to ASCII text buffer.
|
||||
|
||||
Takes an ImageItem or PIL Image and converts it to a text buffer
|
||||
using ASCII character density mapping. The output can be displayed
|
||||
directly or further processed by effects.
|
||||
|
||||
Attributes:
|
||||
width: Output width in characters
|
||||
height: Output height in characters
|
||||
charset: Character set for density mapping (default: simple ASCII)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int = 80,
|
||||
height: int = 24,
|
||||
charset: str = " .:-=+*#%@",
|
||||
name: str = "image-to-text",
|
||||
):
|
||||
self.name = name
|
||||
self.category = "transform"
|
||||
self.optional = False
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.charset = charset
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "transform"
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.PIL_IMAGE} # Accepts PIL Image objects or ImageItem
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"transform.{self.name}", "render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Convert PIL Image to text buffer."""
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
from engine.data_sources.sources import ImageItem
|
||||
|
||||
# Extract PIL Image from various input types
|
||||
pil_image = None
|
||||
|
||||
if isinstance(data, ImageItem) or hasattr(data, "image"):
|
||||
pil_image = data.image
|
||||
else:
|
||||
# Assume it's already a PIL Image
|
||||
pil_image = data
|
||||
|
||||
# Check if it's a PIL Image
|
||||
if not hasattr(pil_image, "resize"):
|
||||
# Not a PIL Image, return as-is
|
||||
return data if isinstance(data, list) else [str(data)]
|
||||
|
||||
# Convert to grayscale and resize
|
||||
try:
|
||||
if pil_image.mode != "L":
|
||||
pil_image = pil_image.convert("L")
|
||||
except Exception:
|
||||
return ["[image conversion error]"]
|
||||
|
||||
# Calculate cell aspect ratio correction (characters are taller than wide)
|
||||
aspect_ratio = 0.5
|
||||
target_w = self.width
|
||||
target_h = int(self.height * aspect_ratio)
|
||||
|
||||
# Resize image to target dimensions
|
||||
try:
|
||||
resized = pil_image.resize((target_w, target_h))
|
||||
except Exception:
|
||||
return ["[image resize error]"]
|
||||
|
||||
# Map pixels to characters
|
||||
result = []
|
||||
pixels = list(resized.getdata())
|
||||
|
||||
for row in range(target_h):
|
||||
line = ""
|
||||
for col in range(target_w):
|
||||
idx = row * target_w + col
|
||||
if idx < len(pixels):
|
||||
brightness = pixels[idx]
|
||||
char_idx = int((brightness / 255) * (len(self.charset) - 1))
|
||||
line += self.charset[char_idx]
|
||||
else:
|
||||
line += " "
|
||||
result.append(line)
|
||||
|
||||
# Pad or trim to exact height
|
||||
while len(result) < self.height:
|
||||
result.append(" " * self.width)
|
||||
result = result[: self.height]
|
||||
|
||||
# Pad lines to width
|
||||
result = [line.ljust(self.width) for line in result]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
|
||||
"""Create a Stage from a Display instance."""
|
||||
return DisplayStage(display, name)
|
||||
|
||||
|
||||
def create_stage_from_effect(effect_plugin, name: str) -> EffectPluginStage:
|
||||
"""Create a Stage from an EffectPlugin."""
|
||||
return EffectPluginStage(effect_plugin, name)
|
||||
|
||||
|
||||
def create_stage_from_source(data_source, name: str = "headlines") -> DataSourceStage:
|
||||
"""Create a Stage from a DataSource."""
|
||||
return DataSourceStage(data_source, name)
|
||||
|
||||
|
||||
def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
|
||||
"""Create a Stage from a Camera."""
|
||||
return CameraStage(camera, name)
|
||||
|
||||
|
||||
def create_stage_from_font(
|
||||
font_path: str | None = None,
|
||||
font_size: int | None = None,
|
||||
font_ref: str | None = "default",
|
||||
name: str = "font",
|
||||
) -> FontStage:
|
||||
"""Create a FontStage for rendering content with fonts."""
|
||||
return FontStage(
|
||||
font_path=font_path, font_size=font_size, font_ref=font_ref, name=name
|
||||
)
|
||||
|
||||
|
||||
class CanvasStage(Stage):
|
||||
"""Stage that manages a Canvas for rendering.
|
||||
|
||||
CanvasStage creates and manages a 2D canvas that can hold rendered content.
|
||||
Other stages can write to and read from the canvas via the pipeline context.
|
||||
|
||||
This enables:
|
||||
- Pre-rendering content off-screen
|
||||
- Multiple cameras viewing different regions
|
||||
- Smooth scrolling (camera moves, content stays)
|
||||
- Layer compositing
|
||||
|
||||
Usage:
|
||||
- Add CanvasStage to pipeline
|
||||
- Other stages access canvas via: ctx.get("canvas")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int = 80,
|
||||
height: int = 24,
|
||||
name: str = "canvas",
|
||||
):
|
||||
self.name = name
|
||||
self.category = "system"
|
||||
self.optional = True
|
||||
self._width = width
|
||||
self._height = height
|
||||
self._canvas = None
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "system"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"canvas"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.ANY}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.ANY}
|
||||
|
||||
def init(self, ctx: PipelineContext) -> bool:
|
||||
from engine.canvas import Canvas
|
||||
|
||||
self._canvas = Canvas(width=self._width, height=self._height)
|
||||
ctx.set("canvas", self._canvas)
|
||||
return True
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Pass through data but ensure canvas is in context."""
|
||||
if self._canvas is None:
|
||||
from engine.canvas import Canvas
|
||||
|
||||
self._canvas = Canvas(width=self._width, height=self._height)
|
||||
ctx.set("canvas", self._canvas)
|
||||
|
||||
# Get dirty regions from canvas and expose via context
|
||||
# Effects can access via ctx.get_state("canvas.dirty_rows")
|
||||
if self._canvas.is_dirty():
|
||||
dirty_rows = self._canvas.get_dirty_rows()
|
||||
ctx.set_state("canvas.dirty_rows", dirty_rows)
|
||||
ctx.set_state("canvas.dirty_regions", self._canvas.get_dirty_regions())
|
||||
|
||||
return data
|
||||
|
||||
def get_canvas(self):
|
||||
"""Get the canvas instance."""
|
||||
return self._canvas
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self._canvas = None
|
||||
576
engine/pipeline/controller.py
Normal file
576
engine/pipeline/controller.py
Normal file
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
Pipeline controller - DAG-based pipeline execution.
|
||||
|
||||
The Pipeline class orchestrates stages in dependency order, handling
|
||||
the complete render cycle from source to display.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from engine.pipeline.core import PipelineContext, Stage, StageError, StageResult
|
||||
from engine.pipeline.params import PipelineParams
|
||||
from engine.pipeline.registry import StageRegistry
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineConfig:
|
||||
"""Configuration for a pipeline instance."""
|
||||
|
||||
source: str = "headlines"
|
||||
display: str = "terminal"
|
||||
camera: str = "vertical"
|
||||
effects: list[str] = field(default_factory=list)
|
||||
enable_metrics: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageMetrics:
|
||||
"""Metrics for a single stage execution."""
|
||||
|
||||
name: str
|
||||
duration_ms: float
|
||||
chars_in: int = 0
|
||||
chars_out: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameMetrics:
|
||||
"""Metrics for a single frame through the pipeline."""
|
||||
|
||||
frame_number: int
|
||||
total_ms: float
|
||||
stages: list[StageMetrics] = field(default_factory=list)
|
||||
|
||||
|
||||
class Pipeline:
|
||||
"""Main pipeline orchestrator.
|
||||
|
||||
Manages the execution of all stages in dependency order,
|
||||
handling initialization, processing, and cleanup.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: PipelineConfig | None = None,
|
||||
context: PipelineContext | None = None,
|
||||
):
|
||||
self.config = config or PipelineConfig()
|
||||
self.context = context or PipelineContext()
|
||||
self._stages: dict[str, Stage] = {}
|
||||
self._execution_order: list[str] = []
|
||||
self._initialized = False
|
||||
|
||||
self._metrics_enabled = self.config.enable_metrics
|
||||
self._frame_metrics: list[FrameMetrics] = []
|
||||
self._max_metrics_frames = 60
|
||||
self._current_frame_number = 0
|
||||
|
||||
def add_stage(self, name: str, stage: Stage) -> "Pipeline":
|
||||
"""Add a stage to the pipeline."""
|
||||
self._stages[name] = stage
|
||||
return self
|
||||
|
||||
def remove_stage(self, name: str) -> None:
|
||||
"""Remove a stage from the pipeline."""
|
||||
if name in self._stages:
|
||||
del self._stages[name]
|
||||
|
||||
def get_stage(self, name: str) -> Stage | None:
|
||||
"""Get a stage by name."""
|
||||
return self._stages.get(name)
|
||||
|
||||
def build(self) -> "Pipeline":
|
||||
"""Build execution order based on dependencies."""
|
||||
self._capability_map = self._build_capability_map()
|
||||
self._execution_order = self._resolve_dependencies()
|
||||
self._validate_dependencies()
|
||||
self._validate_types()
|
||||
self._initialized = True
|
||||
return self
|
||||
|
||||
def _build_capability_map(self) -> dict[str, list[str]]:
|
||||
"""Build a map of capabilities to stage names.
|
||||
|
||||
Returns:
|
||||
Dict mapping capability -> list of stage names that provide it
|
||||
"""
|
||||
capability_map: dict[str, list[str]] = {}
|
||||
for name, stage in self._stages.items():
|
||||
for cap in stage.capabilities:
|
||||
if cap not in capability_map:
|
||||
capability_map[cap] = []
|
||||
capability_map[cap].append(name)
|
||||
return capability_map
|
||||
|
||||
def _find_stage_with_capability(self, capability: str) -> str | None:
|
||||
"""Find a stage that provides the given capability.
|
||||
|
||||
Supports wildcard matching:
|
||||
- "source" matches "source.headlines" (prefix match)
|
||||
- "source.*" matches "source.headlines"
|
||||
- "source.headlines" matches exactly
|
||||
|
||||
Args:
|
||||
capability: The capability to find
|
||||
|
||||
Returns:
|
||||
Stage name that provides the capability, or None if not found
|
||||
"""
|
||||
# Exact match
|
||||
if capability in self._capability_map:
|
||||
return self._capability_map[capability][0]
|
||||
|
||||
# Prefix match (e.g., "source" -> "source.headlines")
|
||||
for cap, stages in self._capability_map.items():
|
||||
if cap.startswith(capability + "."):
|
||||
return stages[0]
|
||||
|
||||
# Wildcard match (e.g., "source.*" -> "source.headlines")
|
||||
if ".*" in capability:
|
||||
prefix = capability[:-2] # Remove ".*"
|
||||
for cap in self._capability_map:
|
||||
if cap.startswith(prefix + "."):
|
||||
return self._capability_map[cap][0]
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_dependencies(self) -> list[str]:
|
||||
"""Resolve stage execution order using topological sort with capability matching."""
|
||||
ordered = []
|
||||
visited = set()
|
||||
temp_mark = set()
|
||||
|
||||
def visit(name: str) -> None:
|
||||
if name in temp_mark:
|
||||
raise StageError(name, "Circular dependency detected")
|
||||
if name in visited:
|
||||
return
|
||||
|
||||
temp_mark.add(name)
|
||||
stage = self._stages.get(name)
|
||||
if stage:
|
||||
for dep in stage.dependencies:
|
||||
# Find a stage that provides this capability
|
||||
dep_stage_name = self._find_stage_with_capability(dep)
|
||||
if dep_stage_name:
|
||||
visit(dep_stage_name)
|
||||
|
||||
temp_mark.remove(name)
|
||||
visited.add(name)
|
||||
ordered.append(name)
|
||||
|
||||
for name in self._stages:
|
||||
if name not in visited:
|
||||
visit(name)
|
||||
|
||||
return ordered
|
||||
|
||||
def _validate_dependencies(self) -> None:
|
||||
"""Validate that all dependencies can be satisfied.
|
||||
|
||||
Raises StageError if any dependency cannot be resolved.
|
||||
"""
|
||||
missing: list[tuple[str, str]] = [] # (stage_name, capability)
|
||||
|
||||
for name, stage in self._stages.items():
|
||||
for dep in stage.dependencies:
|
||||
if not self._find_stage_with_capability(dep):
|
||||
missing.append((name, dep))
|
||||
|
||||
if missing:
|
||||
msgs = [f" - {stage} needs {cap}" for stage, cap in missing]
|
||||
raise StageError(
|
||||
"validation",
|
||||
"Missing capabilities:\n" + "\n".join(msgs),
|
||||
)
|
||||
|
||||
def _validate_types(self) -> None:
|
||||
"""Validate inlet/outlet types between connected stages.
|
||||
|
||||
PureData-style type validation. Each stage declares its inlet_types
|
||||
(what it accepts) and outlet_types (what it produces). This method
|
||||
validates that connected stages have compatible types.
|
||||
|
||||
Raises StageError if type mismatch is detected.
|
||||
"""
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
for i, name in enumerate(self._execution_order):
|
||||
stage = self._stages.get(name)
|
||||
if not stage:
|
||||
continue
|
||||
|
||||
inlet_types = stage.inlet_types
|
||||
|
||||
# Check against previous stage's outlet types
|
||||
if i > 0:
|
||||
prev_name = self._execution_order[i - 1]
|
||||
prev_stage = self._stages.get(prev_name)
|
||||
if prev_stage:
|
||||
prev_outlets = prev_stage.outlet_types
|
||||
|
||||
# Check if any outlet type is accepted by this inlet
|
||||
compatible = (
|
||||
DataType.ANY in inlet_types
|
||||
or DataType.ANY in prev_outlets
|
||||
or bool(prev_outlets & inlet_types)
|
||||
)
|
||||
|
||||
if not compatible:
|
||||
errors.append(
|
||||
f" - {name} (inlet: {inlet_types}) "
|
||||
f"← {prev_name} (outlet: {prev_outlets})"
|
||||
)
|
||||
|
||||
# Check display/sink stages (should accept TEXT_BUFFER)
|
||||
if (
|
||||
stage.category == "display"
|
||||
and DataType.TEXT_BUFFER not in inlet_types
|
||||
and DataType.ANY not in inlet_types
|
||||
):
|
||||
errors.append(f" - {name} is display but doesn't accept TEXT_BUFFER")
|
||||
|
||||
if errors:
|
||||
raise StageError(
|
||||
"type_validation",
|
||||
"Type mismatch in pipeline connections:\n" + "\n".join(errors),
|
||||
)
|
||||
|
||||
def initialize(self) -> bool:
|
||||
"""Initialize all stages in execution order."""
|
||||
for name in self._execution_order:
|
||||
stage = self._stages.get(name)
|
||||
if stage and not stage.init(self.context) and not stage.optional:
|
||||
return False
|
||||
return True
|
||||
|
||||
def execute(self, data: Any | None = None) -> StageResult:
|
||||
"""Execute the pipeline with the given input data.
|
||||
|
||||
Pipeline execution:
|
||||
1. Execute all non-overlay stages in dependency order
|
||||
2. Apply overlay stages on top (sorted by render_order)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
debug = os.environ.get("MAINLINE_DEBUG_DATAFLOW") == "1"
|
||||
|
||||
if debug:
|
||||
print(
|
||||
f"[PIPELINE.execute] Starting with data type: {type(data).__name__ if data else 'None'}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not self._initialized:
|
||||
self.build()
|
||||
|
||||
if not self._initialized:
|
||||
return StageResult(
|
||||
success=False,
|
||||
data=None,
|
||||
error="Pipeline not initialized",
|
||||
)
|
||||
|
||||
current_data = data
|
||||
frame_start = time.perf_counter() if self._metrics_enabled else 0
|
||||
stage_timings: list[StageMetrics] = []
|
||||
|
||||
# Separate overlay stages from regular stages
|
||||
overlay_stages: list[tuple[int, Stage]] = []
|
||||
regular_stages: list[str] = []
|
||||
|
||||
for name in self._execution_order:
|
||||
stage = self._stages.get(name)
|
||||
if not stage or not stage.is_enabled():
|
||||
continue
|
||||
|
||||
# Safely check is_overlay - handle MagicMock and other non-bool returns
|
||||
try:
|
||||
is_overlay = bool(getattr(stage, "is_overlay", False))
|
||||
except Exception:
|
||||
is_overlay = False
|
||||
|
||||
if is_overlay:
|
||||
# Safely get render_order
|
||||
try:
|
||||
render_order = int(getattr(stage, "render_order", 0))
|
||||
except Exception:
|
||||
render_order = 0
|
||||
overlay_stages.append((render_order, stage))
|
||||
else:
|
||||
regular_stages.append(name)
|
||||
|
||||
# Execute regular stages in dependency order
|
||||
for name in regular_stages:
|
||||
stage = self._stages.get(name)
|
||||
if not stage or not stage.is_enabled():
|
||||
continue
|
||||
|
||||
stage_start = time.perf_counter() if self._metrics_enabled else 0
|
||||
|
||||
try:
|
||||
if debug:
|
||||
data_info = type(current_data).__name__
|
||||
if isinstance(current_data, list):
|
||||
data_info += f"[{len(current_data)}]"
|
||||
print(
|
||||
f"[STAGE.{name}] Starting with: {data_info}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
current_data = stage.process(current_data, self.context)
|
||||
|
||||
if debug:
|
||||
data_info = type(current_data).__name__
|
||||
if isinstance(current_data, list):
|
||||
data_info += f"[{len(current_data)}]"
|
||||
print(
|
||||
f"[STAGE.{name}] Completed, output: {data_info}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
if debug:
|
||||
print(f"[STAGE.{name}] ERROR: {e}", file=sys.stderr, flush=True)
|
||||
if not stage.optional:
|
||||
return StageResult(
|
||||
success=False,
|
||||
data=current_data,
|
||||
error=str(e),
|
||||
stage_name=name,
|
||||
)
|
||||
continue
|
||||
|
||||
if self._metrics_enabled:
|
||||
stage_duration = (time.perf_counter() - stage_start) * 1000
|
||||
chars_in = len(str(data)) if data else 0
|
||||
chars_out = len(str(current_data)) if current_data else 0
|
||||
stage_timings.append(
|
||||
StageMetrics(
|
||||
name=name,
|
||||
duration_ms=stage_duration,
|
||||
chars_in=chars_in,
|
||||
chars_out=chars_out,
|
||||
)
|
||||
)
|
||||
|
||||
# Apply overlay stages (sorted by render_order)
|
||||
overlay_stages.sort(key=lambda x: x[0])
|
||||
for render_order, stage in overlay_stages:
|
||||
stage_start = time.perf_counter() if self._metrics_enabled else 0
|
||||
stage_name = f"[overlay]{stage.name}"
|
||||
|
||||
try:
|
||||
# Overlays receive current_data but don't pass their output to next stage
|
||||
# Instead, their output is composited on top
|
||||
overlay_output = stage.process(current_data, self.context)
|
||||
# For now, we just let the overlay output pass through
|
||||
# In a more sophisticated implementation, we'd composite it
|
||||
if overlay_output is not None:
|
||||
current_data = overlay_output
|
||||
except Exception as e:
|
||||
if not stage.optional:
|
||||
return StageResult(
|
||||
success=False,
|
||||
data=current_data,
|
||||
error=str(e),
|
||||
stage_name=stage_name,
|
||||
)
|
||||
|
||||
if self._metrics_enabled:
|
||||
stage_duration = (time.perf_counter() - stage_start) * 1000
|
||||
chars_in = len(str(data)) if data else 0
|
||||
chars_out = len(str(current_data)) if current_data else 0
|
||||
stage_timings.append(
|
||||
StageMetrics(
|
||||
name=stage_name,
|
||||
duration_ms=stage_duration,
|
||||
chars_in=chars_in,
|
||||
chars_out=chars_out,
|
||||
)
|
||||
)
|
||||
|
||||
if self._metrics_enabled:
|
||||
total_duration = (time.perf_counter() - frame_start) * 1000
|
||||
self._frame_metrics.append(
|
||||
FrameMetrics(
|
||||
frame_number=self._current_frame_number,
|
||||
total_ms=total_duration,
|
||||
stages=stage_timings,
|
||||
)
|
||||
)
|
||||
|
||||
# Store metrics in context for other stages (like HUD)
|
||||
# This makes metrics a first-class pipeline citizen
|
||||
if self.context:
|
||||
self.context.state["metrics"] = self.get_metrics_summary()
|
||||
|
||||
if len(self._frame_metrics) > self._max_metrics_frames:
|
||||
self._frame_metrics.pop(0)
|
||||
self._current_frame_number += 1
|
||||
|
||||
return StageResult(success=True, data=current_data)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up all stages in reverse order."""
|
||||
for name in reversed(self._execution_order):
|
||||
stage = self._stages.get(name)
|
||||
if stage:
|
||||
try:
|
||||
stage.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
self._stages.clear()
|
||||
self._initialized = False
|
||||
|
||||
@property
|
||||
def stages(self) -> dict[str, Stage]:
|
||||
"""Get all stages."""
|
||||
return self._stages.copy()
|
||||
|
||||
@property
|
||||
def execution_order(self) -> list[str]:
|
||||
"""Get execution order."""
|
||||
return self._execution_order.copy()
|
||||
|
||||
def get_stage_names(self) -> list[str]:
|
||||
"""Get list of stage names."""
|
||||
return list(self._stages.keys())
|
||||
|
||||
def get_overlay_stages(self) -> list[Stage]:
|
||||
"""Get all overlay stages sorted by render_order."""
|
||||
overlays = [stage for stage in self._stages.values() if stage.is_overlay]
|
||||
overlays.sort(key=lambda s: s.render_order)
|
||||
return overlays
|
||||
|
||||
def get_stage_type(self, name: str) -> str:
|
||||
"""Get the stage_type for a stage."""
|
||||
stage = self._stages.get(name)
|
||||
return stage.stage_type if stage else ""
|
||||
|
||||
def get_render_order(self, name: str) -> int:
|
||||
"""Get the render_order for a stage."""
|
||||
stage = self._stages.get(name)
|
||||
return stage.render_order if stage else 0
|
||||
|
||||
def get_metrics_summary(self) -> dict:
|
||||
"""Get summary of collected metrics."""
|
||||
if not self._frame_metrics:
|
||||
return {"error": "No metrics collected"}
|
||||
|
||||
total_times = [f.total_ms for f in self._frame_metrics]
|
||||
avg_total = sum(total_times) / len(total_times)
|
||||
min_total = min(total_times)
|
||||
max_total = max(total_times)
|
||||
|
||||
stage_stats: dict[str, dict] = {}
|
||||
for frame in self._frame_metrics:
|
||||
for stage in frame.stages:
|
||||
if stage.name not in stage_stats:
|
||||
stage_stats[stage.name] = {"times": [], "total_chars": 0}
|
||||
stage_stats[stage.name]["times"].append(stage.duration_ms)
|
||||
stage_stats[stage.name]["total_chars"] += stage.chars_out
|
||||
|
||||
for name, stats in stage_stats.items():
|
||||
times = stats["times"]
|
||||
stats["avg_ms"] = sum(times) / len(times)
|
||||
stats["min_ms"] = min(times)
|
||||
stats["max_ms"] = max(times)
|
||||
del stats["times"]
|
||||
|
||||
return {
|
||||
"frame_count": len(self._frame_metrics),
|
||||
"pipeline": {
|
||||
"avg_ms": avg_total,
|
||||
"min_ms": min_total,
|
||||
"max_ms": max_total,
|
||||
},
|
||||
"stages": stage_stats,
|
||||
}
|
||||
|
||||
def reset_metrics(self) -> None:
|
||||
"""Reset collected metrics."""
|
||||
self._frame_metrics.clear()
|
||||
self._current_frame_number = 0
|
||||
|
||||
def get_frame_times(self) -> list[float]:
|
||||
"""Get historical frame times for sparklines/charts."""
|
||||
return [f.total_ms for f in self._frame_metrics]
|
||||
|
||||
|
||||
class PipelineRunner:
|
||||
"""High-level pipeline runner with animation support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: Pipeline,
|
||||
params: PipelineParams | None = None,
|
||||
):
|
||||
self.pipeline = pipeline
|
||||
self.params = params or PipelineParams()
|
||||
self._running = False
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the pipeline."""
|
||||
self._running = True
|
||||
return self.pipeline.initialize()
|
||||
|
||||
def step(self, input_data: Any | None = None) -> Any:
|
||||
"""Execute one pipeline step."""
|
||||
self.params.frame_number += 1
|
||||
self.pipeline.context.params = self.params
|
||||
result = self.pipeline.execute(input_data)
|
||||
return result.data if result.success else None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop and clean up the pipeline."""
|
||||
self._running = False
|
||||
self.pipeline.cleanup()
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Check if runner is active."""
|
||||
return self._running
|
||||
|
||||
|
||||
def create_pipeline_from_params(params: PipelineParams) -> Pipeline:
|
||||
"""Create a pipeline from PipelineParams."""
|
||||
config = PipelineConfig(
|
||||
source=params.source,
|
||||
display=params.display,
|
||||
camera=params.camera_mode,
|
||||
effects=params.effect_order,
|
||||
)
|
||||
return Pipeline(config=config)
|
||||
|
||||
|
||||
def create_default_pipeline() -> Pipeline:
|
||||
"""Create a default pipeline with all standard components."""
|
||||
from engine.data_sources.sources import HeadlinesDataSource
|
||||
from engine.pipeline.adapters import (
|
||||
DataSourceStage,
|
||||
SourceItemsToBufferStage,
|
||||
)
|
||||
|
||||
pipeline = Pipeline()
|
||||
|
||||
# Add source stage (wrapped as Stage)
|
||||
source = HeadlinesDataSource()
|
||||
pipeline.add_stage("source", DataSourceStage(source, name="headlines"))
|
||||
|
||||
# Add render stage to convert items to text buffer
|
||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
||||
|
||||
# Add display stage
|
||||
display = StageRegistry.create("display", "terminal")
|
||||
if display:
|
||||
pipeline.add_stage("display", display)
|
||||
|
||||
return pipeline.build()
|
||||
306
engine/pipeline/core.py
Normal file
306
engine/pipeline/core.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Pipeline core - Unified Stage abstraction and PipelineContext.
|
||||
|
||||
This module provides the foundation for a clean, dependency-managed pipeline:
|
||||
- Stage: Base class for all pipeline components (sources, effects, displays, cameras)
|
||||
- PipelineContext: Dependency injection context for runtime data exchange
|
||||
- Capability system: Explicit capability declarations with duck-typing support
|
||||
- DataType: PureData-style inlet/outlet typing for validation
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
|
||||
class DataType(Enum):
|
||||
"""PureData-style data types for inlet/outlet validation.
|
||||
|
||||
Each type represents a specific data format that flows through the pipeline.
|
||||
This enables compile-time-like validation of connections.
|
||||
|
||||
Examples:
|
||||
SOURCE_ITEMS: List[SourceItem] - raw items from sources
|
||||
ITEM_TUPLES: List[tuple] - (title, source, timestamp) tuples
|
||||
TEXT_BUFFER: List[str] - rendered ANSI buffer for display
|
||||
RAW_TEXT: str - raw text strings
|
||||
PIL_IMAGE: PIL Image object
|
||||
"""
|
||||
|
||||
SOURCE_ITEMS = auto() # List[SourceItem] - from DataSource
|
||||
ITEM_TUPLES = auto() # List[tuple] - (title, source, ts)
|
||||
TEXT_BUFFER = auto() # List[str] - ANSI buffer
|
||||
RAW_TEXT = auto() # str - raw text
|
||||
PIL_IMAGE = auto() # PIL Image object
|
||||
ANY = auto() # Accepts any type
|
||||
NONE = auto() # No data (terminator)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageConfig:
|
||||
"""Configuration for a single stage."""
|
||||
|
||||
name: str
|
||||
category: str
|
||||
enabled: bool = True
|
||||
optional: bool = False
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Stage(ABC):
|
||||
"""Abstract base class for all pipeline stages.
|
||||
|
||||
A Stage is a single component in the rendering pipeline. Stages can be:
|
||||
- Sources: Data providers (headlines, poetry, pipeline viz)
|
||||
- Effects: Post-processors (noise, fade, glitch, hud)
|
||||
- Displays: Output backends (terminal, pygame, websocket)
|
||||
- Cameras: Viewport controllers (vertical, horizontal, omni)
|
||||
- Overlays: UI elements that compose on top (HUD)
|
||||
|
||||
Stages declare:
|
||||
- capabilities: What they provide to other stages
|
||||
- dependencies: What they need from other stages
|
||||
- stage_type: Category of stage (source, effect, overlay, display)
|
||||
- render_order: Execution order within category
|
||||
- is_overlay: If True, output is composited on top, not passed downstream
|
||||
|
||||
Duck-typing is supported: any class with the required methods can act as a Stage.
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: str # "source", "effect", "overlay", "display", "camera"
|
||||
optional: bool = False # If True, pipeline continues even if stage fails
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
"""Category of stage for ordering.
|
||||
|
||||
Valid values: "source", "effect", "overlay", "display", "camera"
|
||||
Defaults to category for backwards compatibility.
|
||||
"""
|
||||
return self.category
|
||||
|
||||
@property
|
||||
def render_order(self) -> int:
|
||||
"""Execution order within stage_type group.
|
||||
|
||||
Higher values execute later. Useful for ordering overlays
|
||||
or effects that need specific execution order.
|
||||
"""
|
||||
return 0
|
||||
|
||||
@property
|
||||
def is_overlay(self) -> bool:
|
||||
"""If True, this stage's output is composited on top of the buffer.
|
||||
|
||||
Overlay stages don't pass their output to the next stage.
|
||||
Instead, their output is layered on top of the final buffer.
|
||||
Use this for HUD, status displays, and similar UI elements.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set[DataType]:
|
||||
"""Return set of data types this stage accepts.
|
||||
|
||||
PureData-style inlet typing. If the connected upstream stage's
|
||||
outlet_type is not in this set, the pipeline will raise an error.
|
||||
|
||||
Examples:
|
||||
- Source stages: {DataType.NONE} (no input needed)
|
||||
- Transform stages: {DataType.ITEM_TUPLES, DataType.TEXT_BUFFER}
|
||||
- Display stages: {DataType.TEXT_BUFFER}
|
||||
"""
|
||||
return {DataType.ANY}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set[DataType]:
|
||||
"""Return set of data types this stage produces.
|
||||
|
||||
PureData-style outlet typing. Downstream stages must accept
|
||||
this type in their inlet_types.
|
||||
|
||||
Examples:
|
||||
- Source stages: {DataType.SOURCE_ITEMS}
|
||||
- Transform stages: {DataType.TEXT_BUFFER}
|
||||
- Display stages: {DataType.NONE} (consumes data)
|
||||
"""
|
||||
return {DataType.ANY}
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
"""Return set of capabilities this stage provides.
|
||||
|
||||
Examples:
|
||||
- "source.headlines"
|
||||
- "effect.noise"
|
||||
- "display.output"
|
||||
- "camera"
|
||||
"""
|
||||
return {f"{self.category}.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
"""Return set of capability names this stage needs.
|
||||
|
||||
Examples:
|
||||
- {"display.output"}
|
||||
- {"source.headlines"}
|
||||
- {"camera"}
|
||||
"""
|
||||
return set()
|
||||
|
||||
def init(self, ctx: "PipelineContext") -> bool:
|
||||
"""Initialize stage with pipeline context.
|
||||
|
||||
Args:
|
||||
ctx: PipelineContext for accessing services
|
||||
|
||||
Returns:
|
||||
True if initialization succeeded, False otherwise
|
||||
"""
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def process(self, data: Any, ctx: "PipelineContext") -> Any:
|
||||
"""Process input data and return output.
|
||||
|
||||
Args:
|
||||
data: Input data from previous stage (or initial data for first stage)
|
||||
ctx: PipelineContext for accessing services and state
|
||||
|
||||
Returns:
|
||||
Processed data for next stage
|
||||
"""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None: # noqa: B027
|
||||
"""Clean up resources when pipeline shuts down."""
|
||||
pass
|
||||
|
||||
def get_config(self) -> StageConfig:
|
||||
"""Return current configuration of this stage."""
|
||||
return StageConfig(
|
||||
name=self.name,
|
||||
category=self.category,
|
||||
optional=self.optional,
|
||||
)
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
"""Enable or disable this stage."""
|
||||
self._enabled = enabled # type: ignore[attr-defined]
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if stage is enabled."""
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageResult:
|
||||
"""Result of stage processing, including success/failure info."""
|
||||
|
||||
success: bool
|
||||
data: Any
|
||||
error: str | None = None
|
||||
stage_name: str = ""
|
||||
|
||||
|
||||
class PipelineContext:
|
||||
"""Dependency injection context passed through the pipeline.
|
||||
|
||||
Provides:
|
||||
- services: Named services (display, config, event_bus, etc.)
|
||||
- state: Runtime state shared between stages
|
||||
- params: PipelineParams for animation-driven config
|
||||
|
||||
Services can be injected at construction time or lazily resolved.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
services: dict[str, Any] | None = None,
|
||||
initial_state: dict[str, Any] | None = None,
|
||||
):
|
||||
self.services: dict[str, Any] = services or {}
|
||||
self.state: dict[str, Any] = initial_state or {}
|
||||
self._params: PipelineParams | None = None
|
||||
|
||||
# Lazy resolvers for common services
|
||||
self._lazy_resolvers: dict[str, Callable[[], Any]] = {
|
||||
"config": self._resolve_config,
|
||||
"event_bus": self._resolve_event_bus,
|
||||
}
|
||||
|
||||
def _resolve_config(self) -> Any:
|
||||
from engine.config import get_config
|
||||
|
||||
return get_config()
|
||||
|
||||
def _resolve_event_bus(self) -> Any:
|
||||
from engine.eventbus import get_event_bus
|
||||
|
||||
return get_event_bus()
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a service or state value by key.
|
||||
|
||||
First checks services, then state, then lazy resolution.
|
||||
"""
|
||||
if key in self.services:
|
||||
return self.services[key]
|
||||
if key in self.state:
|
||||
return self.state[key]
|
||||
if key in self._lazy_resolvers:
|
||||
try:
|
||||
return self._lazy_resolvers[key]()
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a service or state value."""
|
||||
self.services[key] = value
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a runtime state value."""
|
||||
self.state[key] = value
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a runtime state value."""
|
||||
return self.state.get(key, default)
|
||||
|
||||
@property
|
||||
def params(self) -> "PipelineParams | None":
|
||||
"""Get current pipeline params (for animation)."""
|
||||
return self._params
|
||||
|
||||
@params.setter
|
||||
def params(self, value: "PipelineParams") -> None:
|
||||
"""Set pipeline params (from animation controller)."""
|
||||
self._params = value
|
||||
|
||||
def has_capability(self, capability: str) -> bool:
|
||||
"""Check if a capability is available."""
|
||||
return capability in self.services or capability in self._lazy_resolvers
|
||||
|
||||
|
||||
class StageError(Exception):
|
||||
"""Raised when a stage fails to process."""
|
||||
|
||||
def __init__(self, stage_name: str, message: str, is_optional: bool = False):
|
||||
self.stage_name = stage_name
|
||||
self.message = message
|
||||
self.is_optional = is_optional
|
||||
super().__init__(f"Stage '{stage_name}' failed: {message}")
|
||||
|
||||
|
||||
def create_stage_error(
|
||||
stage_name: str, error: Exception, is_optional: bool = False
|
||||
) -> StageError:
|
||||
"""Helper to create a StageError from an exception."""
|
||||
return StageError(stage_name, str(error), is_optional)
|
||||
145
engine/pipeline/params.py
Normal file
145
engine/pipeline/params.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Pipeline parameters - Runtime configuration layer for animation control.
|
||||
|
||||
PipelineParams is the target for AnimationController - animation events
|
||||
modify these params, which the pipeline then applies to its stages.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineParams:
|
||||
"""Runtime configuration for the pipeline.
|
||||
|
||||
This is the canonical config object that AnimationController modifies.
|
||||
Stages read from these params to adjust their behavior.
|
||||
"""
|
||||
|
||||
# Source config
|
||||
source: str = "headlines"
|
||||
source_refresh_interval: float = 60.0
|
||||
|
||||
# Display config
|
||||
display: str = "terminal"
|
||||
border: bool = False
|
||||
|
||||
# Camera config
|
||||
camera_mode: str = "vertical"
|
||||
camera_speed: float = 1.0
|
||||
camera_x: int = 0 # For horizontal scrolling
|
||||
|
||||
# Effect config
|
||||
effect_order: list[str] = field(
|
||||
default_factory=lambda: ["noise", "fade", "glitch", "firehose"]
|
||||
)
|
||||
effect_enabled: dict[str, bool] = field(default_factory=dict)
|
||||
effect_intensity: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
# Animation-driven state (set by AnimationController)
|
||||
pulse: float = 0.0
|
||||
current_effect: str | None = None
|
||||
path_progress: float = 0.0
|
||||
|
||||
# Viewport
|
||||
viewport_width: int = 80
|
||||
viewport_height: int = 24
|
||||
|
||||
# Firehose
|
||||
firehose_enabled: bool = False
|
||||
|
||||
# Runtime state
|
||||
frame_number: int = 0
|
||||
fps: float = 60.0
|
||||
|
||||
def get_effect_config(self, name: str) -> tuple[bool, float]:
|
||||
"""Get (enabled, intensity) for an effect."""
|
||||
enabled = self.effect_enabled.get(name, True)
|
||||
intensity = self.effect_intensity.get(name, 1.0)
|
||||
return enabled, intensity
|
||||
|
||||
def set_effect_config(self, name: str, enabled: bool, intensity: float) -> None:
|
||||
"""Set effect configuration."""
|
||||
self.effect_enabled[name] = enabled
|
||||
self.effect_intensity[name] = intensity
|
||||
|
||||
def is_effect_enabled(self, name: str) -> bool:
|
||||
"""Check if an effect is enabled."""
|
||||
if name not in self.effect_enabled:
|
||||
return True # Default to enabled
|
||||
return self.effect_enabled.get(name, True)
|
||||
|
||||
def get_effect_intensity(self, name: str) -> float:
|
||||
"""Get effect intensity (0.0 to 1.0)."""
|
||||
return self.effect_intensity.get(name, 1.0)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"source": self.source,
|
||||
"display": self.display,
|
||||
"camera_mode": self.camera_mode,
|
||||
"camera_speed": self.camera_speed,
|
||||
"effect_order": self.effect_order,
|
||||
"effect_enabled": self.effect_enabled.copy(),
|
||||
"effect_intensity": self.effect_intensity.copy(),
|
||||
"pulse": self.pulse,
|
||||
"current_effect": self.current_effect,
|
||||
"viewport_width": self.viewport_width,
|
||||
"viewport_height": self.viewport_height,
|
||||
"firehose_enabled": self.firehose_enabled,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "PipelineParams":
|
||||
"""Create from dictionary."""
|
||||
params = cls()
|
||||
for key, value in data.items():
|
||||
if hasattr(params, key):
|
||||
setattr(params, key, value)
|
||||
return params
|
||||
|
||||
def copy(self) -> "PipelineParams":
|
||||
"""Create a copy of this params object."""
|
||||
params = PipelineParams()
|
||||
params.source = self.source
|
||||
params.display = self.display
|
||||
params.camera_mode = self.camera_mode
|
||||
params.camera_speed = self.camera_speed
|
||||
params.camera_x = self.camera_x
|
||||
params.effect_order = self.effect_order.copy()
|
||||
params.effect_enabled = self.effect_enabled.copy()
|
||||
params.effect_intensity = self.effect_intensity.copy()
|
||||
params.pulse = self.pulse
|
||||
params.current_effect = self.current_effect
|
||||
params.path_progress = self.path_progress
|
||||
params.viewport_width = self.viewport_width
|
||||
params.viewport_height = self.viewport_height
|
||||
params.firehose_enabled = self.firehose_enabled
|
||||
params.frame_number = self.frame_number
|
||||
params.fps = self.fps
|
||||
return params
|
||||
|
||||
|
||||
# Default params for different modes
|
||||
DEFAULT_HEADLINE_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="terminal",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
DEFAULT_PYGAME_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
DEFAULT_PIPELINE_PARAMS = PipelineParams(
|
||||
source="pipeline",
|
||||
display="pygame",
|
||||
camera_mode="trace",
|
||||
effect_order=[], # No effects for pipeline viz
|
||||
)
|
||||
300
engine/pipeline/pipeline_introspection_demo.py
Normal file
300
engine/pipeline/pipeline_introspection_demo.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Pipeline introspection demo controller - 3-phase animation system.
|
||||
|
||||
Phase 1: Toggle each effect on/off one at a time (3s each, 1s gap)
|
||||
Phase 2: LFO drives intensity default → max → min → default for each effect
|
||||
Phase 3: All effects with shared LFO driving full waveform
|
||||
|
||||
This controller manages the animation and updates the pipeline accordingly.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
|
||||
from engine.effects import get_registry
|
||||
from engine.sensors.oscillator import OscillatorSensor
|
||||
|
||||
|
||||
class DemoPhase(Enum):
|
||||
"""The three phases of the pipeline introspection demo."""
|
||||
|
||||
PHASE_1_TOGGLE = auto() # Toggle each effect on/off
|
||||
PHASE_2_LFO = auto() # LFO drives intensity up/down
|
||||
PHASE_3_SHARED_LFO = auto() # All effects with shared LFO
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseState:
|
||||
"""State for a single phase of the demo."""
|
||||
|
||||
phase: DemoPhase
|
||||
start_time: float
|
||||
current_effect_index: int = 0
|
||||
effect_start_time: float = 0.0
|
||||
lfo_phase: float = 0.0 # 0.0 to 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemoConfig:
|
||||
"""Configuration for the demo animation."""
|
||||
|
||||
effect_cycle_duration: float = 3.0 # seconds per effect
|
||||
gap_duration: float = 1.0 # seconds between effects
|
||||
lfo_duration: float = (
|
||||
4.0 # seconds for full LFO cycle (default → max → min → default)
|
||||
)
|
||||
phase_2_effect_duration: float = 4.0 # seconds per effect in phase 2
|
||||
phase_3_lfo_duration: float = 6.0 # seconds for full waveform in phase 3
|
||||
|
||||
|
||||
class PipelineIntrospectionDemo:
|
||||
"""Controller for the 3-phase pipeline introspection demo.
|
||||
|
||||
Manages effect toggling and LFO modulation across the pipeline.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: Any,
|
||||
effect_names: list[str] | None = None,
|
||||
config: DemoConfig | None = None,
|
||||
):
|
||||
self._pipeline = pipeline
|
||||
self._config = config or DemoConfig()
|
||||
self._effect_names = effect_names or ["noise", "fade", "glitch", "firehose"]
|
||||
self._phase = DemoPhase.PHASE_1_TOGGLE
|
||||
self._phase_state = PhaseState(
|
||||
phase=DemoPhase.PHASE_1_TOGGLE,
|
||||
start_time=time.time(),
|
||||
)
|
||||
self._shared_oscillator: OscillatorSensor | None = None
|
||||
self._frame = 0
|
||||
|
||||
# Register shared oscillator for phase 3
|
||||
self._shared_oscillator = OscillatorSensor(
|
||||
name="demo-lfo",
|
||||
waveform="sine",
|
||||
frequency=1.0 / self._config.phase_3_lfo_duration,
|
||||
)
|
||||
|
||||
@property
|
||||
def phase(self) -> DemoPhase:
|
||||
return self._phase
|
||||
|
||||
@property
|
||||
def phase_display(self) -> str:
|
||||
"""Get a human-readable phase description."""
|
||||
phase_num = {
|
||||
DemoPhase.PHASE_1_TOGGLE: 1,
|
||||
DemoPhase.PHASE_2_LFO: 2,
|
||||
DemoPhase.PHASE_3_SHARED_LFO: 3,
|
||||
}
|
||||
return f"Phase {phase_num[self._phase]}"
|
||||
|
||||
@property
|
||||
def effect_names(self) -> list[str]:
|
||||
return self._effect_names
|
||||
|
||||
@property
|
||||
def shared_oscillator(self) -> OscillatorSensor | None:
|
||||
return self._shared_oscillator
|
||||
|
||||
def update(self) -> dict[str, Any]:
|
||||
"""Update the demo state and return current parameters.
|
||||
|
||||
Returns:
|
||||
dict with current effect settings for the pipeline
|
||||
"""
|
||||
self._frame += 1
|
||||
current_time = time.time()
|
||||
elapsed = current_time - self._phase_state.start_time
|
||||
|
||||
# Phase transition logic
|
||||
phase_duration = self._get_phase_duration()
|
||||
if elapsed >= phase_duration:
|
||||
self._advance_phase()
|
||||
|
||||
# Update based on current phase
|
||||
if self._phase == DemoPhase.PHASE_1_TOGGLE:
|
||||
return self._update_phase_1(current_time)
|
||||
elif self._phase == DemoPhase.PHASE_2_LFO:
|
||||
return self._update_phase_2(current_time)
|
||||
else:
|
||||
return self._update_phase_3(current_time)
|
||||
|
||||
def _get_phase_duration(self) -> float:
|
||||
"""Get duration of current phase in seconds."""
|
||||
if self._phase == DemoPhase.PHASE_1_TOGGLE:
|
||||
# Duration = (effect_time + gap) * num_effects + final_gap
|
||||
return (
|
||||
self._config.effect_cycle_duration + self._config.gap_duration
|
||||
) * len(self._effect_names) + self._config.gap_duration
|
||||
elif self._phase == DemoPhase.PHASE_2_LFO:
|
||||
return self._config.phase_2_effect_duration * len(self._effect_names)
|
||||
else:
|
||||
# Phase 3 runs indefinitely
|
||||
return float("inf")
|
||||
|
||||
def _advance_phase(self) -> None:
|
||||
"""Advance to the next phase."""
|
||||
if self._phase == DemoPhase.PHASE_1_TOGGLE:
|
||||
self._phase = DemoPhase.PHASE_2_LFO
|
||||
elif self._phase == DemoPhase.PHASE_2_LFO:
|
||||
self._phase = DemoPhase.PHASE_3_SHARED_LFO
|
||||
# Start the shared oscillator
|
||||
if self._shared_oscillator:
|
||||
self._shared_oscillator.start()
|
||||
else:
|
||||
# Phase 3 loops indefinitely - reset for demo replay after long time
|
||||
self._phase = DemoPhase.PHASE_1_TOGGLE
|
||||
|
||||
self._phase_state = PhaseState(
|
||||
phase=self._phase,
|
||||
start_time=time.time(),
|
||||
)
|
||||
|
||||
def _update_phase_1(self, current_time: float) -> dict[str, Any]:
|
||||
"""Phase 1: Toggle each effect on/off one at a time."""
|
||||
effect_time = current_time - self._phase_state.effect_start_time
|
||||
|
||||
# Check if we should move to next effect
|
||||
cycle_time = self._config.effect_cycle_duration + self._config.gap_duration
|
||||
effect_index = int((current_time - self._phase_state.start_time) / cycle_time)
|
||||
|
||||
# Clamp to valid range
|
||||
if effect_index >= len(self._effect_names):
|
||||
effect_index = len(self._effect_names) - 1
|
||||
|
||||
# Calculate current effect state
|
||||
in_gap = effect_time >= self._config.effect_cycle_duration
|
||||
|
||||
# Build effect states
|
||||
effect_states: dict[str, dict[str, Any]] = {}
|
||||
for i, name in enumerate(self._effect_names):
|
||||
if i < effect_index:
|
||||
# Past effects - leave at default
|
||||
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||
elif i == effect_index:
|
||||
# Current effect - toggle on/off
|
||||
if in_gap:
|
||||
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||
else:
|
||||
effect_states[name] = {"enabled": True, "intensity": 1.0}
|
||||
else:
|
||||
# Future effects - off
|
||||
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||
|
||||
# Apply to effect registry
|
||||
self._apply_effect_states(effect_states)
|
||||
|
||||
return {
|
||||
"phase": "PHASE_1_TOGGLE",
|
||||
"phase_display": self.phase_display,
|
||||
"current_effect": self._effect_names[effect_index]
|
||||
if effect_index < len(self._effect_names)
|
||||
else None,
|
||||
"effect_states": effect_states,
|
||||
"frame": self._frame,
|
||||
}
|
||||
|
||||
def _update_phase_2(self, current_time: float) -> dict[str, Any]:
|
||||
"""Phase 2: LFO drives intensity default → max → min → default."""
|
||||
elapsed = current_time - self._phase_state.start_time
|
||||
effect_index = int(elapsed / self._config.phase_2_effect_duration)
|
||||
effect_index = min(effect_index, len(self._effect_names) - 1)
|
||||
|
||||
# Calculate LFO position (0 → 1 → 0)
|
||||
effect_elapsed = elapsed % self._config.phase_2_effect_duration
|
||||
lfo_position = effect_elapsed / self._config.phase_2_effect_duration
|
||||
|
||||
# LFO: 0 → 1 → 0 (triangle wave)
|
||||
if lfo_position < 0.5:
|
||||
lfo_value = lfo_position * 2 # 0 → 1
|
||||
else:
|
||||
lfo_value = 2 - lfo_position * 2 # 1 → 0
|
||||
|
||||
# Map to intensity: 0.3 (default) → 1.0 (max) → 0.0 (min) → 0.3 (default)
|
||||
if lfo_position < 0.25:
|
||||
# 0.3 → 1.0
|
||||
intensity = 0.3 + (lfo_position / 0.25) * 0.7
|
||||
elif lfo_position < 0.75:
|
||||
# 1.0 → 0.0
|
||||
intensity = 1.0 - ((lfo_position - 0.25) / 0.5) * 1.0
|
||||
else:
|
||||
# 0.0 → 0.3
|
||||
intensity = ((lfo_position - 0.75) / 0.25) * 0.3
|
||||
|
||||
# Build effect states
|
||||
effect_states: dict[str, dict[str, Any]] = {}
|
||||
for i, name in enumerate(self._effect_names):
|
||||
if i < effect_index:
|
||||
# Past effects - default
|
||||
effect_states[name] = {"enabled": True, "intensity": 0.5}
|
||||
elif i == effect_index:
|
||||
# Current effect - LFO modulated
|
||||
effect_states[name] = {"enabled": True, "intensity": intensity}
|
||||
else:
|
||||
# Future effects - off
|
||||
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||
|
||||
# Apply to effect registry
|
||||
self._apply_effect_states(effect_states)
|
||||
|
||||
return {
|
||||
"phase": "PHASE_2_LFO",
|
||||
"phase_display": self.phase_display,
|
||||
"current_effect": self._effect_names[effect_index],
|
||||
"lfo_value": lfo_value,
|
||||
"intensity": intensity,
|
||||
"effect_states": effect_states,
|
||||
"frame": self._frame,
|
||||
}
|
||||
|
||||
def _update_phase_3(self, current_time: float) -> dict[str, Any]:
|
||||
"""Phase 3: All effects with shared LFO driving full waveform."""
|
||||
# Read shared oscillator
|
||||
lfo_value = 0.5 # Default
|
||||
if self._shared_oscillator:
|
||||
sensor_val = self._shared_oscillator.read()
|
||||
if sensor_val:
|
||||
lfo_value = sensor_val.value
|
||||
|
||||
# All effects enabled with shared LFO
|
||||
effect_states: dict[str, dict[str, Any]] = {}
|
||||
for name in self._effect_names:
|
||||
effect_states[name] = {"enabled": True, "intensity": lfo_value}
|
||||
|
||||
# Apply to effect registry
|
||||
self._apply_effect_states(effect_states)
|
||||
|
||||
return {
|
||||
"phase": "PHASE_3_SHARED_LFO",
|
||||
"phase_display": self.phase_display,
|
||||
"lfo_value": lfo_value,
|
||||
"effect_states": effect_states,
|
||||
"frame": self._frame,
|
||||
}
|
||||
|
||||
def _apply_effect_states(self, effect_states: dict[str, dict[str, Any]]) -> None:
|
||||
"""Apply effect states to the effect registry."""
|
||||
try:
|
||||
registry = get_registry()
|
||||
for name, state in effect_states.items():
|
||||
effect = registry.get(name)
|
||||
if effect:
|
||||
effect.config.enabled = state["enabled"]
|
||||
effect.config.intensity = state["intensity"]
|
||||
except Exception:
|
||||
pass # Silently fail if registry not available
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self._shared_oscillator:
|
||||
self._shared_oscillator.stop()
|
||||
|
||||
# Reset all effects to default
|
||||
self._apply_effect_states(
|
||||
{name: {"enabled": False, "intensity": 0.5} for name in self._effect_names}
|
||||
)
|
||||
280
engine/pipeline/preset_loader.py
Normal file
280
engine/pipeline/preset_loader.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Preset loader - Loads presets from TOML files.
|
||||
|
||||
Supports:
|
||||
- Built-in presets.toml in the package
|
||||
- User overrides in ~/.config/mainline/presets.toml
|
||||
- Local override in ./presets.toml
|
||||
- Fallback DEFAULT_PRESET when loading fails
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tomllib
|
||||
|
||||
DEFAULT_PRESET: dict[str, Any] = {
|
||||
"description": "Default fallback preset",
|
||||
"source": "headlines",
|
||||
"display": "terminal",
|
||||
"camera": "vertical",
|
||||
"effects": [],
|
||||
"viewport": {"width": 80, "height": 24},
|
||||
"camera_speed": 1.0,
|
||||
"firehose_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
def get_preset_paths() -> list[Path]:
|
||||
"""Get list of preset file paths in load order (later overrides earlier)."""
|
||||
paths = []
|
||||
|
||||
builtin = Path(__file__).parent.parent / "presets.toml"
|
||||
if builtin.exists():
|
||||
paths.append(builtin)
|
||||
|
||||
user_config = Path(os.path.expanduser("~/.config/mainline/presets.toml"))
|
||||
if user_config.exists():
|
||||
paths.append(user_config)
|
||||
|
||||
local = Path("presets.toml")
|
||||
if local.exists():
|
||||
paths.append(local)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def load_presets() -> dict[str, Any]:
|
||||
"""Load all presets, merging from multiple sources."""
|
||||
merged: dict[str, Any] = {"presets": {}, "sensors": {}, "effect_configs": {}}
|
||||
|
||||
for path in get_preset_paths():
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
if "presets" in data:
|
||||
merged["presets"].update(data["presets"])
|
||||
|
||||
if "sensors" in data:
|
||||
merged["sensors"].update(data["sensors"])
|
||||
|
||||
if "effect_configs" in data:
|
||||
merged["effect_configs"].update(data["effect_configs"])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load presets from {path}: {e}")
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def get_preset(name: str) -> dict[str, Any] | None:
|
||||
"""Get a preset by name."""
|
||||
presets = load_presets()
|
||||
return presets.get("presets", {}).get(name)
|
||||
|
||||
|
||||
def list_preset_names() -> list[str]:
|
||||
"""List all available preset names."""
|
||||
presets = load_presets()
|
||||
return list(presets.get("presets", {}).keys())
|
||||
|
||||
|
||||
def get_sensor_config(name: str) -> dict[str, Any] | None:
|
||||
"""Get sensor configuration by name."""
|
||||
sensors = load_presets()
|
||||
return sensors.get("sensors", {}).get(name)
|
||||
|
||||
|
||||
def get_effect_config(name: str) -> dict[str, Any] | None:
|
||||
"""Get effect configuration by name."""
|
||||
configs = load_presets()
|
||||
return configs.get("effect_configs", {}).get(name)
|
||||
|
||||
|
||||
def get_all_effect_configs() -> dict[str, Any]:
|
||||
"""Get all effect configurations."""
|
||||
configs = load_presets()
|
||||
return configs.get("effect_configs", {})
|
||||
|
||||
|
||||
def get_preset_or_default(name: str) -> dict[str, Any]:
|
||||
"""Get a preset by name, or return DEFAULT_PRESET if not found."""
|
||||
preset = get_preset(name)
|
||||
if preset is not None:
|
||||
return preset
|
||||
return DEFAULT_PRESET.copy()
|
||||
|
||||
|
||||
def ensure_preset_available(name: str | None) -> dict[str, Any]:
|
||||
"""Ensure a preset is available, falling back to DEFAULT_PRESET."""
|
||||
if name is None:
|
||||
return DEFAULT_PRESET.copy()
|
||||
return get_preset_or_default(name)
|
||||
|
||||
|
||||
class PresetValidationError(Exception):
|
||||
"""Raised when preset validation fails."""
|
||||
|
||||
|
||||
def validate_preset(preset: dict[str, Any]) -> list[str]:
|
||||
"""Validate a preset and return list of errors (empty if valid)."""
|
||||
errors: list[str] = []
|
||||
|
||||
required_fields = ["source", "display", "effects"]
|
||||
for field in required_fields:
|
||||
if field not in preset:
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
if "effects" in preset:
|
||||
if not isinstance(preset["effects"], list):
|
||||
errors.append("'effects' must be a list")
|
||||
else:
|
||||
for effect in preset["effects"]:
|
||||
if not isinstance(effect, str):
|
||||
errors.append(
|
||||
f"Effect must be string, got {type(effect)}: {effect}"
|
||||
)
|
||||
|
||||
if "viewport" in preset:
|
||||
viewport = preset["viewport"]
|
||||
if not isinstance(viewport, dict):
|
||||
errors.append("'viewport' must be a dict")
|
||||
else:
|
||||
if "width" in viewport and not isinstance(viewport["width"], int):
|
||||
errors.append("'viewport.width' must be an int")
|
||||
if "height" in viewport and not isinstance(viewport["height"], int):
|
||||
errors.append("'viewport.height' must be an int")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_signal_flow(stages: list[dict]) -> list[str]:
|
||||
"""Validate signal flow based on inlet/outlet types.
|
||||
|
||||
This validates that the preset's stage configuration produces valid
|
||||
data flow using the PureData-style type system.
|
||||
|
||||
Args:
|
||||
stages: List of stage configs with 'name', 'category', 'inlet_types', 'outlet_types'
|
||||
|
||||
Returns:
|
||||
List of errors (empty if valid)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not stages:
|
||||
errors.append("Signal flow is empty")
|
||||
return errors
|
||||
|
||||
# Define expected types for each category
|
||||
type_map = {
|
||||
"source": {"inlet": "NONE", "outlet": "SOURCE_ITEMS"},
|
||||
"data": {"inlet": "ANY", "outlet": "SOURCE_ITEMS"},
|
||||
"transform": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
|
||||
"effect": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||
"overlay": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||
"camera": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||
"display": {"inlet": "TEXT_BUFFER", "outlet": "NONE"},
|
||||
"render": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
|
||||
}
|
||||
|
||||
# Check stage order and type compatibility
|
||||
for i, stage in enumerate(stages):
|
||||
category = stage.get("category", "unknown")
|
||||
name = stage.get("name", f"stage_{i}")
|
||||
|
||||
if category not in type_map:
|
||||
continue # Skip unknown categories
|
||||
|
||||
expected = type_map[category]
|
||||
|
||||
# Check against previous stage
|
||||
if i > 0:
|
||||
prev = stages[i - 1]
|
||||
prev_category = prev.get("category", "unknown")
|
||||
if prev_category in type_map:
|
||||
prev_outlet = type_map[prev_category]["outlet"]
|
||||
inlet = expected["inlet"]
|
||||
|
||||
# Validate type compatibility
|
||||
if inlet != "ANY" and prev_outlet != "ANY" and inlet != prev_outlet:
|
||||
errors.append(
|
||||
f"Type mismatch at '{name}': "
|
||||
f"expects {inlet} but previous stage outputs {prev_outlet}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_signal_path(stages: list[str]) -> list[str]:
|
||||
"""Validate signal path for circular dependencies and connectivity.
|
||||
|
||||
Args:
|
||||
stages: List of stage names in execution order
|
||||
|
||||
Returns:
|
||||
List of errors (empty if valid)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not stages:
|
||||
errors.append("Signal path is empty")
|
||||
return errors
|
||||
|
||||
seen: set[str] = set()
|
||||
for i, stage in enumerate(stages):
|
||||
if stage in seen:
|
||||
errors.append(
|
||||
f"Circular dependency: '{stage}' appears multiple times at index {i}"
|
||||
)
|
||||
seen.add(stage)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def generate_preset_toml(
|
||||
name: str,
|
||||
source: str = "headlines",
|
||||
display: str = "terminal",
|
||||
effects: list[str] | None = None,
|
||||
viewport_width: int = 80,
|
||||
viewport_height: int = 24,
|
||||
camera: str = "vertical",
|
||||
camera_speed: float = 1.0,
|
||||
firehose_enabled: bool = False,
|
||||
) -> str:
|
||||
"""Generate a TOML preset skeleton with default values.
|
||||
|
||||
Args:
|
||||
name: Preset name
|
||||
source: Data source name
|
||||
display: Display backend
|
||||
effects: List of effect names
|
||||
viewport_width: Viewport width in columns
|
||||
viewport_height: Viewport height in rows
|
||||
camera: Camera mode
|
||||
camera_speed: Camera scroll speed
|
||||
firehose_enabled: Enable firehose mode
|
||||
|
||||
Returns:
|
||||
TOML string for the preset
|
||||
"""
|
||||
|
||||
if effects is None:
|
||||
effects = ["fade"]
|
||||
|
||||
output = []
|
||||
output.append(f"[presets.{name}]")
|
||||
output.append(f'description = "Auto-generated preset: {name}"')
|
||||
output.append(f'source = "{source}"')
|
||||
output.append(f'display = "{display}"')
|
||||
output.append(f'camera = "{camera}"')
|
||||
output.append(f"effects = {effects}")
|
||||
output.append(f"viewport_width = {viewport_width}")
|
||||
output.append(f"viewport_height = {viewport_height}")
|
||||
output.append(f"camera_speed = {camera_speed}")
|
||||
output.append(f"firehose_enabled = {str(firehose_enabled).lower()}")
|
||||
|
||||
return "\n".join(output)
|
||||
182
engine/pipeline/presets.py
Normal file
182
engine/pipeline/presets.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Pipeline presets - Pre-configured pipeline configurations.
|
||||
|
||||
Provides PipelinePreset as a unified preset system.
|
||||
Presets can be loaded from TOML files (presets.toml) or defined in code.
|
||||
|
||||
Loading order:
|
||||
1. Built-in presets.toml in the package
|
||||
2. User config ~/.config/mainline/presets.toml
|
||||
3. Local ./presets.toml (overrides earlier)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
|
||||
def _load_toml_presets() -> dict[str, Any]:
|
||||
"""Load presets from TOML file."""
|
||||
try:
|
||||
from engine.pipeline.preset_loader import load_presets
|
||||
|
||||
return load_presets()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# Pre-load TOML presets
|
||||
_YAML_PRESETS = _load_toml_presets()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelinePreset:
|
||||
"""Pre-configured pipeline with stages and animation.
|
||||
|
||||
A PipelinePreset packages:
|
||||
- Initial params: Starting configuration
|
||||
- Stages: List of stage configurations to create
|
||||
|
||||
This is the new unified preset that works with the Pipeline class.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
source: str = "headlines"
|
||||
display: str = "terminal"
|
||||
camera: str = "scroll"
|
||||
effects: list[str] = field(default_factory=list)
|
||||
border: bool = False
|
||||
|
||||
def to_params(self) -> PipelineParams:
|
||||
"""Convert to PipelineParams."""
|
||||
params = PipelineParams()
|
||||
params.source = self.source
|
||||
params.display = self.display
|
||||
params.border = self.border
|
||||
params.camera_mode = self.camera
|
||||
params.effect_order = self.effects.copy()
|
||||
return params
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, name: str, data: dict[str, Any]) -> "PipelinePreset":
|
||||
"""Create a PipelinePreset from YAML data."""
|
||||
return cls(
|
||||
name=name,
|
||||
description=data.get("description", ""),
|
||||
source=data.get("source", "headlines"),
|
||||
display=data.get("display", "terminal"),
|
||||
camera=data.get("camera", "vertical"),
|
||||
effects=data.get("effects", []),
|
||||
border=data.get("border", False),
|
||||
)
|
||||
|
||||
|
||||
# Built-in presets
|
||||
DEMO_PRESET = PipelinePreset(
|
||||
name="demo",
|
||||
description="Demo mode with effect cycling and camera modes",
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
POETRY_PRESET = PipelinePreset(
|
||||
name="poetry",
|
||||
description="Poetry feed with subtle effects",
|
||||
source="poetry",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["fade"],
|
||||
)
|
||||
|
||||
PIPELINE_VIZ_PRESET = PipelinePreset(
|
||||
name="pipeline",
|
||||
description="Pipeline visualization mode",
|
||||
source="pipeline",
|
||||
display="terminal",
|
||||
camera="trace",
|
||||
effects=[],
|
||||
)
|
||||
|
||||
WEBSOCKET_PRESET = PipelinePreset(
|
||||
name="websocket",
|
||||
description="WebSocket display mode",
|
||||
source="headlines",
|
||||
display="websocket",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch"],
|
||||
)
|
||||
|
||||
SIXEL_PRESET = PipelinePreset(
|
||||
name="sixel",
|
||||
description="Sixel graphics display mode",
|
||||
source="headlines",
|
||||
display="sixel",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch"],
|
||||
)
|
||||
|
||||
FIREHOSE_PRESET = PipelinePreset(
|
||||
name="firehose",
|
||||
description="High-speed firehose mode",
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
|
||||
# Build presets from YAML data
|
||||
def _build_presets() -> dict[str, PipelinePreset]:
|
||||
"""Build preset dictionary from all sources."""
|
||||
result = {}
|
||||
|
||||
# Add YAML presets
|
||||
yaml_presets = _YAML_PRESETS.get("presets", {})
|
||||
for name, data in yaml_presets.items():
|
||||
result[name] = PipelinePreset.from_yaml(name, data)
|
||||
|
||||
# Add built-in presets as fallback (if not in YAML)
|
||||
builtins = {
|
||||
"demo": DEMO_PRESET,
|
||||
"poetry": POETRY_PRESET,
|
||||
"pipeline": PIPELINE_VIZ_PRESET,
|
||||
"websocket": WEBSOCKET_PRESET,
|
||||
"sixel": SIXEL_PRESET,
|
||||
"firehose": FIREHOSE_PRESET,
|
||||
}
|
||||
|
||||
for name, preset in builtins.items():
|
||||
if name not in result:
|
||||
result[name] = preset
|
||||
|
||||
return result
|
||||
|
||||
|
||||
PRESETS: dict[str, PipelinePreset] = _build_presets()
|
||||
|
||||
|
||||
def get_preset(name: str) -> PipelinePreset | None:
|
||||
"""Get a preset by name."""
|
||||
return PRESETS.get(name)
|
||||
|
||||
|
||||
def list_presets() -> list[str]:
|
||||
"""List all available preset names."""
|
||||
return list(PRESETS.keys())
|
||||
|
||||
|
||||
def create_preset_from_params(
|
||||
params: PipelineParams, name: str = "custom"
|
||||
) -> PipelinePreset:
|
||||
"""Create a preset from PipelineParams."""
|
||||
return PipelinePreset(
|
||||
name=name,
|
||||
source=params.source,
|
||||
display=params.display,
|
||||
camera=params.camera_mode,
|
||||
effects=params.effect_order.copy() if hasattr(params, "effect_order") else [],
|
||||
)
|
||||
181
engine/pipeline/registry.py
Normal file
181
engine/pipeline/registry.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Stage registry - Unified registration for all pipeline stages.
|
||||
|
||||
Provides a single registry for sources, effects, displays, and cameras.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from engine.pipeline.core import Stage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.core import Stage
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class StageRegistry:
|
||||
"""Unified registry for all pipeline stage types."""
|
||||
|
||||
_categories: dict[str, dict[str, type[Any]]] = {}
|
||||
_discovered: bool = False
|
||||
_instances: dict[str, Stage] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, category: str, stage_class: type[Any]) -> None:
|
||||
"""Register a stage class in a category.
|
||||
|
||||
Args:
|
||||
category: Category name (source, effect, display, camera)
|
||||
stage_class: Stage subclass to register
|
||||
"""
|
||||
if category not in cls._categories:
|
||||
cls._categories[category] = {}
|
||||
|
||||
key = getattr(stage_class, "__name__", stage_class.__class__.__name__)
|
||||
cls._categories[category][key] = stage_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, category: str, name: str) -> type[Any] | None:
|
||||
"""Get a stage class by category and name."""
|
||||
return cls._categories.get(category, {}).get(name)
|
||||
|
||||
@classmethod
|
||||
def list(cls, category: str) -> list[str]:
|
||||
"""List all stage names in a category."""
|
||||
return list(cls._categories.get(category, {}).keys())
|
||||
|
||||
@classmethod
|
||||
def list_categories(cls) -> list[str]:
|
||||
"""List all registered categories."""
|
||||
return list(cls._categories.keys())
|
||||
|
||||
@classmethod
|
||||
def create(cls, category: str, name: str, **kwargs) -> Stage | None:
|
||||
"""Create a stage instance by category and name."""
|
||||
stage_class = cls.get(category, name)
|
||||
if stage_class:
|
||||
return stage_class(**kwargs)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def create_instance(cls, stage: Stage | type[Stage], **kwargs) -> Stage:
|
||||
"""Create an instance from a stage class or return as-is."""
|
||||
if isinstance(stage, Stage):
|
||||
return stage
|
||||
if isinstance(stage, type) and issubclass(stage, Stage):
|
||||
return stage(**kwargs)
|
||||
raise TypeError(f"Expected Stage class or instance, got {type(stage)}")
|
||||
|
||||
@classmethod
|
||||
def register_instance(cls, name: str, stage: Stage) -> None:
|
||||
"""Register a stage instance by name."""
|
||||
cls._instances[name] = stage
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, name: str) -> Stage | None:
|
||||
"""Get a registered stage instance by name."""
|
||||
return cls._instances.get(name)
|
||||
|
||||
|
||||
def discover_stages() -> None:
|
||||
"""Auto-discover and register all stage implementations."""
|
||||
if StageRegistry._discovered:
|
||||
return
|
||||
|
||||
# Import and register all stage implementations
|
||||
try:
|
||||
from engine.data_sources.sources import (
|
||||
HeadlinesDataSource,
|
||||
PoetryDataSource,
|
||||
)
|
||||
|
||||
StageRegistry.register("source", HeadlinesDataSource)
|
||||
StageRegistry.register("source", PoetryDataSource)
|
||||
|
||||
StageRegistry._categories["source"]["headlines"] = HeadlinesDataSource
|
||||
StageRegistry._categories["source"]["poetry"] = PoetryDataSource
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register pipeline introspection source
|
||||
try:
|
||||
from engine.data_sources.pipeline_introspection import (
|
||||
PipelineIntrospectionSource,
|
||||
)
|
||||
|
||||
StageRegistry.register("source", PipelineIntrospectionSource)
|
||||
StageRegistry._categories["source"]["pipeline-inspect"] = (
|
||||
PipelineIntrospectionSource
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from engine.effects.types import EffectPlugin # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register display stages
|
||||
_register_display_stages()
|
||||
|
||||
StageRegistry._discovered = True
|
||||
|
||||
|
||||
def _register_display_stages() -> None:
|
||||
"""Register display backends as stages."""
|
||||
try:
|
||||
from engine.display import DisplayRegistry
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
DisplayRegistry.initialize()
|
||||
|
||||
for backend_name in DisplayRegistry.list_backends():
|
||||
factory = _DisplayStageFactory(backend_name)
|
||||
StageRegistry._categories.setdefault("display", {})[backend_name] = factory
|
||||
|
||||
|
||||
class _DisplayStageFactory:
|
||||
"""Factory that creates DisplayStage instances for a specific backend."""
|
||||
|
||||
def __init__(self, backend_name: str):
|
||||
self._backend_name = backend_name
|
||||
|
||||
def __call__(self):
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.pipeline.adapters import DisplayStage
|
||||
|
||||
display = DisplayRegistry.create(self._backend_name)
|
||||
if display is None:
|
||||
raise RuntimeError(
|
||||
f"Failed to create display backend: {self._backend_name}"
|
||||
)
|
||||
return DisplayStage(display, name=self._backend_name)
|
||||
|
||||
@property
|
||||
def __name__(self) -> str:
|
||||
return self._backend_name.capitalize() + "Stage"
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def register_source(stage_class: type[Stage]) -> None:
|
||||
"""Register a source stage."""
|
||||
StageRegistry.register("source", stage_class)
|
||||
|
||||
|
||||
def register_effect(stage_class: type[Stage]) -> None:
|
||||
"""Register an effect stage."""
|
||||
StageRegistry.register("effect", stage_class)
|
||||
|
||||
|
||||
def register_display(stage_class: type[Stage]) -> None:
|
||||
"""Register a display stage."""
|
||||
StageRegistry.register("display", stage_class)
|
||||
|
||||
|
||||
def register_camera(stage_class: type[Stage]) -> None:
|
||||
"""Register a camera stage."""
|
||||
StageRegistry.register("camera", stage_class)
|
||||
37
engine/render/__init__.py
Normal file
37
engine/render/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Modern block rendering system - OTF font to terminal half-block conversion.
|
||||
|
||||
This module provides the core rendering capabilities for big block letters
|
||||
and styled text output using PIL fonts and ANSI terminal rendering.
|
||||
|
||||
Exports:
|
||||
- make_block: Render a headline into a content block with color
|
||||
- big_wrap: Word-wrap text and render with OTF font
|
||||
- render_line: Render a line of text as terminal rows using half-blocks
|
||||
- font_for_lang: Get appropriate font for a language
|
||||
- clear_font_cache: Reset cached font objects
|
||||
- lr_gradient: Color block characters with left-to-right gradient
|
||||
- lr_gradient_opposite: Complementary gradient coloring
|
||||
"""
|
||||
|
||||
from engine.render.blocks import (
|
||||
big_wrap,
|
||||
clear_font_cache,
|
||||
font_for_lang,
|
||||
list_font_faces,
|
||||
load_font_face,
|
||||
make_block,
|
||||
render_line,
|
||||
)
|
||||
from engine.render.gradient import lr_gradient, lr_gradient_opposite
|
||||
|
||||
__all__ = [
|
||||
"big_wrap",
|
||||
"clear_font_cache",
|
||||
"font_for_lang",
|
||||
"list_font_faces",
|
||||
"load_font_face",
|
||||
"lr_gradient",
|
||||
"lr_gradient_opposite",
|
||||
"make_block",
|
||||
"render_line",
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
OTF → terminal half-block rendering pipeline.
|
||||
Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly.
|
||||
Depends on: config, terminal, sources, translate.
|
||||
"""Block rendering core - Font loading, text rasterization, word-wrap, and headline assembly.
|
||||
|
||||
Provides PIL font-based rendering to terminal half-block characters.
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -12,41 +11,51 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from engine import config
|
||||
from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
||||
from engine.terminal import RST
|
||||
from engine.translate import detect_location_language, translate_headline
|
||||
|
||||
# ─── GRADIENT ─────────────────────────────────────────────
|
||||
# Left → right: white-hot leading edge fades to near-black
|
||||
GRAD_COLS = [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;195m", # pale cyan-white
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\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
|
||||
]
|
||||
def estimate_block_height(title: str, width: int, fnt=None) -> int:
|
||||
"""Estimate rendered block height without full PIL rendering.
|
||||
|
||||
Uses font bbox measurement to count wrapped lines, then computes:
|
||||
height = num_lines * RENDER_H + (num_lines - 1) + 2
|
||||
|
||||
Args:
|
||||
title: Headline text to measure
|
||||
width: Terminal width in characters
|
||||
fnt: Optional PIL font (uses default if None)
|
||||
|
||||
Returns:
|
||||
Estimated height in terminal rows
|
||||
"""
|
||||
if fnt is None:
|
||||
fnt = font()
|
||||
text = re.sub(r"\s+", " ", title.upper())
|
||||
words = text.split()
|
||||
lines = 0
|
||||
cur = ""
|
||||
for word in words:
|
||||
test = f"{cur} {word}".strip() if cur else word
|
||||
bbox = fnt.getbbox(test)
|
||||
if bbox:
|
||||
img_h = bbox[3] - bbox[1] + 8
|
||||
pix_h = config.RENDER_H * 2
|
||||
scale = pix_h / max(img_h, 1)
|
||||
term_w = int((bbox[2] - bbox[0] + 8) * scale)
|
||||
else:
|
||||
term_w = 0
|
||||
max_term_w = width - 4 - 4
|
||||
if term_w > max_term_w and cur:
|
||||
lines += 1
|
||||
cur = word
|
||||
else:
|
||||
cur = test
|
||||
if cur:
|
||||
lines += 1
|
||||
if lines == 0:
|
||||
lines = 1
|
||||
return lines * config.RENDER_H + max(0, lines - 1) + 2
|
||||
|
||||
|
||||
# ─── FONT LOADING ─────────────────────────────────────────
|
||||
_FONT_OBJ = None
|
||||
@@ -189,36 +198,22 @@ def big_wrap(text, max_w, fnt=None):
|
||||
return out
|
||||
|
||||
|
||||
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||
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:
|
||||
if not row.strip():
|
||||
out.append(row)
|
||||
continue
|
||||
buf = []
|
||||
for x, ch in enumerate(row):
|
||||
if ch == " ":
|
||||
buf.append(" ")
|
||||
else:
|
||||
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||
idx = min(round(shifted * (n - 1)), n - 1)
|
||||
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."""
|
||||
"""Render a headline into a content block with color.
|
||||
|
||||
Args:
|
||||
title: Headline text to render
|
||||
src: Source identifier (for metadata)
|
||||
ts: Timestamp string (for metadata)
|
||||
w: Width constraint in terminal characters
|
||||
|
||||
Returns:
|
||||
tuple: (content_lines, color_code, meta_row_index)
|
||||
- content_lines: List of rendered text lines
|
||||
- color_code: ANSI color code for display
|
||||
- meta_row_index: Row index of metadata line
|
||||
"""
|
||||
target_lang = (
|
||||
(SOURCE_LANGS.get(src) or detect_location_language(title))
|
||||
if config.MODE == "news"
|
||||
82
engine/render/gradient.py
Normal file
82
engine/render/gradient.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Gradient coloring for rendered block characters.
|
||||
|
||||
Provides left-to-right and complementary gradient effects for terminal display.
|
||||
"""
|
||||
|
||||
from engine.terminal import RST
|
||||
|
||||
# Left → right: white-hot leading edge fades to near-black
|
||||
GRAD_COLS = [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;195m", # pale cyan-white
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\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
|
||||
]
|
||||
|
||||
|
||||
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||
"""Color each non-space block character with a shifting left-to-right gradient.
|
||||
|
||||
Args:
|
||||
rows: List of text lines with block characters
|
||||
offset: Gradient offset (0.0-1.0) for animation
|
||||
grad_cols: List of ANSI color codes (default: GRAD_COLS)
|
||||
|
||||
Returns:
|
||||
List of lines with gradient coloring applied
|
||||
"""
|
||||
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:
|
||||
if not row.strip():
|
||||
out.append(row)
|
||||
continue
|
||||
buf = []
|
||||
for x, ch in enumerate(row):
|
||||
if ch == " ":
|
||||
buf.append(" ")
|
||||
else:
|
||||
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||
idx = min(round(shifted * (n - 1)), n - 1)
|
||||
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.
|
||||
|
||||
Args:
|
||||
rows: List of text lines with block characters
|
||||
offset: Gradient offset (0.0-1.0) for animation
|
||||
|
||||
Returns:
|
||||
List of lines with complementary gradient coloring applied
|
||||
"""
|
||||
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||
220
engine/scroll.py
220
engine/scroll.py
@@ -1,220 +0,0 @@
|
||||
"""
|
||||
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||
Depends on: config, terminal, render, effects, ntfy, mic.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.effects import (
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
next_headline,
|
||||
noise,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite, make_block
|
||||
from engine.terminal import CLR, RST, W_COOL, th, tw
|
||||
|
||||
|
||||
def stream(items, ntfy_poller, mic_monitor):
|
||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||
random.shuffle(items)
|
||||
pool = list(items)
|
||||
seen = set()
|
||||
queued = 0
|
||||
|
||||
time.sleep(0.5)
|
||||
sys.stdout.write(CLR)
|
||||
sys.stdout.flush()
|
||||
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh # reserve fixed firehose strip at bottom
|
||||
GAP = 3 # blank rows between headlines
|
||||
scroll_step_interval = config.SCROLL_DUR / (ticker_view_h + 15) * 2
|
||||
|
||||
# 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 = []
|
||||
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_motion_accum = 0.0
|
||||
|
||||
def _noise_at(cy):
|
||||
if cy not in noise_cache:
|
||||
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||
return noise_cache[cy]
|
||||
|
||||
# Message color: bright cyan/white — distinct from headline greens
|
||||
MSG_META = "\033[38;5;245m" # cool grey
|
||||
MSG_BORDER = "\033[2;38;5;37m" # dim teal
|
||||
_msg_cache = (None, None) # (cache_key, rendered_rows)
|
||||
|
||||
while queued < config.HEADLINE_LIMIT or active:
|
||||
t0 = time.monotonic()
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh
|
||||
|
||||
# ── Check for ntfy message ────────────────────────
|
||||
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 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)
|
||||
if _msg_cache[0] != cache_key:
|
||||
msg_rows = big_wrap(display_text, w - 4)
|
||||
_msg_cache = (cache_key, msg_rows)
|
||||
else:
|
||||
msg_rows = _msg_cache[1]
|
||||
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)
|
||||
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 = []
|
||||
if m_title and m_title != m_body:
|
||||
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]
|
||||
)
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}{RST}\033[K"
|
||||
)
|
||||
row_idx += 1
|
||||
# Border — constant boundary under message panel
|
||||
bar = "\u2500" * (w - 4)
|
||||
msg_overlay.append(
|
||||
f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}{RST}\033[K"
|
||||
)
|
||||
|
||||
# Ticker draws above the fixed firehose strip; message is a centered overlay.
|
||||
ticker_h = ticker_view_h - msg_h
|
||||
|
||||
# ── 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 (
|
||||
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||
and queued < config.HEADLINE_LIMIT
|
||||
):
|
||||
t, src, ts = next_headline(pool, items, seen)
|
||||
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) > scroll_cam
|
||||
]
|
||||
for k in list(noise_cache):
|
||||
if k < scroll_cam:
|
||||
del noise_cache[k]
|
||||
|
||||
# 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
|
||||
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, (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:
|
||||
cr = cy - by
|
||||
if 0 <= cr < len(content):
|
||||
raw = content[cr]
|
||||
if cr != midx:
|
||||
colored = lr_gradient([raw], grad_offset)[0]
|
||||
else:
|
||||
colored = raw
|
||||
ln = vis_trunc(colored, w)
|
||||
if row_fade < 1.0:
|
||||
ln = fade_line(ln, row_fade)
|
||||
if cr == midx:
|
||||
buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
|
||||
elif ln.strip():
|
||||
buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
|
||||
else:
|
||||
buf.append(f"\033[{scr_row};1H\033[K")
|
||||
drawn = True
|
||||
break
|
||||
if not drawn:
|
||||
n = _noise_at(cy)
|
||||
if row_fade < 1.0 and n:
|
||||
n = fade_line(n, row_fade)
|
||||
if n:
|
||||
buf.append(f"\033[{scr_row};1H{n}")
|
||||
else:
|
||||
buf.append(f"\033[{scr_row};1H\033[K")
|
||||
|
||||
# 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)
|
||||
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()
|
||||
|
||||
# Precise frame timing
|
||||
elapsed = time.monotonic() - t0
|
||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||
|
||||
sys.stdout.write(CLR)
|
||||
sys.stdout.flush()
|
||||
203
engine/sensors/__init__.py
Normal file
203
engine/sensors/__init__.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Sensor framework - PureData-style real-time input system.
|
||||
|
||||
Sensors are data sources that emit values over time, similar to how
|
||||
PureData objects emit signals. Effects can bind to sensors to modulate
|
||||
their parameters dynamically.
|
||||
|
||||
Architecture:
|
||||
- Sensor: Base class for all sensors (mic, camera, ntfy, OSC, etc.)
|
||||
- SensorRegistry: Global registry for sensor discovery
|
||||
- SensorStage: Pipeline stage wrapper for sensors
|
||||
- Effect param_bindings: Declarative sensor-to-param routing
|
||||
|
||||
Example:
|
||||
class GlitchEffect(EffectPlugin):
|
||||
param_bindings = {
|
||||
"intensity": {"sensor": "mic", "transform": "linear"},
|
||||
}
|
||||
|
||||
This binds the mic sensor to the glitch intensity parameter.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.core import PipelineContext
|
||||
|
||||
|
||||
@dataclass
|
||||
class SensorValue:
|
||||
"""A sensor reading with metadata."""
|
||||
|
||||
sensor_name: str
|
||||
value: float
|
||||
timestamp: float
|
||||
unit: str = ""
|
||||
|
||||
|
||||
class Sensor(ABC):
|
||||
"""Abstract base class for sensors.
|
||||
|
||||
Sensors are real-time data sources that emit values. They can be:
|
||||
- Physical: mic, camera, joystick, MIDI, OSC
|
||||
- Virtual: ntfy, timer, random, noise
|
||||
|
||||
Each sensor has a name and emits SensorValue objects.
|
||||
"""
|
||||
|
||||
name: str
|
||||
unit: str = ""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Whether the sensor is currently available."""
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def read(self) -> SensorValue | None:
|
||||
"""Read current sensor value.
|
||||
|
||||
Returns:
|
||||
SensorValue if available, None if sensor is not ready.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def start(self) -> bool:
|
||||
"""Start the sensor.
|
||||
|
||||
Returns:
|
||||
True if started successfully.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
"""Stop the sensor and release resources."""
|
||||
...
|
||||
|
||||
|
||||
class SensorRegistry:
|
||||
"""Global registry for sensors.
|
||||
|
||||
Provides:
|
||||
- Registration of sensor instances
|
||||
- Lookup by name
|
||||
- Global start/stop
|
||||
"""
|
||||
|
||||
_sensors: dict[str, Sensor] = {}
|
||||
_started: bool = False
|
||||
|
||||
@classmethod
|
||||
def register(cls, sensor: Sensor) -> None:
|
||||
"""Register a sensor instance."""
|
||||
cls._sensors[sensor.name] = sensor
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> Sensor | None:
|
||||
"""Get a sensor by name."""
|
||||
return cls._sensors.get(name)
|
||||
|
||||
@classmethod
|
||||
def list_sensors(cls) -> list[str]:
|
||||
"""List all registered sensor names."""
|
||||
return list(cls._sensors.keys())
|
||||
|
||||
@classmethod
|
||||
def start_all(cls) -> bool:
|
||||
"""Start all sensors.
|
||||
|
||||
Returns:
|
||||
True if all sensors started successfully.
|
||||
"""
|
||||
if cls._started:
|
||||
return True
|
||||
|
||||
all_started = True
|
||||
for sensor in cls._sensors.values():
|
||||
if sensor.available and not sensor.start():
|
||||
all_started = False
|
||||
|
||||
cls._started = all_started
|
||||
return all_started
|
||||
|
||||
@classmethod
|
||||
def stop_all(cls) -> None:
|
||||
"""Stop all sensors."""
|
||||
for sensor in cls._sensors.values():
|
||||
sensor.stop()
|
||||
cls._started = False
|
||||
|
||||
@classmethod
|
||||
def read_all(cls) -> dict[str, float]:
|
||||
"""Read all sensor values.
|
||||
|
||||
Returns:
|
||||
Dict mapping sensor name to current value.
|
||||
"""
|
||||
result = {}
|
||||
for name, sensor in cls._sensors.items():
|
||||
value = sensor.read()
|
||||
if value:
|
||||
result[name] = value.value
|
||||
return result
|
||||
|
||||
|
||||
class SensorStage:
|
||||
"""Pipeline stage wrapper for sensors.
|
||||
|
||||
Provides sensor data to the pipeline context.
|
||||
Sensors don't transform data - they inject sensor values into context.
|
||||
"""
|
||||
|
||||
def __init__(self, sensor: Sensor, name: str | None = None):
|
||||
self._sensor = sensor
|
||||
self.name = name or sensor.name
|
||||
self.category = "sensor"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "sensor"
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.ANY}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.ANY}
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"sensor.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
def init(self, ctx: "PipelineContext") -> bool:
|
||||
return self._sensor.start()
|
||||
|
||||
def process(self, data: Any, ctx: "PipelineContext") -> Any:
|
||||
value = self._sensor.read()
|
||||
if value:
|
||||
ctx.set_state(f"sensor.{self.name}", value.value)
|
||||
ctx.set_state(f"sensor.{self.name}.full", value)
|
||||
return data
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self._sensor.stop()
|
||||
|
||||
|
||||
def create_sensor_stage(sensor: Sensor, name: str | None = None) -> SensorStage:
|
||||
"""Create a pipeline stage from a sensor."""
|
||||
return SensorStage(sensor, name)
|
||||
145
engine/sensors/mic.py
Normal file
145
engine/sensors/mic.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Mic sensor - audio input as a pipeline sensor.
|
||||
|
||||
Self-contained implementation that handles audio input directly,
|
||||
with graceful degradation if sounddevice is unavailable.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
|
||||
_HAS_AUDIO = True
|
||||
except Exception:
|
||||
np = None # type: ignore
|
||||
sd = None # type: ignore
|
||||
_HAS_AUDIO = False
|
||||
|
||||
|
||||
from engine.events import MicLevelEvent
|
||||
from engine.sensors import Sensor, SensorRegistry, SensorValue
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioConfig:
|
||||
"""Configuration for audio input."""
|
||||
|
||||
threshold_db: float = 50.0
|
||||
sample_rate: float = 44100.0
|
||||
block_size: int = 1024
|
||||
|
||||
|
||||
class MicSensor(Sensor):
|
||||
"""Microphone sensor for pipeline integration.
|
||||
|
||||
Self-contained implementation with graceful degradation.
|
||||
No external dependencies required - works with or without sounddevice.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold_db: float = 50.0, name: str = "mic"):
|
||||
self.name = name
|
||||
self.unit = "dB"
|
||||
self._config = AudioConfig(threshold_db=threshold_db)
|
||||
self._db: float = -99.0
|
||||
self._stream: Any = None
|
||||
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Check if audio input is available."""
|
||||
return _HAS_AUDIO and self._stream is not None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the microphone stream."""
|
||||
if not _HAS_AUDIO or sd is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._stream = sd.InputStream(
|
||||
samplerate=self._config.sample_rate,
|
||||
blocksize=self._config.block_size,
|
||||
channels=1,
|
||||
callback=self._audio_callback,
|
||||
)
|
||||
self._stream.start()
|
||||
atexit.register(self.stop)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the microphone stream."""
|
||||
if self._stream:
|
||||
try:
|
||||
self._stream.stop()
|
||||
self._stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._stream = None
|
||||
|
||||
def _audio_callback(self, indata, frames, time_info, status) -> None:
|
||||
"""Process audio data from sounddevice."""
|
||||
if not _HAS_AUDIO or np is None:
|
||||
return
|
||||
|
||||
rms = np.sqrt(np.mean(indata**2))
|
||||
if rms > 0:
|
||||
db = 20 * np.log10(rms)
|
||||
else:
|
||||
db = -99.0
|
||||
|
||||
self._db = db
|
||||
|
||||
excess = max(0.0, db - self._config.threshold_db)
|
||||
event = MicLevelEvent(
|
||||
db_level=db, excess_above_threshold=excess, timestamp=datetime.now()
|
||||
)
|
||||
self._emit(event)
|
||||
|
||||
def _emit(self, event: MicLevelEvent) -> None:
|
||||
"""Emit event to all subscribers."""
|
||||
for callback in self._subscribers:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Subscribe to mic level events."""
|
||||
if callback not in self._subscribers:
|
||||
self._subscribers.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Unsubscribe from mic level events."""
|
||||
if callback in self._subscribers:
|
||||
self._subscribers.remove(callback)
|
||||
|
||||
def read(self) -> SensorValue | None:
|
||||
"""Read current mic level as sensor value."""
|
||||
if not self.available:
|
||||
return None
|
||||
|
||||
excess = max(0.0, self._db - self._config.threshold_db)
|
||||
return SensorValue(
|
||||
sensor_name=self.name,
|
||||
value=excess,
|
||||
timestamp=time.time(),
|
||||
unit=self.unit,
|
||||
)
|
||||
|
||||
|
||||
def register_mic_sensor() -> None:
|
||||
"""Register the mic sensor with the global registry."""
|
||||
sensor = MicSensor()
|
||||
SensorRegistry.register(sensor)
|
||||
|
||||
|
||||
# Auto-register when imported
|
||||
register_mic_sensor()
|
||||
161
engine/sensors/oscillator.py
Normal file
161
engine/sensors/oscillator.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Oscillator sensor - Modular synth-style oscillator as a pipeline sensor.
|
||||
|
||||
Provides various waveforms that can be:
|
||||
1. Self-driving (phase accumulates over time)
|
||||
2. Sensor-driven (phase modulated by external sensor)
|
||||
|
||||
Built-in waveforms:
|
||||
- sine: Pure sine wave
|
||||
- square: Square wave (0 to 1)
|
||||
- sawtooth: Rising sawtooth (0 to 1, wraps)
|
||||
- triangle: Triangle wave (0 to 1 to 0)
|
||||
- noise: Random values (0 to 1)
|
||||
|
||||
Example usage:
|
||||
osc = OscillatorSensor(waveform="sine", frequency=0.5)
|
||||
# Or driven by mic sensor:
|
||||
osc = OscillatorSensor(waveform="sine", frequency=1.0, input_sensor="mic")
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from enum import Enum
|
||||
|
||||
from engine.sensors import Sensor, SensorRegistry, SensorValue
|
||||
|
||||
|
||||
class Waveform(Enum):
|
||||
"""Built-in oscillator waveforms."""
|
||||
|
||||
SINE = "sine"
|
||||
SQUARE = "square"
|
||||
SAWTOOTH = "sawtooth"
|
||||
TRIANGLE = "triangle"
|
||||
NOISE = "noise"
|
||||
|
||||
|
||||
class OscillatorSensor(Sensor):
|
||||
"""Oscillator sensor that generates periodic or random values.
|
||||
|
||||
Can run in two modes:
|
||||
- Self-driving: phase accumulates based on frequency
|
||||
- Sensor-driven: phase modulated by external sensor value
|
||||
"""
|
||||
|
||||
WAVEFORMS = {
|
||||
"sine": lambda p: (math.sin(2 * math.pi * p) + 1) / 2,
|
||||
"square": lambda p: 1.0 if (p % 1.0) < 0.5 else 0.0,
|
||||
"sawtooth": lambda p: p % 1.0,
|
||||
"triangle": lambda p: 2 * abs(2 * (p % 1.0) - 1) - 1,
|
||||
"noise": lambda _: random.random(),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "osc",
|
||||
waveform: str = "sine",
|
||||
frequency: float = 1.0,
|
||||
input_sensor: str | None = None,
|
||||
input_scale: float = 1.0,
|
||||
):
|
||||
"""Initialize oscillator sensor.
|
||||
|
||||
Args:
|
||||
name: Sensor name
|
||||
waveform: Waveform type (sine, square, sawtooth, triangle, noise)
|
||||
frequency: Frequency in Hz (self-driving mode)
|
||||
input_sensor: Optional sensor name to drive phase
|
||||
input_scale: Scale factor for input sensor
|
||||
"""
|
||||
self.name = name
|
||||
self.unit = ""
|
||||
self._waveform = waveform
|
||||
self._frequency = frequency
|
||||
self._input_sensor = input_sensor
|
||||
self._input_scale = input_scale
|
||||
self._phase = 0.0
|
||||
self._start_time = time.time()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def waveform(self) -> str:
|
||||
return self._waveform
|
||||
|
||||
@waveform.setter
|
||||
def waveform(self, value: str) -> None:
|
||||
if value not in self.WAVEFORMS:
|
||||
raise ValueError(f"Unknown waveform: {value}")
|
||||
self._waveform = value
|
||||
|
||||
@property
|
||||
def frequency(self) -> float:
|
||||
return self._frequency
|
||||
|
||||
@frequency.setter
|
||||
def frequency(self, value: float) -> None:
|
||||
self._frequency = max(0.0, value)
|
||||
|
||||
def start(self) -> bool:
|
||||
self._phase = 0.0
|
||||
self._start_time = time.time()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
def _get_input_value(self) -> float:
|
||||
"""Get value from input sensor if configured."""
|
||||
if self._input_sensor:
|
||||
from engine.sensors import SensorRegistry
|
||||
|
||||
sensor = SensorRegistry.get(self._input_sensor)
|
||||
if sensor:
|
||||
reading = sensor.read()
|
||||
if reading:
|
||||
return reading.value * self._input_scale
|
||||
return 0.0
|
||||
|
||||
def read(self) -> SensorValue | None:
|
||||
current_time = time.time()
|
||||
elapsed = current_time - self._start_time
|
||||
|
||||
if self._input_sensor:
|
||||
input_val = self._get_input_value()
|
||||
phase_increment = (self._frequency * elapsed) + input_val
|
||||
else:
|
||||
phase_increment = self._frequency * elapsed
|
||||
|
||||
self._phase += phase_increment
|
||||
|
||||
waveform_fn = self.WAVEFORMS.get(self._waveform)
|
||||
if waveform_fn is None:
|
||||
return None
|
||||
|
||||
value = waveform_fn(self._phase)
|
||||
value = max(0.0, min(1.0, value))
|
||||
|
||||
return SensorValue(
|
||||
sensor_name=self.name,
|
||||
value=value,
|
||||
timestamp=current_time,
|
||||
unit=self.unit,
|
||||
)
|
||||
|
||||
def set_waveform(self, waveform: str) -> None:
|
||||
"""Change waveform at runtime."""
|
||||
self.waveform = waveform
|
||||
|
||||
def set_frequency(self, frequency: float) -> None:
|
||||
"""Change frequency at runtime."""
|
||||
self.frequency = frequency
|
||||
|
||||
|
||||
def register_oscillator_sensor(name: str = "osc", **kwargs) -> None:
|
||||
"""Register an oscillator sensor with the global registry."""
|
||||
sensor = OscillatorSensor(name=name, **kwargs)
|
||||
SensorRegistry.register(sensor)
|
||||
114
engine/sensors/pipeline_metrics.py
Normal file
114
engine/sensors/pipeline_metrics.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Pipeline metrics sensor - Exposes pipeline performance data as sensor values.
|
||||
|
||||
This sensor reads metrics from a Pipeline instance and provides them
|
||||
as sensor values that can drive effect parameters.
|
||||
|
||||
Example:
|
||||
sensor = PipelineMetricsSensor(pipeline)
|
||||
sensor.read() # Returns SensorValue with total_ms, fps, etc.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from engine.sensors import Sensor, SensorValue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.controller import Pipeline
|
||||
|
||||
|
||||
class PipelineMetricsSensor(Sensor):
|
||||
"""Sensor that reads metrics from a Pipeline instance.
|
||||
|
||||
Provides real-time performance data:
|
||||
- total_ms: Total frame time in milliseconds
|
||||
- fps: Calculated frames per second
|
||||
- stage_timings: Dict of stage name -> duration_ms
|
||||
|
||||
Can be bound to effect parameters for reactive visuals.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: "Pipeline | None" = None, name: str = "pipeline"):
|
||||
self._pipeline = pipeline
|
||||
self.name = name
|
||||
self.unit = "ms"
|
||||
self._last_values: dict[str, float] = {
|
||||
"total_ms": 0.0,
|
||||
"fps": 0.0,
|
||||
"avg_ms": 0.0,
|
||||
"min_ms": 0.0,
|
||||
"max_ms": 0.0,
|
||||
}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._pipeline is not None
|
||||
|
||||
def set_pipeline(self, pipeline: "Pipeline") -> None:
|
||||
"""Set or update the pipeline to read metrics from."""
|
||||
self._pipeline = pipeline
|
||||
|
||||
def read(self) -> SensorValue | None:
|
||||
"""Read current metrics from the pipeline."""
|
||||
if not self._pipeline:
|
||||
return None
|
||||
|
||||
try:
|
||||
metrics = self._pipeline.get_metrics_summary()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not metrics or "error" in metrics:
|
||||
return None
|
||||
|
||||
self._last_values["total_ms"] = metrics.get("total_ms", 0.0)
|
||||
self._last_values["fps"] = metrics.get("fps", 0.0)
|
||||
self._last_values["avg_ms"] = metrics.get("avg_ms", 0.0)
|
||||
self._last_values["min_ms"] = metrics.get("min_ms", 0.0)
|
||||
self._last_values["max_ms"] = metrics.get("max_ms", 0.0)
|
||||
|
||||
# Provide total_ms as primary value (for LFO-style effects)
|
||||
return SensorValue(
|
||||
sensor_name=self.name,
|
||||
value=self._last_values["total_ms"],
|
||||
timestamp=0.0,
|
||||
unit=self.unit,
|
||||
)
|
||||
|
||||
def get_stage_timing(self, stage_name: str) -> float:
|
||||
"""Get timing for a specific stage."""
|
||||
if not self._pipeline:
|
||||
return 0.0
|
||||
try:
|
||||
metrics = self._pipeline.get_metrics_summary()
|
||||
stages = metrics.get("stages", {})
|
||||
return stages.get(stage_name, {}).get("avg_ms", 0.0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_all_timings(self) -> dict[str, float]:
|
||||
"""Get all stage timings as a dict."""
|
||||
if not self._pipeline:
|
||||
return {}
|
||||
try:
|
||||
metrics = self._pipeline.get_metrics_summary()
|
||||
return metrics.get("stages", {})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_frame_history(self) -> list[float]:
|
||||
"""Get historical frame times for sparklines."""
|
||||
if not self._pipeline:
|
||||
return []
|
||||
try:
|
||||
return self._pipeline.get_frame_times()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the sensor (no-op for read-only metrics)."""
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the sensor (no-op for read-only metrics)."""
|
||||
pass
|
||||
@@ -7,26 +7,16 @@ import json
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from functools import lru_cache
|
||||
|
||||
from engine.sources import LOCATION_LANGS
|
||||
|
||||
_TRANSLATE_CACHE = {}
|
||||
TRANSLATE_CACHE_SIZE = 500
|
||||
|
||||
|
||||
def detect_location_language(title):
|
||||
"""Detect if headline mentions a location, return target language."""
|
||||
title_lower = title.lower()
|
||||
for pattern, lang in LOCATION_LANGS.items():
|
||||
if re.search(pattern, title_lower):
|
||||
return lang
|
||||
return None
|
||||
|
||||
|
||||
def translate_headline(title, target_lang):
|
||||
"""Translate headline via Google Translate API (zero dependencies)."""
|
||||
key = (title, target_lang)
|
||||
if key in _TRANSLATE_CACHE:
|
||||
return _TRANSLATE_CACHE[key]
|
||||
@lru_cache(maxsize=TRANSLATE_CACHE_SIZE)
|
||||
def _translate_cached(title: str, target_lang: str) -> str:
|
||||
"""Cached translation implementation."""
|
||||
try:
|
||||
q = urllib.parse.quote(title)
|
||||
url = (
|
||||
@@ -39,5 +29,18 @@ def translate_headline(title, target_lang):
|
||||
result = "".join(p[0] for p in data[0] if p[0]) or title
|
||||
except Exception:
|
||||
result = title
|
||||
_TRANSLATE_CACHE[key] = result
|
||||
return result
|
||||
|
||||
|
||||
def detect_location_language(title):
|
||||
"""Detect if headline mentions a location, return target language."""
|
||||
title_lower = title.lower()
|
||||
for pattern, lang in LOCATION_LANGS.items():
|
||||
if re.search(pattern, title_lower):
|
||||
return lang
|
||||
return None
|
||||
|
||||
|
||||
def translate_headline(title: str, target_lang: str) -> str:
|
||||
"""Translate headline via Google Translate API (zero dependencies)."""
|
||||
return _translate_cached(title, target_lang)
|
||||
|
||||
60
engine/types.py
Normal file
60
engine/types.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Shared dataclasses for the mainline application.
|
||||
Provides named types for tuple returns across modules.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeadlineItem:
|
||||
"""A single headline item: title, source, and timestamp."""
|
||||
|
||||
title: str
|
||||
source: str
|
||||
timestamp: str
|
||||
|
||||
def to_tuple(self) -> tuple[str, str, str]:
|
||||
"""Convert to tuple for backward compatibility."""
|
||||
return (self.title, self.source, self.timestamp)
|
||||
|
||||
@classmethod
|
||||
def from_tuple(cls, t: tuple[str, str, str]) -> "HeadlineItem":
|
||||
"""Create from tuple for backward compatibility."""
|
||||
return cls(title=t[0], source=t[1], timestamp=t[2])
|
||||
|
||||
|
||||
def items_to_tuples(items: list[HeadlineItem]) -> list[tuple[str, str, str]]:
|
||||
"""Convert list of HeadlineItem to list of tuples."""
|
||||
return [item.to_tuple() for item in items]
|
||||
|
||||
|
||||
def tuples_to_items(tuples: list[tuple[str, str, str]]) -> list[HeadlineItem]:
|
||||
"""Convert list of tuples to list of HeadlineItem."""
|
||||
return [HeadlineItem.from_tuple(t) for t in tuples]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FetchResult:
|
||||
"""Result from fetch_all() or fetch_poetry()."""
|
||||
|
||||
items: list[HeadlineItem]
|
||||
linked: int
|
||||
failed: int
|
||||
|
||||
def to_legacy_tuple(self) -> tuple[list[tuple], int, int]:
|
||||
"""Convert to legacy tuple format for backward compatibility."""
|
||||
return ([item.to_tuple() for item in self.items], self.linked, self.failed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Block:
|
||||
"""Rendered headline block from make_block()."""
|
||||
|
||||
content: list[str]
|
||||
color: str
|
||||
meta_row_index: int
|
||||
|
||||
def to_legacy_tuple(self) -> tuple[list[str], str, int]:
|
||||
"""Convert to legacy tuple format for backward compatibility."""
|
||||
return (self.content, self.color, self.meta_row_index)
|
||||
37
engine/viewport.py
Normal file
37
engine/viewport.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Viewport utilities — terminal dimensions and ANSI positioning helpers.
|
||||
No internal dependencies.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def tw() -> int:
|
||||
"""Get terminal width (columns)."""
|
||||
try:
|
||||
return os.get_terminal_size().columns
|
||||
except Exception:
|
||||
return 80
|
||||
|
||||
|
||||
def th() -> int:
|
||||
"""Get terminal height (lines)."""
|
||||
try:
|
||||
return os.get_terminal_size().lines
|
||||
except Exception:
|
||||
return 24
|
||||
|
||||
|
||||
def move_to(row: int, col: int = 1) -> str:
|
||||
"""Generate ANSI escape to move cursor to row, col (1-indexed)."""
|
||||
return f"\033[{row};{col}H"
|
||||
|
||||
|
||||
def clear_screen() -> str:
|
||||
"""Clear screen and move cursor to home."""
|
||||
return "\033[2J\033[H"
|
||||
|
||||
|
||||
def clear_line() -> str:
|
||||
"""Clear current line."""
|
||||
return "\033[K"
|
||||
3
hk.pkl
3
hk.pkl
@@ -22,6 +22,9 @@ hooks {
|
||||
prefix = "uv run"
|
||||
check = "ruff check engine/ tests/"
|
||||
}
|
||||
["benchmark"] {
|
||||
check = "uv run python -m engine.benchmark --hook --displays null --iterations 20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
mise.toml
65
mise.toml
@@ -2,28 +2,39 @@
|
||||
python = "3.12"
|
||||
hk = "latest"
|
||||
pkl = "latest"
|
||||
uv = "latest"
|
||||
|
||||
[tasks]
|
||||
# =====================
|
||||
# Development
|
||||
# Core
|
||||
# =====================
|
||||
|
||||
test = "uv run pytest"
|
||||
test-v = "uv run pytest -v"
|
||||
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
||||
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
||||
|
||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing", depends = ["sync-all"] }
|
||||
lint = "uv run ruff check engine/ mainline.py"
|
||||
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||
format = "uv run ruff format engine/ mainline.py"
|
||||
|
||||
# =====================
|
||||
# Runtime
|
||||
# Run
|
||||
# =====================
|
||||
|
||||
run = "uv run mainline.py"
|
||||
run-poetry = "uv run mainline.py --poetry"
|
||||
run-firehose = "uv run mainline.py --firehose"
|
||||
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
|
||||
run-terminal = { run = "uv run mainline.py --display terminal", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Presets
|
||||
# =====================
|
||||
|
||||
run-demo = { run = "uv run mainline.py --preset demo --display pygame", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Daemon
|
||||
# =====================
|
||||
|
||||
daemon = "nohup uv run mainline.py > nohup.out 2>&1 &"
|
||||
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
|
||||
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
||||
|
||||
# =====================
|
||||
# Environment
|
||||
@@ -31,22 +42,38 @@ run-firehose = "uv run mainline.py --firehose"
|
||||
|
||||
sync = "uv sync"
|
||||
sync-all = "uv sync --all-extras"
|
||||
install = "uv sync"
|
||||
install-dev = "uv sync --group dev"
|
||||
|
||||
bootstrap = "uv sync && uv run mainline.py --help"
|
||||
|
||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
||||
install = "mise run sync"
|
||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||
|
||||
# =====================
|
||||
# CI/CD
|
||||
# CI
|
||||
# =====================
|
||||
|
||||
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
||||
ci-lint = "uv run ruff check engine/ mainline.py"
|
||||
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
||||
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
||||
|
||||
# =====================
|
||||
# Git Hooks (via hk)
|
||||
# Hooks
|
||||
# =====================
|
||||
|
||||
pre-commit = "hk run pre-commit"
|
||||
|
||||
# =====================
|
||||
# Diagrams
|
||||
# =====================
|
||||
|
||||
# Render Mermaid diagrams to ASCII art
|
||||
diagram-ascii = "python3 scripts/render-diagrams.py docs/ARCHITECTURE.md"
|
||||
|
||||
# Validate Mermaid syntax in docs (check all diagrams parse)
|
||||
# Note: classDiagram not supported by mermaid-ascii but works in GitHub/GitLab
|
||||
diagram-validate = """
|
||||
python3 scripts/validate-diagrams.py
|
||||
"""
|
||||
|
||||
# Render diagrams and check they match expected output
|
||||
diagram-check = "mise run diagram-validate"
|
||||
|
||||
[env]
|
||||
KAGI_API_KEY = "lOp6AGyX6TUB0kGzAli1BlAx5-VjlIN1OPCPYEXDdQc.FOKLieOa7NgWUUZi4mTZvHmrW2uNnOr8hfgv7jMvRQM"
|
||||
|
||||
336
presets.toml
Normal file
336
presets.toml
Normal file
@@ -0,0 +1,336 @@
|
||||
# Mainline Presets Configuration
|
||||
# Human- and machine-readable preset definitions
|
||||
#
|
||||
# Format: TOML
|
||||
# Usage: mainline --preset <name>
|
||||
#
|
||||
# Built-in presets can be overridden by user presets in:
|
||||
# - ~/.config/mainline/presets.toml
|
||||
# - ./presets.toml (local override)
|
||||
|
||||
# ============================================
|
||||
# TEST PRESETS
|
||||
# ============================================
|
||||
|
||||
[presets.test-single-item]
|
||||
description = "Test: Single item to isolate rendering stage issues"
|
||||
source = "empty"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = []
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.test-single-item-border]
|
||||
description = "Test: Single item with border effect only"
|
||||
source = "empty"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = ["border"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.test-headlines]
|
||||
description = "Test: Headlines from cache with border effect"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = ["border"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.test-headlines-noise]
|
||||
description = "Test: Headlines from cache with noise effect"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.test-demo-effects]
|
||||
description = "Test: All demo effects with terminal display"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = ["noise", "fade", "firehose"]
|
||||
camera_speed = 0.3
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# DATA SOURCE GALLERY
|
||||
# ============================================
|
||||
|
||||
[presets.gallery-sources]
|
||||
description = "Gallery: Headlines data source"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = []
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-sources-poetry]
|
||||
description = "Gallery: Poetry data source"
|
||||
source = "poetry"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["fade"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-sources-pipeline]
|
||||
description = "Gallery: Pipeline introspection"
|
||||
source = "pipeline-inspect"
|
||||
display = "pygame"
|
||||
camera = "scroll"
|
||||
effects = []
|
||||
camera_speed = 0.3
|
||||
viewport_width = 100
|
||||
viewport_height = 35
|
||||
|
||||
[presets.gallery-sources-empty]
|
||||
description = "Gallery: Empty source (for border tests)"
|
||||
source = "empty"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = ["border"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# EFFECT GALLERY
|
||||
# ============================================
|
||||
|
||||
[presets.gallery-effect-noise]
|
||||
description = "Gallery: Noise effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-fade]
|
||||
description = "Gallery: Fade effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["fade"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-glitch]
|
||||
description = "Gallery: Glitch effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["glitch"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-firehose]
|
||||
description = "Gallery: Firehose effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["firehose"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-hud]
|
||||
description = "Gallery: HUD effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["hud"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-tint]
|
||||
description = "Gallery: Tint effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["tint"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-border]
|
||||
description = "Gallery: Border effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["border"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-effect-crop]
|
||||
description = "Gallery: Crop effect"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["crop"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# CAMERA GALLERY
|
||||
# ============================================
|
||||
|
||||
[presets.gallery-camera-feed]
|
||||
description = "Gallery: Feed camera (rapid single-item)"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 1.0
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-scroll]
|
||||
description = "Gallery: Scroll camera (smooth)"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "scroll"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.3
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-horizontal]
|
||||
description = "Gallery: Horizontal camera"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "horizontal"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.5
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-omni]
|
||||
description = "Gallery: Omni camera"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "omni"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.5
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-floating]
|
||||
description = "Gallery: Floating camera"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "floating"
|
||||
effects = ["noise"]
|
||||
camera_speed = 1.0
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-camera-bounce]
|
||||
description = "Gallery: Bounce camera"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "bounce"
|
||||
effects = ["noise"]
|
||||
camera_speed = 1.0
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# DISPLAY GALLERY
|
||||
# ============================================
|
||||
|
||||
[presets.gallery-display-terminal]
|
||||
description = "Gallery: Terminal display"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-display-pygame]
|
||||
description = "Gallery: Pygame display"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-display-websocket]
|
||||
description = "Gallery: WebSocket display"
|
||||
source = "headlines"
|
||||
display = "websocket"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-display-multi]
|
||||
description = "Gallery: MultiDisplay (terminal + pygame)"
|
||||
source = "headlines"
|
||||
display = "multi:terminal,pygame"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# SENSOR CONFIGURATION
|
||||
# ============================================
|
||||
|
||||
[sensors.mic]
|
||||
enabled = false
|
||||
threshold_db = 50.0
|
||||
|
||||
[sensors.oscillator]
|
||||
enabled = false
|
||||
waveform = "sine"
|
||||
frequency = 1.0
|
||||
|
||||
# ============================================
|
||||
# EFFECT CONFIGURATIONS
|
||||
# ============================================
|
||||
|
||||
[effect_configs.noise]
|
||||
enabled = true
|
||||
intensity = 1.0
|
||||
|
||||
[effect_configs.fade]
|
||||
enabled = true
|
||||
intensity = 1.0
|
||||
|
||||
[effect_configs.glitch]
|
||||
enabled = true
|
||||
intensity = 0.5
|
||||
|
||||
[effect_configs.firehose]
|
||||
enabled = true
|
||||
intensity = 1.0
|
||||
|
||||
[effect_configs.hud]
|
||||
enabled = true
|
||||
intensity = 1.0
|
||||
@@ -22,6 +22,8 @@ classifiers = [
|
||||
dependencies = [
|
||||
"feedparser>=6.0.0",
|
||||
"Pillow>=10.0.0",
|
||||
"pyright>=1.1.408",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -29,8 +31,21 @@ mic = [
|
||||
"sounddevice>=0.4.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
websocket = [
|
||||
"websockets>=12.0",
|
||||
]
|
||||
sixel = [
|
||||
"Pillow>=10.0.0",
|
||||
]
|
||||
pygame = [
|
||||
"pygame>=2.0.0",
|
||||
]
|
||||
browser = [
|
||||
"playwright>=1.40.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-benchmark>=4.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"ruff>=0.1.0",
|
||||
@@ -46,6 +61,7 @@ build-backend = "hatchling.build"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-benchmark>=4.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"ruff>=0.1.0",
|
||||
@@ -60,6 +76,12 @@ addopts = [
|
||||
"--tb=short",
|
||||
"-v",
|
||||
]
|
||||
markers = [
|
||||
"benchmark: marks tests as performance benchmarks (may be slow)",
|
||||
"e2e: marks tests as end-to-end tests (require network/display)",
|
||||
"integration: marks tests as integration tests (require external services)",
|
||||
"ntfy: marks tests that require ntfy service",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
pytest>=8.0.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-mock>=3.12.0
|
||||
ruff>=0.1.0
|
||||
@@ -1,4 +0,0 @@
|
||||
feedparser>=6.0.0
|
||||
Pillow>=10.0.0
|
||||
sounddevice>=0.4.0
|
||||
numpy>=1.24.0
|
||||
49
scripts/render-diagrams.py
Normal file
49
scripts/render-diagrams.py
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render Mermaid diagrams in markdown files to ASCII art."""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def extract_mermaid_blocks(content: str) -> list[str]:
|
||||
"""Extract mermaid blocks from markdown."""
|
||||
return re.findall(r"```mermaid\n(.*?)\n```", content, re.DOTALL)
|
||||
|
||||
|
||||
def render_diagram(block: str) -> str:
|
||||
"""Render a single mermaid block to ASCII."""
|
||||
result = subprocess.run(
|
||||
["mermaid-ascii", "-f", "-"],
|
||||
input=block,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return f"ERROR: {result.stderr}"
|
||||
return result.stdout
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: render-diagrams.py <markdown-file>")
|
||||
sys.exit(1)
|
||||
|
||||
filename = sys.argv[1]
|
||||
content = open(filename).read()
|
||||
blocks = extract_mermaid_blocks(content)
|
||||
|
||||
print(f"Found {len(blocks)} mermaid diagram(s) in {filename}")
|
||||
print()
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
# Skip if empty
|
||||
if not block.strip():
|
||||
continue
|
||||
|
||||
print(f"=== Diagram {i + 1} ===")
|
||||
print(render_diagram(block))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
scripts/validate-diagrams.py
Normal file
64
scripts/validate-diagrams.py
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate Mermaid diagrams in markdown files."""
|
||||
|
||||
import glob
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
# Diagram types that are valid in Mermaid
|
||||
VALID_TYPES = {
|
||||
"flowchart",
|
||||
"graph",
|
||||
"classDiagram",
|
||||
"sequenceDiagram",
|
||||
"stateDiagram",
|
||||
"stateDiagram-v2",
|
||||
"erDiagram",
|
||||
"gantt",
|
||||
"pie",
|
||||
"mindmap",
|
||||
"journey",
|
||||
"gitGraph",
|
||||
"requirementDiagram",
|
||||
}
|
||||
|
||||
|
||||
def extract_mermaid_blocks(content: str) -> list[tuple[int, str]]:
|
||||
"""Extract mermaid blocks with their positions."""
|
||||
blocks = []
|
||||
for match in re.finditer(r"```mermaid\n(.*?)\n```", content, re.DOTALL):
|
||||
blocks.append((match.start(), match.group(1)))
|
||||
return blocks
|
||||
|
||||
|
||||
def validate_block(block: str) -> bool:
|
||||
"""Check if a mermaid block has a valid diagram type."""
|
||||
if not block.strip():
|
||||
return True # Empty block is OK
|
||||
first_line = block.strip().split("\n")[0]
|
||||
return any(first_line.startswith(t) for t in VALID_TYPES)
|
||||
|
||||
|
||||
def main():
|
||||
md_files = glob.glob("docs/*.md")
|
||||
|
||||
errors = []
|
||||
for filepath in md_files:
|
||||
content = open(filepath).read()
|
||||
blocks = extract_mermaid_blocks(content)
|
||||
|
||||
for i, (_, block) in enumerate(blocks):
|
||||
if not validate_block(block):
|
||||
errors.append(f"{filepath}: invalid diagram type in block {i + 1}")
|
||||
|
||||
if errors:
|
||||
for e in errors:
|
||||
print(f"ERROR: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Validated {len(md_files)} markdown files - all OK")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
36
tests/conftest.py
Normal file
36
tests/conftest.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Pytest configuration for mainline.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest to skip integration tests by default."""
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"integration: marks tests as integration tests (require external services)",
|
||||
)
|
||||
config.addinivalue_line("markers", "ntfy: marks tests that require ntfy service")
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
"""Skip integration/e2e tests unless explicitly requested with -m."""
|
||||
# Get the current marker expression
|
||||
marker_expr = config.getoption("-m", default="")
|
||||
|
||||
# If explicitly running integration or e2e, don't skip them
|
||||
if marker_expr in ("integration", "e2e", "integration or e2e"):
|
||||
return
|
||||
|
||||
# Skip integration tests
|
||||
skip_integration = pytest.mark.skip(reason="need -m integration to run")
|
||||
for item in items:
|
||||
if "integration" in item.keywords:
|
||||
item.add_marker(skip_integration)
|
||||
|
||||
# Skip e2e tests by default (they require browser/display)
|
||||
skip_e2e = pytest.mark.skip(reason="need -m e2e to run")
|
||||
for item in items:
|
||||
if "e2e" in item.keywords and "integration" not in item.keywords:
|
||||
item.add_marker(skip_e2e)
|
||||
133
tests/e2e/test_web_client.py
Normal file
133
tests/e2e/test_web_client.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
End-to-end tests for web client with headless browser.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socketserver
|
||||
import threading
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
CLIENT_DIR = Path(__file__).parent.parent.parent / "client"
|
||||
|
||||
|
||||
class ThreadedHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
|
||||
"""Threaded HTTP server for handling concurrent requests."""
|
||||
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def http_server():
|
||||
"""Start a local HTTP server for the client."""
|
||||
os.chdir(CLIENT_DIR)
|
||||
|
||||
handler = SimpleHTTPRequestHandler
|
||||
server = ThreadedHTTPServer(("127.0.0.1", 0), handler)
|
||||
port = server.server_address[1]
|
||||
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
|
||||
server.shutdown()
|
||||
|
||||
|
||||
class TestWebClient:
|
||||
"""Tests for the web client using Playwright."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_browser(self):
|
||||
"""Set up browser for tests."""
|
||||
pytest.importorskip("playwright")
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self.playwright = sync_playwright().start()
|
||||
self.browser = self.playwright.chromium.launch(headless=True)
|
||||
self.context = self.browser.new_context()
|
||||
self.page = self.context.new_page()
|
||||
|
||||
yield
|
||||
|
||||
self.page.close()
|
||||
self.context.close()
|
||||
self.browser.close()
|
||||
self.playwright.stop()
|
||||
|
||||
def test_client_loads(self, http_server):
|
||||
"""Web client loads without errors."""
|
||||
response = self.page.goto(http_server)
|
||||
assert response.status == 200, f"Page load failed with status {response.status}"
|
||||
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
content = self.page.content()
|
||||
assert "<canvas" in content, "Canvas element not found in page"
|
||||
|
||||
canvas = self.page.locator("#terminal")
|
||||
assert canvas.count() > 0, "Canvas not found"
|
||||
|
||||
def test_status_shows_connecting(self, http_server):
|
||||
"""Status shows connecting initially."""
|
||||
self.page.goto(http_server)
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
status = self.page.locator("#status")
|
||||
assert status.count() > 0, "Status element not found"
|
||||
|
||||
def test_canvas_has_dimensions(self, http_server):
|
||||
"""Canvas has correct dimensions after load."""
|
||||
self.page.goto(http_server)
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
canvas = self.page.locator("#terminal")
|
||||
assert canvas.count() > 0, "Canvas not found"
|
||||
|
||||
def test_no_console_errors_on_load(self, http_server):
|
||||
"""No JavaScript errors on page load (websocket errors are expected without server)."""
|
||||
js_errors = []
|
||||
|
||||
def handle_console(msg):
|
||||
if msg.type == "error":
|
||||
text = msg.text
|
||||
if "WebSocket" not in text:
|
||||
js_errors.append(text)
|
||||
|
||||
self.page.on("console", handle_console)
|
||||
self.page.goto(http_server)
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
assert len(js_errors) == 0, f"JavaScript errors: {js_errors}"
|
||||
|
||||
|
||||
class TestWebClientProtocol:
|
||||
"""Tests for WebSocket protocol handling in client."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_browser(self):
|
||||
"""Set up browser for tests."""
|
||||
pytest.importorskip("playwright")
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self.playwright = sync_playwright().start()
|
||||
self.browser = self.playwright.chromium.launch(headless=True)
|
||||
self.context = self.browser.new_context()
|
||||
self.page = self.context.new_page()
|
||||
|
||||
yield
|
||||
|
||||
self.page.close()
|
||||
self.context.close()
|
||||
self.browser.close()
|
||||
self.playwright.stop()
|
||||
|
||||
def test_websocket_reconnection(self, http_server):
|
||||
"""Client attempts reconnection on disconnect."""
|
||||
self.page.goto(http_server)
|
||||
self.page.wait_for_load_state("domcontentloaded")
|
||||
|
||||
status = self.page.locator("#status")
|
||||
assert status.count() > 0, "Status element not found"
|
||||
236
tests/fixtures/__init__.py
vendored
Normal file
236
tests/fixtures/__init__.py
vendored
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Pytest fixtures for mocking external dependencies (network, filesystem).
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_feed_response():
|
||||
"""Mock RSS feed response data."""
|
||||
return b"""<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Test Feed</title>
|
||||
<link>https://example.com</link>
|
||||
<item>
|
||||
<title>Test Headline One</title>
|
||||
<pubDate>Sat, 15 Mar 2025 12:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Test Headline Two</title>
|
||||
<pubDate>Sat, 15 Mar 2025 11:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Sports: Team Wins Championship</title>
|
||||
<pubDate>Sat, 15 Mar 2025 10:00:00 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gutenberg_response():
|
||||
"""Mock Project Gutenberg text response."""
|
||||
return """Project Gutenberg's Collection, by Various
|
||||
|
||||
*** START OF SOME TEXT ***
|
||||
This is a test poem with multiple lines
|
||||
that should be parsed as stanzas.
|
||||
|
||||
Another stanza here with different content
|
||||
and more lines to test the parsing logic.
|
||||
|
||||
Yet another stanza for variety
|
||||
in the test data.
|
||||
|
||||
*** END OF SOME TEXT ***"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_gutenberg_empty():
|
||||
"""Mock Gutenberg response with no valid stanzas."""
|
||||
return """Project Gutenberg's Collection
|
||||
|
||||
*** START OF TEXT ***
|
||||
THIS IS ALL CAPS AND SHOULD BE SKIPPED
|
||||
|
||||
I.
|
||||
|
||||
*** END OF TEXT ***"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ntfy_message():
|
||||
"""Mock ntfy.sh SSE message."""
|
||||
return json.dumps(
|
||||
{
|
||||
"id": "test123",
|
||||
"event": "message",
|
||||
"title": "Test Title",
|
||||
"message": "Test message body",
|
||||
"time": 1234567890,
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ntfy_keepalive():
|
||||
"""Mock ntfy.sh keepalive message."""
|
||||
return b'data: {"event":"keepalive"}\n\n'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_google_translate_response():
|
||||
"""Mock Google Translate API response."""
|
||||
return json.dumps(
|
||||
[
|
||||
[["Translated text", "Original text", None, 0.8], None, "en"],
|
||||
None,
|
||||
None,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_feedparser():
|
||||
"""Create a mock feedparser.parse function."""
|
||||
|
||||
def _mock(data):
|
||||
mock_result = MagicMock()
|
||||
mock_result.bozo = False
|
||||
mock_result.entries = [
|
||||
{
|
||||
"title": "Test Headline",
|
||||
"published_parsed": (2025, 3, 15, 12, 0, 0, 0, 0, 0),
|
||||
},
|
||||
{
|
||||
"title": "Another Headline",
|
||||
"updated_parsed": (2025, 3, 15, 11, 0, 0, 0, 0, 0),
|
||||
},
|
||||
]
|
||||
return mock_result
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_urllib_open(mock_feed_response):
|
||||
"""Create a mock urllib.request.urlopen that returns feed data."""
|
||||
|
||||
def _mock(url):
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = mock_feed_response
|
||||
return mock_response
|
||||
|
||||
return _mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_items():
|
||||
"""Sample items as returned by fetch module (title, source, timestamp)."""
|
||||
return [
|
||||
("Headline One", "Test Source", "12:00"),
|
||||
("Headline Two", "Another Source", "11:30"),
|
||||
("Headline Three", "Third Source", "10:45"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_config():
|
||||
"""Sample config for testing."""
|
||||
from engine.config import Config
|
||||
|
||||
return Config(
|
||||
headline_limit=100,
|
||||
feed_timeout=10,
|
||||
mic_threshold_db=50,
|
||||
mode="news",
|
||||
firehose=False,
|
||||
ntfy_topic="https://ntfy.sh/test/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir="fonts",
|
||||
font_path="",
|
||||
font_index=0,
|
||||
font_picker=False,
|
||||
font_sz=60,
|
||||
render_h=8,
|
||||
ssaa=4,
|
||||
scroll_dur=5.625,
|
||||
frame_dt=0.05,
|
||||
firehose_h=12,
|
||||
grad_speed=0.08,
|
||||
glitch_glyphs="░▒▓█▌▐",
|
||||
kata_glyphs="ハミヒーウ",
|
||||
script_fonts={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def poetry_config():
|
||||
"""Sample config for poetry mode."""
|
||||
from engine.config import Config
|
||||
|
||||
return Config(
|
||||
headline_limit=100,
|
||||
feed_timeout=10,
|
||||
mic_threshold_db=50,
|
||||
mode="poetry",
|
||||
firehose=False,
|
||||
ntfy_topic="https://ntfy.sh/test/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir="fonts",
|
||||
font_path="",
|
||||
font_index=0,
|
||||
font_picker=False,
|
||||
font_sz=60,
|
||||
render_h=8,
|
||||
ssaa=4,
|
||||
scroll_dur=5.625,
|
||||
frame_dt=0.05,
|
||||
firehose_h=12,
|
||||
grad_speed=0.08,
|
||||
glitch_glyphs="░▒▓█▌▐",
|
||||
kata_glyphs="ハミヒーウ",
|
||||
script_fonts={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def firehose_config():
|
||||
"""Sample config with firehose enabled."""
|
||||
from engine.config import Config
|
||||
|
||||
return Config(
|
||||
headline_limit=100,
|
||||
feed_timeout=10,
|
||||
mic_threshold_db=50,
|
||||
mode="news",
|
||||
firehose=True,
|
||||
ntfy_topic="https://ntfy.sh/test/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir="fonts",
|
||||
font_path="",
|
||||
font_index=0,
|
||||
font_picker=False,
|
||||
font_sz=60,
|
||||
render_h=8,
|
||||
ssaa=4,
|
||||
scroll_dur=5.625,
|
||||
frame_dt=0.05,
|
||||
firehose_h=12,
|
||||
grad_speed=0.08,
|
||||
glitch_glyphs="░▒▓█▌▐",
|
||||
kata_glyphs="ハミヒーウ",
|
||||
script_fonts={},
|
||||
)
|
||||
31
tests/kitty_test.py
Normal file
31
tests/kitty_test.py
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script for Kitty graphics display."""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def test_kitty_simple():
|
||||
"""Test simple Kitty graphics output with embedded PNG."""
|
||||
import base64
|
||||
|
||||
# Minimal 1x1 red pixel PNG (pre-encoded)
|
||||
# This is a tiny valid PNG with a red pixel
|
||||
png_red_1x1 = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00"
|
||||
b"\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde"
|
||||
b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00"
|
||||
b"\x03\x00\x01\x00\x05\xfe\xd4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
encoded = base64.b64encode(png_red_1x1).decode("ascii")
|
||||
|
||||
graphic = f"\x1b_Gf=100,t=d,s=1,v=1,c=1,r=1;{encoded}\x1b\\"
|
||||
sys.stdout.buffer.write(graphic.encode("utf-8"))
|
||||
sys.stdout.flush()
|
||||
|
||||
print("\n[If you see a red dot above, Kitty graphics is working!]")
|
||||
print("[If you see nothing or garbage, it's not working]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_kitty_simple()
|
||||
345
tests/test_adapters.py
Normal file
345
tests/test_adapters.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Tests for engine/pipeline/adapters.py - Stage adapters for the pipeline.
|
||||
|
||||
Tests Stage adapters that bridge existing components to the Stage interface:
|
||||
- DataSourceStage: Wraps DataSource objects
|
||||
- DisplayStage: Wraps Display backends
|
||||
- PassthroughStage: Simple pass-through stage for pre-rendered data
|
||||
- SourceItemsToBufferStage: Converts SourceItem objects to text buffers
|
||||
- EffectPluginStage: Wraps effect plugins
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from engine.data_sources.sources import SourceItem
|
||||
from engine.pipeline.adapters import (
|
||||
DataSourceStage,
|
||||
DisplayStage,
|
||||
EffectPluginStage,
|
||||
PassthroughStage,
|
||||
SourceItemsToBufferStage,
|
||||
)
|
||||
from engine.pipeline.core import PipelineContext
|
||||
|
||||
|
||||
class TestDataSourceStage:
|
||||
"""Test DataSourceStage adapter."""
|
||||
|
||||
def test_datasource_stage_name(self):
|
||||
"""DataSourceStage stores name correctly."""
|
||||
mock_source = MagicMock()
|
||||
stage = DataSourceStage(mock_source, name="headlines")
|
||||
assert stage.name == "headlines"
|
||||
|
||||
def test_datasource_stage_category(self):
|
||||
"""DataSourceStage has 'source' category."""
|
||||
mock_source = MagicMock()
|
||||
stage = DataSourceStage(mock_source, name="headlines")
|
||||
assert stage.category == "source"
|
||||
|
||||
def test_datasource_stage_capabilities(self):
|
||||
"""DataSourceStage advertises source capability."""
|
||||
mock_source = MagicMock()
|
||||
stage = DataSourceStage(mock_source, name="headlines")
|
||||
assert "source.headlines" in stage.capabilities
|
||||
|
||||
def test_datasource_stage_dependencies(self):
|
||||
"""DataSourceStage has no dependencies."""
|
||||
mock_source = MagicMock()
|
||||
stage = DataSourceStage(mock_source, name="headlines")
|
||||
assert stage.dependencies == set()
|
||||
|
||||
def test_datasource_stage_process_calls_get_items(self):
|
||||
"""DataSourceStage.process() calls source.get_items()."""
|
||||
mock_items = [
|
||||
SourceItem(content="Item 1", source="headlines", timestamp="12:00"),
|
||||
]
|
||||
mock_source = MagicMock()
|
||||
mock_source.get_items.return_value = mock_items
|
||||
|
||||
stage = DataSourceStage(mock_source, name="headlines")
|
||||
ctx = PipelineContext()
|
||||
result = stage.process(None, ctx)
|
||||
|
||||
assert result == mock_items
|
||||
mock_source.get_items.assert_called_once()
|
||||
|
||||
def test_datasource_stage_process_fallback_returns_data(self):
|
||||
"""DataSourceStage.process() returns data if no get_items method."""
|
||||
mock_source = MagicMock(spec=[]) # No get_items method
|
||||
stage = DataSourceStage(mock_source, name="headlines")
|
||||
ctx = PipelineContext()
|
||||
test_data = [{"content": "test"}]
|
||||
|
||||
result = stage.process(test_data, ctx)
|
||||
assert result == test_data
|
||||
|
||||
|
||||
class TestDisplayStage:
|
||||
"""Test DisplayStage adapter."""
|
||||
|
||||
def test_display_stage_name(self):
|
||||
"""DisplayStage stores name correctly."""
|
||||
mock_display = MagicMock()
|
||||
stage = DisplayStage(mock_display, name="terminal")
|
||||
assert stage.name == "terminal"
|
||||
|
||||
def test_display_stage_category(self):
|
||||
"""DisplayStage has 'display' category."""
|
||||
mock_display = MagicMock()
|
||||
stage = DisplayStage(mock_display, name="terminal")
|
||||
assert stage.category == "display"
|
||||
|
||||
def test_display_stage_capabilities(self):
|
||||
"""DisplayStage advertises display capability."""
|
||||
mock_display = MagicMock()
|
||||
stage = DisplayStage(mock_display, name="terminal")
|
||||
assert "display.output" in stage.capabilities
|
||||
|
||||
def test_display_stage_dependencies(self):
|
||||
"""DisplayStage depends on render.output."""
|
||||
mock_display = MagicMock()
|
||||
stage = DisplayStage(mock_display, name="terminal")
|
||||
assert "render.output" in stage.dependencies
|
||||
|
||||
def test_display_stage_init(self):
|
||||
"""DisplayStage.init() calls display.init() with dimensions."""
|
||||
mock_display = MagicMock()
|
||||
mock_display.init.return_value = True
|
||||
stage = DisplayStage(mock_display, name="terminal")
|
||||
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MagicMock()
|
||||
ctx.params.viewport_width = 100
|
||||
ctx.params.viewport_height = 30
|
||||
|
||||
result = stage.init(ctx)
|
||||
|
||||
assert result is True
|
||||
mock_display.init.assert_called_once_with(100, 30, reuse=False)
|
||||
|
||||
def test_display_stage_init_uses_defaults(self):
|
||||
"""DisplayStage.init() uses defaults when params missing."""
|
||||
mock_display = MagicMock()
|
||||
mock_display.init.return_value = True
|
||||
stage = DisplayStage(mock_display, name="terminal")
|
||||
|
||||
ctx = PipelineContext()
|
||||
ctx.params = None
|
||||
|
||||
result = stage.init(ctx)
|
||||
|
||||
assert result is True
|
||||
mock_display.init.assert_called_once_with(80, 24, reuse=False)
|
||||
|
||||
def test_display_stage_process_calls_show(self):
|
||||
"""DisplayStage.process() calls display.show() with data."""
|
||||
mock_display = MagicMock()
|
||||
stage = DisplayStage(mock_display, name="terminal")
|
||||
|
||||
test_buffer = [[["A", "red"] for _ in range(80)] for _ in range(24)]
|
||||
ctx = PipelineContext()
|
||||
result = stage.process(test_buffer, ctx)
|
||||
|
||||
assert result == test_buffer
|
||||
mock_display.show.assert_called_once_with(test_buffer)
|
||||
|
||||
def test_display_stage_process_skips_none_data(self):
|
||||
"""DisplayStage.process() skips show() if data is None."""
|
||||
mock_display = MagicMock()
|
||||
stage = DisplayStage(mock_display, name="terminal")
|
||||
|
||||
ctx = PipelineContext()
|
||||
result = stage.process(None, ctx)
|
||||
|
||||
assert result is None
|
||||
mock_display.show.assert_not_called()
|
||||
|
||||
def test_display_stage_cleanup(self):
|
||||
"""DisplayStage.cleanup() calls display.cleanup()."""
|
||||
mock_display = MagicMock()
|
||||
stage = DisplayStage(mock_display, name="terminal")
|
||||
|
||||
stage.cleanup()
|
||||
|
||||
mock_display.cleanup.assert_called_once()
|
||||
|
||||
|
||||
class TestPassthroughStage:
|
||||
"""Test PassthroughStage adapter."""
|
||||
|
||||
def test_passthrough_stage_name(self):
|
||||
"""PassthroughStage stores name correctly."""
|
||||
stage = PassthroughStage(name="test")
|
||||
assert stage.name == "test"
|
||||
|
||||
def test_passthrough_stage_category(self):
|
||||
"""PassthroughStage has 'render' category."""
|
||||
stage = PassthroughStage()
|
||||
assert stage.category == "render"
|
||||
|
||||
def test_passthrough_stage_is_optional(self):
|
||||
"""PassthroughStage is optional."""
|
||||
stage = PassthroughStage()
|
||||
assert stage.optional is True
|
||||
|
||||
def test_passthrough_stage_capabilities(self):
|
||||
"""PassthroughStage advertises render output capability."""
|
||||
stage = PassthroughStage()
|
||||
assert "render.output" in stage.capabilities
|
||||
|
||||
def test_passthrough_stage_dependencies(self):
|
||||
"""PassthroughStage depends on source."""
|
||||
stage = PassthroughStage()
|
||||
assert "source" in stage.dependencies
|
||||
|
||||
def test_passthrough_stage_process_returns_data_unchanged(self):
|
||||
"""PassthroughStage.process() returns data unchanged."""
|
||||
stage = PassthroughStage()
|
||||
ctx = PipelineContext()
|
||||
|
||||
test_data = [
|
||||
SourceItem(content="Line 1", source="test", timestamp="12:00"),
|
||||
]
|
||||
result = stage.process(test_data, ctx)
|
||||
|
||||
assert result == test_data
|
||||
assert result is test_data
|
||||
|
||||
|
||||
class TestSourceItemsToBufferStage:
|
||||
"""Test SourceItemsToBufferStage adapter."""
|
||||
|
||||
def test_source_items_to_buffer_stage_name(self):
|
||||
"""SourceItemsToBufferStage stores name correctly."""
|
||||
stage = SourceItemsToBufferStage(name="custom-name")
|
||||
assert stage.name == "custom-name"
|
||||
|
||||
def test_source_items_to_buffer_stage_category(self):
|
||||
"""SourceItemsToBufferStage has 'render' category."""
|
||||
stage = SourceItemsToBufferStage()
|
||||
assert stage.category == "render"
|
||||
|
||||
def test_source_items_to_buffer_stage_is_optional(self):
|
||||
"""SourceItemsToBufferStage is optional."""
|
||||
stage = SourceItemsToBufferStage()
|
||||
assert stage.optional is True
|
||||
|
||||
def test_source_items_to_buffer_stage_capabilities(self):
|
||||
"""SourceItemsToBufferStage advertises render output capability."""
|
||||
stage = SourceItemsToBufferStage()
|
||||
assert "render.output" in stage.capabilities
|
||||
|
||||
def test_source_items_to_buffer_stage_dependencies(self):
|
||||
"""SourceItemsToBufferStage depends on source."""
|
||||
stage = SourceItemsToBufferStage()
|
||||
assert "source" in stage.dependencies
|
||||
|
||||
def test_source_items_to_buffer_stage_process_single_line_item(self):
|
||||
"""SourceItemsToBufferStage converts single-line SourceItem."""
|
||||
stage = SourceItemsToBufferStage()
|
||||
ctx = PipelineContext()
|
||||
|
||||
items = [
|
||||
SourceItem(content="Single line content", source="test", timestamp="12:00"),
|
||||
]
|
||||
result = stage.process(items, ctx)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 1
|
||||
# Result should be lines of text
|
||||
assert all(isinstance(line, str) for line in result)
|
||||
|
||||
def test_source_items_to_buffer_stage_process_multiline_item(self):
|
||||
"""SourceItemsToBufferStage splits multiline SourceItem content."""
|
||||
stage = SourceItemsToBufferStage()
|
||||
ctx = PipelineContext()
|
||||
|
||||
content = "Line 1\nLine 2\nLine 3"
|
||||
items = [
|
||||
SourceItem(content=content, source="test", timestamp="12:00"),
|
||||
]
|
||||
result = stage.process(items, ctx)
|
||||
|
||||
# Should have at least 3 lines
|
||||
assert len(result) >= 3
|
||||
assert all(isinstance(line, str) for line in result)
|
||||
|
||||
def test_source_items_to_buffer_stage_process_multiple_items(self):
|
||||
"""SourceItemsToBufferStage handles multiple SourceItems."""
|
||||
stage = SourceItemsToBufferStage()
|
||||
ctx = PipelineContext()
|
||||
|
||||
items = [
|
||||
SourceItem(content="Item 1", source="test", timestamp="12:00"),
|
||||
SourceItem(content="Item 2", source="test", timestamp="12:01"),
|
||||
SourceItem(content="Item 3", source="test", timestamp="12:02"),
|
||||
]
|
||||
result = stage.process(items, ctx)
|
||||
|
||||
# Should have at least 3 lines (one per item, possibly more)
|
||||
assert len(result) >= 3
|
||||
assert all(isinstance(line, str) for line in result)
|
||||
|
||||
|
||||
class TestEffectPluginStage:
|
||||
"""Test EffectPluginStage adapter."""
|
||||
|
||||
def test_effect_plugin_stage_name(self):
|
||||
"""EffectPluginStage stores name correctly."""
|
||||
mock_effect = MagicMock()
|
||||
stage = EffectPluginStage(mock_effect, name="blur")
|
||||
assert stage.name == "blur"
|
||||
|
||||
def test_effect_plugin_stage_category(self):
|
||||
"""EffectPluginStage has 'effect' category."""
|
||||
mock_effect = MagicMock()
|
||||
stage = EffectPluginStage(mock_effect, name="blur")
|
||||
assert stage.category == "effect"
|
||||
|
||||
def test_effect_plugin_stage_is_not_optional(self):
|
||||
"""EffectPluginStage is required when configured."""
|
||||
mock_effect = MagicMock()
|
||||
stage = EffectPluginStage(mock_effect, name="blur")
|
||||
assert stage.optional is False
|
||||
|
||||
def test_effect_plugin_stage_capabilities(self):
|
||||
"""EffectPluginStage advertises effect capability with name."""
|
||||
mock_effect = MagicMock()
|
||||
stage = EffectPluginStage(mock_effect, name="blur")
|
||||
assert "effect.blur" in stage.capabilities
|
||||
|
||||
def test_effect_plugin_stage_dependencies(self):
|
||||
"""EffectPluginStage has no static dependencies."""
|
||||
mock_effect = MagicMock()
|
||||
stage = EffectPluginStage(mock_effect, name="blur")
|
||||
# EffectPluginStage has empty dependencies - they are resolved dynamically
|
||||
assert stage.dependencies == set()
|
||||
|
||||
def test_effect_plugin_stage_stage_type(self):
|
||||
"""EffectPluginStage.stage_type returns effect for non-HUD."""
|
||||
mock_effect = MagicMock()
|
||||
stage = EffectPluginStage(mock_effect, name="blur")
|
||||
assert stage.stage_type == "effect"
|
||||
|
||||
def test_effect_plugin_stage_hud_special_handling(self):
|
||||
"""EffectPluginStage has special handling for HUD effect."""
|
||||
mock_effect = MagicMock()
|
||||
stage = EffectPluginStage(mock_effect, name="hud")
|
||||
assert stage.stage_type == "overlay"
|
||||
assert stage.is_overlay is True
|
||||
assert stage.render_order == 100
|
||||
|
||||
def test_effect_plugin_stage_process(self):
|
||||
"""EffectPluginStage.process() calls effect.process()."""
|
||||
mock_effect = MagicMock()
|
||||
mock_effect.process.return_value = "processed_data"
|
||||
|
||||
stage = EffectPluginStage(mock_effect, name="blur")
|
||||
ctx = PipelineContext()
|
||||
test_buffer = "test_buffer"
|
||||
|
||||
result = stage.process(test_buffer, ctx)
|
||||
|
||||
assert result == "processed_data"
|
||||
mock_effect.process.assert_called_once()
|
||||
205
tests/test_app.py
Normal file
205
tests/test_app.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Integration tests for engine/app.py - pipeline orchestration.
|
||||
|
||||
Tests the main entry point and pipeline mode initialization.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.app import main, run_pipeline_mode
|
||||
from engine.pipeline import get_preset
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Test main() entry point."""
|
||||
|
||||
def test_main_calls_run_pipeline_mode_with_default_preset(self):
|
||||
"""main() runs default preset (demo) when no args provided."""
|
||||
with patch("engine.app.run_pipeline_mode") as mock_run:
|
||||
sys.argv = ["mainline.py"]
|
||||
main()
|
||||
mock_run.assert_called_once_with("demo")
|
||||
|
||||
def test_main_calls_run_pipeline_mode_with_config_preset(self):
|
||||
"""main() uses PRESET from config if set."""
|
||||
with (
|
||||
patch("engine.app.config") as mock_config,
|
||||
patch("engine.app.run_pipeline_mode") as mock_run,
|
||||
):
|
||||
mock_config.PIPELINE_DIAGRAM = False
|
||||
mock_config.PRESET = "gallery-sources"
|
||||
mock_config.PIPELINE_MODE = False
|
||||
sys.argv = ["mainline.py"]
|
||||
main()
|
||||
mock_run.assert_called_once_with("gallery-sources")
|
||||
|
||||
def test_main_exits_on_unknown_preset(self):
|
||||
"""main() exits with error for unknown preset."""
|
||||
with (
|
||||
patch("engine.app.config") as mock_config,
|
||||
patch("engine.app.list_presets", return_value=["demo", "poetry"]),
|
||||
):
|
||||
mock_config.PIPELINE_DIAGRAM = False
|
||||
mock_config.PRESET = "nonexistent"
|
||||
mock_config.PIPELINE_MODE = False
|
||||
sys.argv = ["mainline.py"]
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
class TestRunPipelineMode:
|
||||
"""Test run_pipeline_mode() initialization."""
|
||||
|
||||
def test_run_pipeline_mode_loads_valid_preset(self):
|
||||
"""run_pipeline_mode() loads a valid preset."""
|
||||
preset = get_preset("demo")
|
||||
assert preset is not None
|
||||
assert preset.name == "demo"
|
||||
assert preset.source == "headlines"
|
||||
|
||||
def test_run_pipeline_mode_exits_on_invalid_preset(self):
|
||||
"""run_pipeline_mode() exits if preset not found."""
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
run_pipeline_mode("invalid-preset-xyz")
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_run_pipeline_mode_exits_when_no_content_available(self):
|
||||
"""run_pipeline_mode() exits if no content can be fetched."""
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=None),
|
||||
patch("engine.app.fetch_all", return_value=([], None, None)),
|
||||
patch("engine.app.effects_plugins"),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
run_pipeline_mode("demo")
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_run_pipeline_mode_uses_cache_over_fetch(self):
|
||||
"""run_pipeline_mode() uses cached content if available."""
|
||||
cached = ["cached_item"]
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=cached) as mock_load,
|
||||
patch("engine.app.fetch_all") as mock_fetch,
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
):
|
||||
mock_display = Mock()
|
||||
mock_display.init = Mock()
|
||||
mock_display.get_dimensions = Mock(return_value=(80, 24))
|
||||
mock_display.is_quit_requested = Mock(return_value=True)
|
||||
mock_display.clear_quit_request = Mock()
|
||||
mock_display.show = Mock()
|
||||
mock_display.cleanup = Mock()
|
||||
mock_create.return_value = mock_display
|
||||
|
||||
try:
|
||||
run_pipeline_mode("demo")
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
# Verify fetch_all was NOT called (cache was used)
|
||||
mock_fetch.assert_not_called()
|
||||
mock_load.assert_called_once()
|
||||
|
||||
def test_run_pipeline_mode_creates_display(self):
|
||||
"""run_pipeline_mode() creates a display backend."""
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=["item"]),
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
):
|
||||
mock_display = Mock()
|
||||
mock_display.init = Mock()
|
||||
mock_display.get_dimensions = Mock(return_value=(80, 24))
|
||||
mock_display.is_quit_requested = Mock(return_value=True)
|
||||
mock_display.clear_quit_request = Mock()
|
||||
mock_display.show = Mock()
|
||||
mock_display.cleanup = Mock()
|
||||
mock_create.return_value = mock_display
|
||||
|
||||
try:
|
||||
run_pipeline_mode("gallery-display-terminal")
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
# Verify display was created with 'terminal' (preset display)
|
||||
mock_create.assert_called_once_with("terminal")
|
||||
|
||||
def test_run_pipeline_mode_respects_display_cli_flag(self):
|
||||
"""run_pipeline_mode() uses --display CLI flag if provided."""
|
||||
sys.argv = ["mainline.py", "--display", "websocket"]
|
||||
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=["item"]),
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
):
|
||||
mock_display = Mock()
|
||||
mock_display.init = Mock()
|
||||
mock_display.get_dimensions = Mock(return_value=(80, 24))
|
||||
mock_display.is_quit_requested = Mock(return_value=True)
|
||||
mock_display.clear_quit_request = Mock()
|
||||
mock_display.show = Mock()
|
||||
mock_display.cleanup = Mock()
|
||||
mock_create.return_value = mock_display
|
||||
|
||||
try:
|
||||
run_pipeline_mode("demo")
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
# Verify display was created with CLI override
|
||||
mock_create.assert_called_once_with("websocket")
|
||||
|
||||
def test_run_pipeline_mode_fetches_poetry_for_poetry_source(self):
|
||||
"""run_pipeline_mode() fetches poetry for poetry preset."""
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=None),
|
||||
patch(
|
||||
"engine.app.fetch_poetry", return_value=(["poem"], None, None)
|
||||
) as mock_fetch_poetry,
|
||||
patch("engine.app.fetch_all") as mock_fetch_all,
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
):
|
||||
mock_display = Mock()
|
||||
mock_display.init = Mock()
|
||||
mock_display.get_dimensions = Mock(return_value=(80, 24))
|
||||
mock_display.is_quit_requested = Mock(return_value=True)
|
||||
mock_display.clear_quit_request = Mock()
|
||||
mock_display.show = Mock()
|
||||
mock_display.cleanup = Mock()
|
||||
mock_create.return_value = mock_display
|
||||
|
||||
try:
|
||||
run_pipeline_mode("poetry")
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
# Verify fetch_poetry was called, not fetch_all
|
||||
mock_fetch_poetry.assert_called_once()
|
||||
mock_fetch_all.assert_not_called()
|
||||
|
||||
def test_run_pipeline_mode_discovers_effect_plugins(self):
|
||||
"""run_pipeline_mode() discovers available effect plugins."""
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=["item"]),
|
||||
patch("engine.app.effects_plugins") as mock_effects,
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
):
|
||||
mock_display = Mock()
|
||||
mock_display.init = Mock()
|
||||
mock_display.get_dimensions = Mock(return_value=(80, 24))
|
||||
mock_display.is_quit_requested = Mock(return_value=True)
|
||||
mock_display.clear_quit_request = Mock()
|
||||
mock_display.show = Mock()
|
||||
mock_display.cleanup = Mock()
|
||||
mock_create.return_value = mock_display
|
||||
|
||||
try:
|
||||
run_pipeline_mode("demo")
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
# Verify effects_plugins.discover_plugins was called
|
||||
mock_effects.discover_plugins.assert_called_once()
|
||||
100
tests/test_benchmark.py
Normal file
100
tests/test_benchmark.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Tests for engine.benchmark module - performance regression tests.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.display import NullDisplay
|
||||
|
||||
|
||||
class TestBenchmarkNullDisplay:
|
||||
"""Performance tests for NullDisplay - regression tests."""
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_null_display_minimum_fps(self):
|
||||
"""NullDisplay should meet minimum performance threshold."""
|
||||
import time
|
||||
|
||||
display = NullDisplay()
|
||||
display.init(80, 24)
|
||||
buffer = ["x" * 80 for _ in range(24)]
|
||||
|
||||
iterations = 1000
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
display.show(buffer)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
fps = iterations / elapsed
|
||||
min_fps = 20000
|
||||
|
||||
assert fps >= min_fps, f"NullDisplay FPS {fps:.0f} below minimum {min_fps}"
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_effects_minimum_throughput(self):
|
||||
"""Effects should meet minimum processing throughput."""
|
||||
import time
|
||||
|
||||
from engine.effects import EffectContext, get_registry
|
||||
from engine.effects.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
registry = get_registry()
|
||||
effect = registry.get("noise")
|
||||
assert effect is not None, "Noise effect should be registered"
|
||||
|
||||
buffer = ["x" * 80 for _ in range(24)]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
|
||||
iterations = 500
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
effect.process(buffer, ctx)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
fps = iterations / elapsed
|
||||
min_fps = 10000
|
||||
|
||||
assert fps >= min_fps, (
|
||||
f"Effect processing FPS {fps:.0f} below minimum {min_fps}"
|
||||
)
|
||||
|
||||
|
||||
class TestBenchmarkWebSocketDisplay:
|
||||
"""Performance tests for WebSocketDisplay."""
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_websocket_display_minimum_fps(self):
|
||||
"""WebSocketDisplay should meet minimum performance threshold."""
|
||||
import time
|
||||
|
||||
with patch("engine.display.backends.websocket.websockets", None):
|
||||
from engine.display import WebSocketDisplay
|
||||
|
||||
display = WebSocketDisplay()
|
||||
display.init(80, 24)
|
||||
buffer = ["x" * 80 for _ in range(24)]
|
||||
|
||||
iterations = 500
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
display.show(buffer)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
fps = iterations / elapsed
|
||||
min_fps = 10000
|
||||
|
||||
assert fps >= min_fps, (
|
||||
f"WebSocketDisplay FPS {fps:.0f} below minimum {min_fps}"
|
||||
)
|
||||
111
tests/test_border_effect.py
Normal file
111
tests/test_border_effect.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Tests for BorderEffect.
|
||||
"""
|
||||
|
||||
from engine.effects.plugins.border import BorderEffect
|
||||
from engine.effects.types import EffectContext
|
||||
|
||||
|
||||
def make_ctx(terminal_width: int = 80, terminal_height: int = 24) -> EffectContext:
|
||||
"""Create a mock EffectContext."""
|
||||
return EffectContext(
|
||||
terminal_width=terminal_width,
|
||||
terminal_height=terminal_height,
|
||||
scroll_cam=0,
|
||||
ticker_height=terminal_height,
|
||||
)
|
||||
|
||||
|
||||
class TestBorderEffect:
|
||||
"""Tests for BorderEffect."""
|
||||
|
||||
def test_basic_init(self):
|
||||
"""BorderEffect initializes with defaults."""
|
||||
effect = BorderEffect()
|
||||
assert effect.name == "border"
|
||||
assert effect.config.enabled is True
|
||||
|
||||
def test_adds_border(self):
|
||||
"""BorderEffect adds border around content."""
|
||||
effect = BorderEffect()
|
||||
buf = [
|
||||
"Hello World",
|
||||
"Test Content",
|
||||
"Third Line",
|
||||
]
|
||||
ctx = make_ctx(terminal_width=20, terminal_height=10)
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
# Should have top and bottom borders
|
||||
assert len(result) >= 3
|
||||
# First line should start with border character
|
||||
assert result[0][0] in "┌┎┍"
|
||||
# Last line should end with border character
|
||||
assert result[-1][-1] in "┘┖┚"
|
||||
|
||||
def test_border_with_small_buffer(self):
|
||||
"""BorderEffect handles small buffer (too small for border)."""
|
||||
effect = BorderEffect()
|
||||
buf = ["ab"] # Too small for proper border
|
||||
ctx = make_ctx(terminal_width=10, terminal_height=5)
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
# Should still try to add border but result may differ
|
||||
# At minimum should have output
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_metrics_in_border(self):
|
||||
"""BorderEffect includes FPS and frame time in border."""
|
||||
effect = BorderEffect()
|
||||
buf = ["x" * 10] * 5
|
||||
ctx = make_ctx(terminal_width=20, terminal_height=10)
|
||||
|
||||
# Add metrics to context
|
||||
ctx.set_state(
|
||||
"metrics",
|
||||
{
|
||||
"avg_ms": 16.5,
|
||||
"frame_count": 100,
|
||||
"fps": 60.0,
|
||||
},
|
||||
)
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
# Check for FPS in top border
|
||||
top_line = result[0]
|
||||
assert "FPS" in top_line or "60" in top_line
|
||||
|
||||
# Check for frame time in bottom border
|
||||
bottom_line = result[-1]
|
||||
assert "ms" in bottom_line or "16" in bottom_line
|
||||
|
||||
def test_no_metrics(self):
|
||||
"""BorderEffect works without metrics."""
|
||||
effect = BorderEffect()
|
||||
buf = ["content"] * 5
|
||||
ctx = make_ctx(terminal_width=20, terminal_height=10)
|
||||
# No metrics set
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
# Should still have border characters
|
||||
assert len(result) >= 3
|
||||
assert result[0][0] in "┌┎┍"
|
||||
|
||||
def test_crops_before_bordering(self):
|
||||
"""BorderEffect crops input before adding border."""
|
||||
effect = BorderEffect()
|
||||
buf = ["x" * 100] * 50 # Very large buffer
|
||||
ctx = make_ctx(terminal_width=20, terminal_height=10)
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
# Should be cropped to fit, then bordered
|
||||
# Result should be <= terminal_height with border
|
||||
assert len(result) <= ctx.terminal_height
|
||||
# Each line should be <= terminal_width
|
||||
for line in result:
|
||||
assert len(line) <= ctx.terminal_width
|
||||
68
tests/test_camera.py
Normal file
68
tests/test_camera.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from engine.camera import Camera, CameraMode
|
||||
|
||||
|
||||
def test_camera_vertical_default():
|
||||
"""Test default vertical camera."""
|
||||
cam = Camera()
|
||||
assert cam.mode == CameraMode.FEED
|
||||
assert cam.x == 0
|
||||
assert cam.y == 0
|
||||
|
||||
|
||||
def test_camera_vertical_factory():
|
||||
"""Test vertical factory method."""
|
||||
cam = Camera.feed(speed=2.0)
|
||||
assert cam.mode == CameraMode.FEED
|
||||
assert cam.speed == 2.0
|
||||
|
||||
|
||||
def test_camera_horizontal():
|
||||
"""Test horizontal camera."""
|
||||
cam = Camera.horizontal(speed=1.5)
|
||||
assert cam.mode == CameraMode.HORIZONTAL
|
||||
cam.update(1.0)
|
||||
assert cam.x > 0
|
||||
|
||||
|
||||
def test_camera_omni():
|
||||
"""Test omnidirectional camera."""
|
||||
cam = Camera.omni(speed=1.0)
|
||||
assert cam.mode == CameraMode.OMNI
|
||||
cam.update(1.0)
|
||||
assert cam.x > 0
|
||||
assert cam.y > 0
|
||||
|
||||
|
||||
def test_camera_floating():
|
||||
"""Test floating camera with sinusoidal motion."""
|
||||
cam = Camera.floating(speed=1.0)
|
||||
assert cam.mode == CameraMode.FLOATING
|
||||
y_before = cam.y
|
||||
cam.update(0.5)
|
||||
y_after = cam.y
|
||||
assert y_before != y_after
|
||||
|
||||
|
||||
def test_camera_reset():
|
||||
"""Test camera reset."""
|
||||
cam = Camera.vertical()
|
||||
cam.update(1.0)
|
||||
assert cam.y > 0
|
||||
cam.reset()
|
||||
assert cam.x == 0
|
||||
assert cam.y == 0
|
||||
|
||||
|
||||
def test_camera_custom_update():
|
||||
"""Test custom update function."""
|
||||
call_count = 0
|
||||
|
||||
def custom_update(camera, dt):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
camera.x += int(10 * dt)
|
||||
|
||||
cam = Camera.custom(custom_update)
|
||||
cam.update(1.0)
|
||||
assert call_count == 1
|
||||
assert cam.x == 10
|
||||
@@ -7,6 +7,8 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine import config
|
||||
|
||||
|
||||
@@ -160,3 +162,140 @@ class TestSetFontSelection:
|
||||
config.set_font_selection(font_path=None, font_index=None)
|
||||
assert original_path == config.FONT_PATH
|
||||
assert original_index == config.FONT_INDEX
|
||||
|
||||
|
||||
class TestConfigDataclass:
|
||||
"""Tests for Config dataclass."""
|
||||
|
||||
def test_config_has_required_fields(self):
|
||||
"""Config has all required fields."""
|
||||
c = config.Config()
|
||||
assert hasattr(c, "headline_limit")
|
||||
assert hasattr(c, "feed_timeout")
|
||||
assert hasattr(c, "mic_threshold_db")
|
||||
assert hasattr(c, "mode")
|
||||
assert hasattr(c, "firehose")
|
||||
assert hasattr(c, "ntfy_topic")
|
||||
assert hasattr(c, "ntfy_reconnect_delay")
|
||||
assert hasattr(c, "message_display_secs")
|
||||
assert hasattr(c, "font_dir")
|
||||
assert hasattr(c, "font_path")
|
||||
assert hasattr(c, "font_index")
|
||||
assert hasattr(c, "font_picker")
|
||||
assert hasattr(c, "font_sz")
|
||||
assert hasattr(c, "render_h")
|
||||
assert hasattr(c, "ssaa")
|
||||
assert hasattr(c, "scroll_dur")
|
||||
assert hasattr(c, "frame_dt")
|
||||
assert hasattr(c, "firehose_h")
|
||||
assert hasattr(c, "grad_speed")
|
||||
assert hasattr(c, "glitch_glyphs")
|
||||
assert hasattr(c, "kata_glyphs")
|
||||
assert hasattr(c, "script_fonts")
|
||||
|
||||
def test_config_defaults(self):
|
||||
"""Config has sensible defaults."""
|
||||
c = config.Config()
|
||||
assert c.headline_limit == 1000
|
||||
assert c.feed_timeout == 10
|
||||
assert c.mic_threshold_db == 50
|
||||
assert c.mode == "news"
|
||||
assert c.firehose is False
|
||||
assert c.ntfy_reconnect_delay == 5
|
||||
assert c.message_display_secs == 30
|
||||
|
||||
def test_config_is_immutable(self):
|
||||
"""Config is frozen (immutable)."""
|
||||
c = config.Config()
|
||||
with pytest.raises(AttributeError):
|
||||
c.headline_limit = 500 # type: ignore
|
||||
|
||||
def test_config_custom_values(self):
|
||||
"""Config accepts custom values."""
|
||||
c = config.Config(
|
||||
headline_limit=500,
|
||||
mode="poetry",
|
||||
firehose=True,
|
||||
ntfy_topic="https://ntfy.sh/test",
|
||||
)
|
||||
assert c.headline_limit == 500
|
||||
assert c.mode == "poetry"
|
||||
assert c.firehose is True
|
||||
assert c.ntfy_topic == "https://ntfy.sh/test"
|
||||
|
||||
|
||||
class TestConfigFromArgs:
|
||||
"""Tests for Config.from_args method."""
|
||||
|
||||
def test_from_args_defaults(self):
|
||||
"""from_args creates config with defaults from empty argv."""
|
||||
c = config.Config.from_args(["prog"])
|
||||
assert c.mode == "news"
|
||||
assert c.firehose is False
|
||||
assert c.font_picker is True
|
||||
|
||||
def test_from_args_poetry_mode(self):
|
||||
"""from_args detects --poetry flag."""
|
||||
c = config.Config.from_args(["prog", "--poetry"])
|
||||
assert c.mode == "poetry"
|
||||
|
||||
def test_from_args_poetry_short_flag(self):
|
||||
"""from_args detects -p short flag."""
|
||||
c = config.Config.from_args(["prog", "-p"])
|
||||
assert c.mode == "poetry"
|
||||
|
||||
def test_from_args_firehose(self):
|
||||
"""from_args detects --firehose flag."""
|
||||
c = config.Config.from_args(["prog", "--firehose"])
|
||||
assert c.firehose is True
|
||||
|
||||
def test_from_args_no_font_picker(self):
|
||||
"""from_args detects --no-font-picker flag."""
|
||||
c = config.Config.from_args(["prog", "--no-font-picker"])
|
||||
assert c.font_picker is False
|
||||
|
||||
def test_from_args_font_index(self):
|
||||
"""from_args parses --font-index."""
|
||||
c = config.Config.from_args(["prog", "--font-index", "3"])
|
||||
assert c.font_index == 3
|
||||
|
||||
|
||||
class TestGetSetConfig:
|
||||
"""Tests for get_config and set_config functions."""
|
||||
|
||||
def test_get_config_returns_config(self):
|
||||
"""get_config returns a Config instance."""
|
||||
c = config.get_config()
|
||||
assert isinstance(c, config.Config)
|
||||
|
||||
def test_set_config_allows_injection(self):
|
||||
"""set_config allows injecting a custom config."""
|
||||
custom = config.Config(mode="poetry", headline_limit=100)
|
||||
config.set_config(custom)
|
||||
assert config.get_config().mode == "poetry"
|
||||
assert config.get_config().headline_limit == 100
|
||||
|
||||
def test_set_config_then_get_config(self):
|
||||
"""set_config followed by get_config returns the set config."""
|
||||
original = config.get_config()
|
||||
test_config = config.Config(headline_limit=42)
|
||||
config.set_config(test_config)
|
||||
result = config.get_config()
|
||||
assert result.headline_limit == 42
|
||||
config.set_config(original)
|
||||
|
||||
|
||||
class TestPlatformFontPaths:
|
||||
"""Tests for platform font path detection."""
|
||||
|
||||
def test_get_platform_font_paths_returns_dict(self):
|
||||
"""_get_platform_font_paths returns a dictionary."""
|
||||
fonts = config._get_platform_font_paths()
|
||||
assert isinstance(fonts, dict)
|
||||
|
||||
def test_platform_font_paths_common_languages(self):
|
||||
"""Common language font mappings exist."""
|
||||
fonts = config._get_platform_font_paths()
|
||||
common = {"ja", "zh-cn", "ko", "ru", "ar", "hi"}
|
||||
found = set(fonts.keys()) & common
|
||||
assert len(found) > 0
|
||||
|
||||
99
tests/test_crop_effect.py
Normal file
99
tests/test_crop_effect.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Tests for CropEffect.
|
||||
"""
|
||||
|
||||
from engine.effects.plugins.crop import CropEffect
|
||||
from engine.effects.types import EffectContext
|
||||
|
||||
|
||||
def make_ctx(terminal_width: int = 80, terminal_height: int = 24) -> EffectContext:
|
||||
"""Create a mock EffectContext."""
|
||||
return EffectContext(
|
||||
terminal_width=terminal_width,
|
||||
terminal_height=terminal_height,
|
||||
scroll_cam=0,
|
||||
ticker_height=terminal_height,
|
||||
)
|
||||
|
||||
|
||||
class TestCropEffect:
|
||||
"""Tests for CropEffect."""
|
||||
|
||||
def test_basic_init(self):
|
||||
"""CropEffect initializes with defaults."""
|
||||
effect = CropEffect()
|
||||
assert effect.name == "crop"
|
||||
assert effect.config.enabled is True
|
||||
|
||||
def test_crop_wider_buffer(self):
|
||||
"""CropEffect crops wide buffer to terminal width."""
|
||||
effect = CropEffect()
|
||||
buf = [
|
||||
"This is a very long line that exceeds the terminal width of eighty characters!",
|
||||
"Another long line that should also be cropped to fit within the terminal bounds!",
|
||||
"Short",
|
||||
]
|
||||
ctx = make_ctx(terminal_width=40, terminal_height=10)
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
# Lines should be cropped to 40 chars
|
||||
assert len(result[0]) == 40
|
||||
assert len(result[1]) == 40
|
||||
assert result[2] == "Short" + " " * 35 # padded to width
|
||||
|
||||
def test_crop_taller_buffer(self):
|
||||
"""CropEffect crops tall buffer to terminal height."""
|
||||
effect = CropEffect()
|
||||
buf = ["line"] * 30 # 30 lines
|
||||
ctx = make_ctx(terminal_width=80, terminal_height=10)
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
# Should be cropped to 10 lines
|
||||
assert len(result) == 10
|
||||
|
||||
def test_pad_shorter_lines(self):
|
||||
"""CropEffect pads lines shorter than width."""
|
||||
effect = CropEffect()
|
||||
buf = ["short", "medium length", ""]
|
||||
ctx = make_ctx(terminal_width=20, terminal_height=5)
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
assert len(result[0]) == 20 # padded
|
||||
assert len(result[1]) == 20 # padded
|
||||
assert len(result[2]) == 20 # padded (was empty)
|
||||
|
||||
def test_pad_to_height(self):
|
||||
"""CropEffect pads with empty lines if buffer is too short."""
|
||||
effect = CropEffect()
|
||||
buf = ["line1", "line2"]
|
||||
ctx = make_ctx(terminal_width=20, terminal_height=10)
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
# Should have 10 lines
|
||||
assert len(result) == 10
|
||||
# Last 8 should be empty padding
|
||||
for i in range(2, 10):
|
||||
assert result[i] == " " * 20
|
||||
|
||||
def test_empty_buffer(self):
|
||||
"""CropEffect handles empty buffer."""
|
||||
effect = CropEffect()
|
||||
ctx = make_ctx()
|
||||
|
||||
result = effect.process([], ctx)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_uses_context_dimensions(self):
|
||||
"""CropEffect uses context terminal_width/terminal_height."""
|
||||
effect = CropEffect()
|
||||
buf = ["x" * 100]
|
||||
ctx = make_ctx(terminal_width=50, terminal_height=1)
|
||||
|
||||
result = effect.process(buf, ctx)
|
||||
|
||||
assert len(result[0]) == 50
|
||||
220
tests/test_data_sources.py
Normal file
220
tests/test_data_sources.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Tests for engine/data_sources/sources.py - data source implementations.
|
||||
|
||||
Tests HeadlinesDataSource, PoetryDataSource, EmptyDataSource, and the
|
||||
base DataSource class functionality.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.data_sources.sources import (
|
||||
EmptyDataSource,
|
||||
HeadlinesDataSource,
|
||||
PoetryDataSource,
|
||||
SourceItem,
|
||||
)
|
||||
|
||||
|
||||
class TestSourceItem:
|
||||
"""Test SourceItem dataclass."""
|
||||
|
||||
def test_source_item_creation(self):
|
||||
"""SourceItem can be created with required fields."""
|
||||
item = SourceItem(
|
||||
content="Test headline",
|
||||
source="test_source",
|
||||
timestamp="2024-01-01",
|
||||
)
|
||||
assert item.content == "Test headline"
|
||||
assert item.source == "test_source"
|
||||
assert item.timestamp == "2024-01-01"
|
||||
assert item.metadata is None
|
||||
|
||||
def test_source_item_with_metadata(self):
|
||||
"""SourceItem can include optional metadata."""
|
||||
metadata = {"author": "John", "category": "tech"}
|
||||
item = SourceItem(
|
||||
content="Test",
|
||||
source="test",
|
||||
timestamp="2024-01-01",
|
||||
metadata=metadata,
|
||||
)
|
||||
assert item.metadata == metadata
|
||||
|
||||
|
||||
class TestEmptyDataSource:
|
||||
"""Test EmptyDataSource."""
|
||||
|
||||
def test_empty_source_name(self):
|
||||
"""EmptyDataSource has correct name."""
|
||||
source = EmptyDataSource()
|
||||
assert source.name == "empty"
|
||||
|
||||
def test_empty_source_is_not_dynamic(self):
|
||||
"""EmptyDataSource is static, not dynamic."""
|
||||
source = EmptyDataSource()
|
||||
assert source.is_dynamic is False
|
||||
|
||||
def test_empty_source_fetch_returns_blank_content(self):
|
||||
"""EmptyDataSource.fetch() returns blank lines."""
|
||||
source = EmptyDataSource(width=80, height=24)
|
||||
items = source.fetch()
|
||||
|
||||
assert len(items) == 1
|
||||
assert isinstance(items[0], SourceItem)
|
||||
assert items[0].source == "empty"
|
||||
# Content should be 24 lines of 80 spaces
|
||||
lines = items[0].content.split("\n")
|
||||
assert len(lines) == 24
|
||||
assert all(len(line) == 80 for line in lines)
|
||||
|
||||
def test_empty_source_get_items_caches_result(self):
|
||||
"""EmptyDataSource.get_items() caches the result."""
|
||||
source = EmptyDataSource()
|
||||
items1 = source.get_items()
|
||||
items2 = source.get_items()
|
||||
# Should return same cached items (same object reference)
|
||||
assert items1 is items2
|
||||
|
||||
|
||||
class TestHeadlinesDataSource:
|
||||
"""Test HeadlinesDataSource."""
|
||||
|
||||
def test_headlines_source_name(self):
|
||||
"""HeadlinesDataSource has correct name."""
|
||||
source = HeadlinesDataSource()
|
||||
assert source.name == "headlines"
|
||||
|
||||
def test_headlines_source_is_static(self):
|
||||
"""HeadlinesDataSource is static."""
|
||||
source = HeadlinesDataSource()
|
||||
assert source.is_dynamic is False
|
||||
|
||||
def test_headlines_fetch_returns_source_items(self):
|
||||
"""HeadlinesDataSource.fetch() returns SourceItem list."""
|
||||
mock_items = [
|
||||
("Test Article 1", "source1", "10:30"),
|
||||
("Test Article 2", "source2", "11:45"),
|
||||
]
|
||||
with patch("engine.fetch.fetch_all") as mock_fetch_all:
|
||||
mock_fetch_all.return_value = (mock_items, 2, 0)
|
||||
|
||||
source = HeadlinesDataSource()
|
||||
items = source.fetch()
|
||||
|
||||
assert len(items) == 2
|
||||
assert all(isinstance(item, SourceItem) for item in items)
|
||||
assert items[0].content == "Test Article 1"
|
||||
assert items[0].source == "source1"
|
||||
assert items[0].timestamp == "10:30"
|
||||
|
||||
def test_headlines_fetch_with_empty_feed(self):
|
||||
"""HeadlinesDataSource handles empty feeds gracefully."""
|
||||
with patch("engine.fetch.fetch_all") as mock_fetch_all:
|
||||
mock_fetch_all.return_value = ([], 0, 1)
|
||||
|
||||
source = HeadlinesDataSource()
|
||||
items = source.fetch()
|
||||
|
||||
# Should return empty list
|
||||
assert isinstance(items, list)
|
||||
assert len(items) == 0
|
||||
|
||||
def test_headlines_get_items_caches_result(self):
|
||||
"""HeadlinesDataSource.get_items() caches the result."""
|
||||
mock_items = [("Test Article", "source", "12:00")]
|
||||
with patch("engine.fetch.fetch_all") as mock_fetch_all:
|
||||
mock_fetch_all.return_value = (mock_items, 1, 0)
|
||||
|
||||
source = HeadlinesDataSource()
|
||||
items1 = source.get_items()
|
||||
items2 = source.get_items()
|
||||
|
||||
# Should only call fetch once (cached)
|
||||
assert mock_fetch_all.call_count == 1
|
||||
assert items1 is items2
|
||||
|
||||
def test_headlines_refresh_clears_cache(self):
|
||||
"""HeadlinesDataSource.refresh() clears cache and refetches."""
|
||||
mock_items = [("Test Article", "source", "12:00")]
|
||||
with patch("engine.fetch.fetch_all") as mock_fetch_all:
|
||||
mock_fetch_all.return_value = (mock_items, 1, 0)
|
||||
|
||||
source = HeadlinesDataSource()
|
||||
source.get_items()
|
||||
source.refresh()
|
||||
source.get_items()
|
||||
|
||||
# Should call fetch twice (once for initial, once for refresh)
|
||||
assert mock_fetch_all.call_count == 2
|
||||
|
||||
|
||||
class TestPoetryDataSource:
|
||||
"""Test PoetryDataSource."""
|
||||
|
||||
def test_poetry_source_name(self):
|
||||
"""PoetryDataSource has correct name."""
|
||||
source = PoetryDataSource()
|
||||
assert source.name == "poetry"
|
||||
|
||||
def test_poetry_source_is_static(self):
|
||||
"""PoetryDataSource is static."""
|
||||
source = PoetryDataSource()
|
||||
assert source.is_dynamic is False
|
||||
|
||||
def test_poetry_fetch_returns_source_items(self):
|
||||
"""PoetryDataSource.fetch() returns SourceItem list."""
|
||||
mock_items = [
|
||||
("Poetry line 1", "Poetry Source 1", ""),
|
||||
("Poetry line 2", "Poetry Source 2", ""),
|
||||
]
|
||||
with patch("engine.fetch.fetch_poetry") as mock_fetch_poetry:
|
||||
mock_fetch_poetry.return_value = (mock_items, 2, 0)
|
||||
|
||||
source = PoetryDataSource()
|
||||
items = source.fetch()
|
||||
|
||||
assert len(items) == 2
|
||||
assert all(isinstance(item, SourceItem) for item in items)
|
||||
assert items[0].content == "Poetry line 1"
|
||||
assert items[0].source == "Poetry Source 1"
|
||||
|
||||
def test_poetry_get_items_caches_result(self):
|
||||
"""PoetryDataSource.get_items() caches result."""
|
||||
mock_items = [("Poetry line", "Poetry Source", "")]
|
||||
with patch("engine.fetch.fetch_poetry") as mock_fetch_poetry:
|
||||
mock_fetch_poetry.return_value = (mock_items, 1, 0)
|
||||
|
||||
source = PoetryDataSource()
|
||||
items1 = source.get_items()
|
||||
items2 = source.get_items()
|
||||
|
||||
# Should only fetch once (cached)
|
||||
assert mock_fetch_poetry.call_count == 1
|
||||
assert items1 is items2
|
||||
|
||||
|
||||
class TestDataSourceInterface:
|
||||
"""Test DataSource base class interface."""
|
||||
|
||||
def test_data_source_stream_not_implemented(self):
|
||||
"""DataSource.stream() raises NotImplementedError."""
|
||||
source = EmptyDataSource()
|
||||
with pytest.raises(NotImplementedError):
|
||||
source.stream()
|
||||
|
||||
def test_data_source_is_dynamic_defaults_false(self):
|
||||
"""DataSource.is_dynamic defaults to False."""
|
||||
source = EmptyDataSource()
|
||||
assert source.is_dynamic is False
|
||||
|
||||
def test_data_source_refresh_updates_cache(self):
|
||||
"""DataSource.refresh() updates internal cache."""
|
||||
source = EmptyDataSource()
|
||||
source.get_items()
|
||||
items_refreshed = source.refresh()
|
||||
|
||||
# refresh() should return new items
|
||||
assert isinstance(items_refreshed, list)
|
||||
429
tests/test_display.py
Normal file
429
tests/test_display.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Tests for engine.display module.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay, render_border
|
||||
from engine.display.backends.multi import MultiDisplay
|
||||
|
||||
|
||||
class TestDisplayProtocol:
|
||||
"""Test that display backends satisfy the Display protocol."""
|
||||
|
||||
def test_terminal_display_is_display(self):
|
||||
"""TerminalDisplay satisfies Display protocol."""
|
||||
display = TerminalDisplay()
|
||||
assert hasattr(display, "init")
|
||||
assert hasattr(display, "show")
|
||||
assert hasattr(display, "clear")
|
||||
assert hasattr(display, "cleanup")
|
||||
|
||||
def test_null_display_is_display(self):
|
||||
"""NullDisplay satisfies Display protocol."""
|
||||
display = NullDisplay()
|
||||
assert hasattr(display, "init")
|
||||
assert hasattr(display, "show")
|
||||
assert hasattr(display, "clear")
|
||||
assert hasattr(display, "cleanup")
|
||||
|
||||
|
||||
class TestDisplayRegistry:
|
||||
"""Tests for DisplayRegistry class."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Reset registry before each test."""
|
||||
DisplayRegistry._backends = {}
|
||||
DisplayRegistry._initialized = False
|
||||
|
||||
def test_register_adds_backend(self):
|
||||
"""register adds a backend to the registry."""
|
||||
DisplayRegistry.register("test", TerminalDisplay)
|
||||
assert DisplayRegistry.get("test") == TerminalDisplay
|
||||
|
||||
def test_register_case_insensitive(self):
|
||||
"""register is case insensitive."""
|
||||
DisplayRegistry.register("TEST", TerminalDisplay)
|
||||
assert DisplayRegistry.get("test") == TerminalDisplay
|
||||
|
||||
def test_get_returns_none_for_unknown(self):
|
||||
"""get returns None for unknown backend."""
|
||||
assert DisplayRegistry.get("unknown") is None
|
||||
|
||||
def test_list_backends_returns_all(self):
|
||||
"""list_backends returns all registered backends."""
|
||||
DisplayRegistry.register("a", TerminalDisplay)
|
||||
DisplayRegistry.register("b", NullDisplay)
|
||||
backends = DisplayRegistry.list_backends()
|
||||
assert "a" in backends
|
||||
assert "b" in backends
|
||||
|
||||
def test_create_returns_instance(self):
|
||||
"""create returns a display instance."""
|
||||
DisplayRegistry.register("test", NullDisplay)
|
||||
display = DisplayRegistry.create("test")
|
||||
assert isinstance(display, NullDisplay)
|
||||
|
||||
def test_create_returns_none_for_unknown(self):
|
||||
"""create returns None for unknown backend."""
|
||||
display = DisplayRegistry.create("unknown")
|
||||
assert display is None
|
||||
|
||||
def test_initialize_registers_defaults(self):
|
||||
"""initialize registers default backends."""
|
||||
DisplayRegistry.initialize()
|
||||
assert DisplayRegistry.get("terminal") == TerminalDisplay
|
||||
assert DisplayRegistry.get("null") == NullDisplay
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
assert DisplayRegistry.get("websocket") == WebSocketDisplay
|
||||
assert DisplayRegistry.get("sixel") == SixelDisplay
|
||||
|
||||
def test_initialize_idempotent(self):
|
||||
"""initialize can be called multiple times safely."""
|
||||
DisplayRegistry.initialize()
|
||||
DisplayRegistry._backends["custom"] = TerminalDisplay
|
||||
DisplayRegistry.initialize()
|
||||
assert "custom" in DisplayRegistry.list_backends()
|
||||
|
||||
|
||||
class TestTerminalDisplay:
|
||||
"""Tests for TerminalDisplay class."""
|
||||
|
||||
def test_init_sets_dimensions(self):
|
||||
"""init stores terminal dimensions."""
|
||||
display = TerminalDisplay()
|
||||
display.init(80, 24)
|
||||
assert display.width == 80
|
||||
assert display.height == 24
|
||||
|
||||
def test_show_returns_none(self):
|
||||
"""show returns None after writing to stdout."""
|
||||
display = TerminalDisplay()
|
||||
display.width = 80
|
||||
display.height = 24
|
||||
display.show(["line1", "line2"])
|
||||
|
||||
def test_clear_does_not_error(self):
|
||||
"""clear works without error."""
|
||||
display = TerminalDisplay()
|
||||
display.clear()
|
||||
|
||||
def test_cleanup_does_not_error(self):
|
||||
"""cleanup works without error."""
|
||||
display = TerminalDisplay()
|
||||
display.cleanup()
|
||||
|
||||
def test_get_dimensions_returns_cached_value(self):
|
||||
"""get_dimensions returns cached dimensions for stability."""
|
||||
display = TerminalDisplay()
|
||||
display.init(80, 24)
|
||||
|
||||
# First call should set cache
|
||||
d1 = display.get_dimensions()
|
||||
assert d1 == (80, 24)
|
||||
|
||||
def test_show_clears_screen_before_each_frame(self):
|
||||
"""show clears previous frame to prevent visual wobble.
|
||||
|
||||
Regression test: Previously show() didn't clear the screen,
|
||||
causing old content to remain and creating visual wobble.
|
||||
The fix adds \\033[H\\033[J (cursor home + erase down) before each frame.
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
display = TerminalDisplay()
|
||||
display.init(80, 24)
|
||||
|
||||
buffer = ["line1", "line2", "line3"]
|
||||
|
||||
fake_buffer = BytesIO()
|
||||
fake_stdout = MagicMock()
|
||||
fake_stdout.buffer = fake_buffer
|
||||
with patch.object(sys, "stdout", fake_stdout):
|
||||
display.show(buffer)
|
||||
|
||||
output = fake_buffer.getvalue().decode("utf-8")
|
||||
assert output.startswith("\033[H\033[J"), (
|
||||
f"Output should start with clear sequence, got: {repr(output[:20])}"
|
||||
)
|
||||
|
||||
def test_show_clears_screen_on_subsequent_frames(self):
|
||||
"""show clears screen on every frame, not just the first.
|
||||
|
||||
Regression test: Ensures each show() call includes the clear sequence.
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
# Use target_fps=0 to disable frame skipping in test
|
||||
display = TerminalDisplay(target_fps=0)
|
||||
display.init(80, 24)
|
||||
|
||||
buffer = ["line1", "line2"]
|
||||
|
||||
for i in range(3):
|
||||
fake_buffer = BytesIO()
|
||||
fake_stdout = MagicMock()
|
||||
fake_stdout.buffer = fake_buffer
|
||||
with patch.object(sys, "stdout", fake_stdout):
|
||||
display.show(buffer)
|
||||
|
||||
output = fake_buffer.getvalue().decode("utf-8")
|
||||
assert output.startswith("\033[H\033[J"), (
|
||||
f"Frame {i} should start with clear sequence"
|
||||
)
|
||||
|
||||
def test_get_dimensions_stable_across_rapid_calls(self):
|
||||
"""get_dimensions should not fluctuate when called rapidly.
|
||||
|
||||
This test catches the bug where os.get_terminal_size() returns
|
||||
inconsistent values, causing visual wobble.
|
||||
"""
|
||||
display = TerminalDisplay()
|
||||
display.init(80, 24)
|
||||
|
||||
# Get dimensions 10 times rapidly (simulating frame loop)
|
||||
dims = [display.get_dimensions() for _ in range(10)]
|
||||
|
||||
# All should be the same - this would fail if os.get_terminal_size()
|
||||
# returns different values each call
|
||||
assert len(set(dims)) == 1, f"Dimensions should be stable, got: {set(dims)}"
|
||||
|
||||
def test_show_with_border_uses_render_border(self):
|
||||
"""show with border=True calls render_border with FPS."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
display = TerminalDisplay()
|
||||
display.init(80, 24)
|
||||
|
||||
buffer = ["line1", "line2"]
|
||||
|
||||
# Mock get_monitor to provide FPS
|
||||
mock_monitor = MagicMock()
|
||||
mock_monitor.get_stats.return_value = {
|
||||
"pipeline": {"avg_ms": 16.5},
|
||||
"frame_count": 100,
|
||||
}
|
||||
|
||||
# Mock render_border to verify it's called
|
||||
with (
|
||||
patch("engine.display.get_monitor", return_value=mock_monitor),
|
||||
patch("engine.display.render_border", wraps=render_border) as mock_render,
|
||||
):
|
||||
display.show(buffer, border=True)
|
||||
|
||||
# Verify render_border was called with correct arguments
|
||||
assert mock_render.called
|
||||
args, kwargs = mock_render.call_args
|
||||
# Arguments: buffer, width, height, fps, frame_time (positional)
|
||||
assert args[0] == buffer
|
||||
assert args[1] == 80
|
||||
assert args[2] == 24
|
||||
assert args[3] == pytest.approx(60.6, rel=0.1) # fps = 1000/16.5
|
||||
assert args[4] == pytest.approx(16.5, rel=0.1)
|
||||
assert kwargs == {} # no keyword arguments
|
||||
|
||||
|
||||
class TestNullDisplay:
|
||||
"""Tests for NullDisplay class."""
|
||||
|
||||
def test_init_stores_dimensions(self):
|
||||
"""init stores dimensions."""
|
||||
display = NullDisplay()
|
||||
display.init(100, 50)
|
||||
assert display.width == 100
|
||||
assert display.height == 50
|
||||
|
||||
def test_show_does_nothing(self):
|
||||
"""show discards buffer without error."""
|
||||
display = NullDisplay()
|
||||
display.show(["line1", "line2", "line3"])
|
||||
|
||||
def test_clear_does_nothing(self):
|
||||
"""clear does nothing."""
|
||||
display = NullDisplay()
|
||||
display.clear()
|
||||
|
||||
def test_cleanup_does_nothing(self):
|
||||
"""cleanup does nothing."""
|
||||
display = NullDisplay()
|
||||
display.cleanup()
|
||||
|
||||
def test_show_stores_last_buffer(self):
|
||||
"""show stores last buffer for testing inspection."""
|
||||
display = NullDisplay()
|
||||
display.init(80, 24)
|
||||
|
||||
buffer = ["line1", "line2", "line3"]
|
||||
display.show(buffer)
|
||||
|
||||
assert display._last_buffer == buffer
|
||||
|
||||
def test_show_tracks_last_buffer_across_calls(self):
|
||||
"""show updates last_buffer on each call."""
|
||||
display = NullDisplay()
|
||||
display.init(80, 24)
|
||||
|
||||
display.show(["first"])
|
||||
assert display._last_buffer == ["first"]
|
||||
|
||||
display.show(["second"])
|
||||
assert display._last_buffer == ["second"]
|
||||
|
||||
|
||||
class TestRenderBorder:
|
||||
"""Tests for render_border function."""
|
||||
|
||||
def test_render_border_adds_corners(self):
|
||||
"""render_border adds corner characters."""
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = ["hello", "world"]
|
||||
result = render_border(buffer, width=10, height=5)
|
||||
|
||||
assert result[0][0] in "┌┎┍" # top-left
|
||||
assert result[0][-1] in "┐┒┓" # top-right
|
||||
assert result[-1][0] in "└┚┖" # bottom-left
|
||||
assert result[-1][-1] in "┘┛┙" # bottom-right
|
||||
|
||||
def test_render_border_dimensions(self):
|
||||
"""render_border output matches requested dimensions."""
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = ["line1", "line2", "line3"]
|
||||
result = render_border(buffer, width=20, height=10)
|
||||
|
||||
# Output should be exactly height lines
|
||||
assert len(result) == 10
|
||||
# Each line should be exactly width characters
|
||||
for line in result:
|
||||
assert len(line) == 20
|
||||
|
||||
def test_render_border_with_fps(self):
|
||||
"""render_border includes FPS in top border when provided."""
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = ["test"]
|
||||
result = render_border(buffer, width=20, height=5, fps=60.0)
|
||||
|
||||
top_line = result[0]
|
||||
assert "FPS:60" in top_line or "FPS: 60" in top_line
|
||||
|
||||
def test_render_border_with_frame_time(self):
|
||||
"""render_border includes frame time in bottom border when provided."""
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = ["test"]
|
||||
result = render_border(buffer, width=20, height=5, frame_time=16.5)
|
||||
|
||||
bottom_line = result[-1]
|
||||
assert "16.5ms" in bottom_line
|
||||
|
||||
def test_render_border_crops_content_to_fit(self):
|
||||
"""render_border crops content to fit within borders."""
|
||||
from engine.display import render_border
|
||||
|
||||
# Buffer larger than viewport
|
||||
buffer = ["x" * 100] * 50
|
||||
result = render_border(buffer, width=20, height=10)
|
||||
|
||||
# Result shrinks to fit viewport
|
||||
assert len(result) == 10
|
||||
for line in result[1:-1]: # Skip border lines
|
||||
assert len(line) == 20
|
||||
|
||||
def test_render_border_preserves_content(self):
|
||||
"""render_border preserves content within borders."""
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = ["hello world", "test line"]
|
||||
result = render_border(buffer, width=20, height=5)
|
||||
|
||||
# Content should appear in the middle rows
|
||||
content_lines = result[1:-1]
|
||||
assert any("hello world" in line for line in content_lines)
|
||||
|
||||
def test_render_border_with_small_buffer(self):
|
||||
"""render_border handles buffers smaller than viewport."""
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = ["hi"]
|
||||
result = render_border(buffer, width=20, height=10)
|
||||
|
||||
# Should still produce full viewport with padding
|
||||
assert len(result) == 10
|
||||
# All lines should be full width
|
||||
for line in result:
|
||||
assert len(line) == 20
|
||||
|
||||
|
||||
class TestMultiDisplay:
|
||||
"""Tests for MultiDisplay class."""
|
||||
|
||||
def test_init_stores_dimensions(self):
|
||||
"""init stores dimensions and forwards to displays."""
|
||||
mock_display1 = MagicMock()
|
||||
mock_display2 = MagicMock()
|
||||
multi = MultiDisplay([mock_display1, mock_display2])
|
||||
|
||||
multi.init(120, 40)
|
||||
|
||||
assert multi.width == 120
|
||||
assert multi.height == 40
|
||||
mock_display1.init.assert_called_once_with(120, 40, reuse=False)
|
||||
mock_display2.init.assert_called_once_with(120, 40, reuse=False)
|
||||
|
||||
def test_show_forwards_to_all_displays(self):
|
||||
"""show forwards buffer to all displays."""
|
||||
mock_display1 = MagicMock()
|
||||
mock_display2 = MagicMock()
|
||||
multi = MultiDisplay([mock_display1, mock_display2])
|
||||
|
||||
buffer = ["line1", "line2"]
|
||||
multi.show(buffer, border=False)
|
||||
|
||||
mock_display1.show.assert_called_once_with(buffer, border=False)
|
||||
mock_display2.show.assert_called_once_with(buffer, border=False)
|
||||
|
||||
def test_clear_forwards_to_all_displays(self):
|
||||
"""clear forwards to all displays."""
|
||||
mock_display1 = MagicMock()
|
||||
mock_display2 = MagicMock()
|
||||
multi = MultiDisplay([mock_display1, mock_display2])
|
||||
|
||||
multi.clear()
|
||||
|
||||
mock_display1.clear.assert_called_once()
|
||||
mock_display2.clear.assert_called_once()
|
||||
|
||||
def test_cleanup_forwards_to_all_displays(self):
|
||||
"""cleanup forwards to all displays."""
|
||||
mock_display1 = MagicMock()
|
||||
mock_display2 = MagicMock()
|
||||
multi = MultiDisplay([mock_display1, mock_display2])
|
||||
|
||||
multi.cleanup()
|
||||
|
||||
mock_display1.cleanup.assert_called_once()
|
||||
mock_display2.cleanup.assert_called_once()
|
||||
|
||||
def test_empty_displays_list(self):
|
||||
"""handles empty displays list gracefully."""
|
||||
multi = MultiDisplay([])
|
||||
multi.init(80, 24)
|
||||
multi.show(["test"])
|
||||
multi.clear()
|
||||
multi.cleanup()
|
||||
|
||||
def test_init_with_reuse(self):
|
||||
"""init passes reuse flag to child displays."""
|
||||
mock_display = MagicMock()
|
||||
multi = MultiDisplay([mock_display])
|
||||
|
||||
multi.init(80, 24, reuse=True)
|
||||
|
||||
mock_display.init.assert_called_once_with(80, 24, reuse=True)
|
||||
427
tests/test_effects.py
Normal file
427
tests/test_effects.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Tests for engine.effects module.
|
||||
"""
|
||||
|
||||
from engine.effects import EffectChain, EffectConfig, EffectContext, EffectRegistry
|
||||
|
||||
|
||||
class MockEffect:
|
||||
name = "mock"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def __init__(self):
|
||||
self.processed = False
|
||||
self.last_ctx = None
|
||||
|
||||
def process(self, buf, ctx):
|
||||
self.processed = True
|
||||
self.last_ctx = ctx
|
||||
return buf + ["processed"]
|
||||
|
||||
def configure(self, config):
|
||||
self.config = config
|
||||
|
||||
|
||||
class TestEffectConfig:
|
||||
def test_defaults(self):
|
||||
cfg = EffectConfig()
|
||||
assert cfg.enabled is True
|
||||
assert cfg.intensity == 1.0
|
||||
assert cfg.params == {}
|
||||
|
||||
def test_custom_values(self):
|
||||
cfg = EffectConfig(enabled=False, intensity=0.5, params={"key": "value"})
|
||||
assert cfg.enabled is False
|
||||
assert cfg.intensity == 0.5
|
||||
assert cfg.params == {"key": "value"}
|
||||
|
||||
|
||||
class TestEffectContext:
|
||||
def test_defaults(self):
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
assert ctx.terminal_width == 80
|
||||
assert ctx.terminal_height == 24
|
||||
assert ctx.ticker_height == 20
|
||||
assert ctx.items == []
|
||||
|
||||
def test_with_items(self):
|
||||
items = [("Title", "Source", "12:00")]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
items=items,
|
||||
)
|
||||
assert ctx.items == items
|
||||
|
||||
|
||||
class TestEffectRegistry:
|
||||
def test_init_empty(self):
|
||||
registry = EffectRegistry()
|
||||
assert len(registry.list_all()) == 0
|
||||
|
||||
def test_register(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
registry.register(effect)
|
||||
assert "mock" in registry.list_all()
|
||||
|
||||
def test_get(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
registry.register(effect)
|
||||
retrieved = registry.get("mock")
|
||||
assert retrieved is effect
|
||||
|
||||
def test_get_nonexistent(self):
|
||||
registry = EffectRegistry()
|
||||
assert registry.get("nonexistent") is None
|
||||
|
||||
def test_enable(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.config.enabled = False
|
||||
registry.register(effect)
|
||||
registry.enable("mock")
|
||||
assert effect.config.enabled is True
|
||||
|
||||
def test_disable(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.config.enabled = True
|
||||
registry.register(effect)
|
||||
registry.disable("mock")
|
||||
assert effect.config.enabled is False
|
||||
|
||||
def test_list_enabled(self):
|
||||
registry = EffectRegistry()
|
||||
|
||||
class EnabledEffect:
|
||||
name = "enabled_effect"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
class DisabledEffect:
|
||||
name = "disabled_effect"
|
||||
config = EffectConfig(enabled=False, intensity=1.0)
|
||||
|
||||
registry.register(EnabledEffect())
|
||||
registry.register(DisabledEffect())
|
||||
enabled = registry.list_enabled()
|
||||
assert len(enabled) == 1
|
||||
assert enabled[0].name == "enabled_effect"
|
||||
|
||||
def test_configure(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
registry.register(effect)
|
||||
new_config = EffectConfig(enabled=False, intensity=0.3)
|
||||
registry.configure("mock", new_config)
|
||||
assert effect.config.enabled is False
|
||||
assert effect.config.intensity == 0.3
|
||||
|
||||
def test_is_enabled(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.config.enabled = True
|
||||
registry.register(effect)
|
||||
assert registry.is_enabled("mock") is True
|
||||
assert registry.is_enabled("nonexistent") is False
|
||||
|
||||
|
||||
class TestEffectChain:
|
||||
def test_init(self):
|
||||
registry = EffectRegistry()
|
||||
chain = EffectChain(registry)
|
||||
assert chain.get_order() == []
|
||||
|
||||
def test_set_order(self):
|
||||
registry = EffectRegistry()
|
||||
effect1 = MockEffect()
|
||||
effect1.name = "effect1"
|
||||
effect2 = MockEffect()
|
||||
effect2.name = "effect2"
|
||||
registry.register(effect1)
|
||||
registry.register(effect2)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["effect1", "effect2"])
|
||||
assert chain.get_order() == ["effect1", "effect2"]
|
||||
|
||||
def test_add_effect(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.add_effect("test_effect")
|
||||
assert "test_effect" in chain.get_order()
|
||||
|
||||
def test_add_effect_invalid(self):
|
||||
registry = EffectRegistry()
|
||||
chain = EffectChain(registry)
|
||||
result = chain.add_effect("nonexistent")
|
||||
assert result is False
|
||||
|
||||
def test_remove_effect(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["test_effect"])
|
||||
chain.remove_effect("test_effect")
|
||||
assert "test_effect" not in chain.get_order()
|
||||
|
||||
def test_reorder(self):
|
||||
registry = EffectRegistry()
|
||||
effect1 = MockEffect()
|
||||
effect1.name = "effect1"
|
||||
effect2 = MockEffect()
|
||||
effect2.name = "effect2"
|
||||
effect3 = MockEffect()
|
||||
effect3.name = "effect3"
|
||||
registry.register(effect1)
|
||||
registry.register(effect2)
|
||||
registry.register(effect3)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["effect1", "effect2", "effect3"])
|
||||
result = chain.reorder(["effect3", "effect1", "effect2"])
|
||||
assert result is True
|
||||
assert chain.get_order() == ["effect3", "effect1", "effect2"]
|
||||
|
||||
def test_reorder_invalid(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "effect1"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
result = chain.reorder(["effect1", "nonexistent"])
|
||||
assert result is False
|
||||
|
||||
def test_process_empty_chain(self):
|
||||
registry = EffectRegistry()
|
||||
chain = EffectChain(registry)
|
||||
buf = ["line1", "line2"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
result = chain.process(buf, ctx)
|
||||
assert result == buf
|
||||
|
||||
def test_process_with_effects(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["test_effect"])
|
||||
buf = ["line1", "line2"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
result = chain.process(buf, ctx)
|
||||
assert result == ["line1", "line2", "processed"]
|
||||
assert effect.processed is True
|
||||
assert effect.last_ctx is ctx
|
||||
|
||||
def test_process_disabled_effect(self):
|
||||
registry = EffectRegistry()
|
||||
effect = MockEffect()
|
||||
effect.name = "test_effect"
|
||||
effect.config.enabled = False
|
||||
registry.register(effect)
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["test_effect"])
|
||||
buf = ["line1"]
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
result = chain.process(buf, ctx)
|
||||
assert result == ["line1"]
|
||||
assert effect.processed is False
|
||||
|
||||
|
||||
class TestEffectsExports:
|
||||
def test_all_exports_are_importable(self):
|
||||
"""Verify all exports in __all__ can actually be imported."""
|
||||
import engine.effects as effects_module
|
||||
|
||||
for name in effects_module.__all__:
|
||||
getattr(effects_module, name)
|
||||
|
||||
|
||||
class TestPerformanceMonitor:
|
||||
def test_empty_stats(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
stats = monitor.get_stats()
|
||||
assert "error" in stats
|
||||
|
||||
def test_record_and_retrieve(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
monitor.start_frame(1)
|
||||
monitor.record_effect("test_effect", 1.5, 100, 150)
|
||||
monitor.end_frame(1, 2.0)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert "error" not in stats
|
||||
assert stats["frame_count"] == 1
|
||||
assert "test_effect" in stats["effects"]
|
||||
|
||||
def test_multiple_frames(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor(max_frames=3)
|
||||
for i in range(5):
|
||||
monitor.start_frame(i)
|
||||
monitor.record_effect("effect1", 1.0, 100, 100)
|
||||
monitor.record_effect("effect2", 0.5, 100, 100)
|
||||
monitor.end_frame(i, 1.5)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert stats["frame_count"] == 3
|
||||
assert "effect1" in stats["effects"]
|
||||
assert "effect2" in stats["effects"]
|
||||
|
||||
def test_reset(self):
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor()
|
||||
monitor.start_frame(1)
|
||||
monitor.record_effect("test", 1.0, 100, 100)
|
||||
monitor.end_frame(1, 1.0)
|
||||
|
||||
monitor.reset()
|
||||
stats = monitor.get_stats()
|
||||
assert "error" in stats
|
||||
|
||||
|
||||
class TestEffectPipelinePerformance:
|
||||
def test_pipeline_stays_within_frame_budget(self):
|
||||
"""Verify effect pipeline completes within frame budget (33ms for 30fps)."""
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectRegistry,
|
||||
)
|
||||
|
||||
class DummyEffect:
|
||||
name = "dummy"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf, ctx):
|
||||
return [line * 2 for line in buf]
|
||||
|
||||
registry = EffectRegistry()
|
||||
registry.register(DummyEffect())
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor(max_frames=10)
|
||||
chain = EffectChain(registry, monitor)
|
||||
chain.set_order(["dummy"])
|
||||
|
||||
buf = ["x" * 80] * 20
|
||||
|
||||
for i in range(10):
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=i,
|
||||
has_message=False,
|
||||
)
|
||||
chain.process(buf, ctx)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert "error" not in stats
|
||||
assert stats["pipeline"]["max_ms"] < 33.0
|
||||
|
||||
def test_individual_effects_performance(self):
|
||||
"""Verify individual effects don't exceed 10ms per frame."""
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectRegistry,
|
||||
)
|
||||
|
||||
class SlowEffect:
|
||||
name = "slow"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf, ctx):
|
||||
result = []
|
||||
for line in buf:
|
||||
result.append(line)
|
||||
result.append(line + line)
|
||||
return result
|
||||
|
||||
registry = EffectRegistry()
|
||||
registry.register(SlowEffect())
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor
|
||||
|
||||
monitor = PerformanceMonitor(max_frames=5)
|
||||
chain = EffectChain(registry, monitor)
|
||||
chain.set_order(["slow"])
|
||||
|
||||
buf = ["x" * 80] * 10
|
||||
|
||||
for i in range(5):
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=i,
|
||||
has_message=False,
|
||||
)
|
||||
chain.process(buf, ctx)
|
||||
|
||||
stats = monitor.get_stats()
|
||||
assert "error" not in stats
|
||||
assert stats["effects"]["slow"]["max_ms"] < 10.0
|
||||
241
tests/test_effects_controller.py
Normal file
241
tests/test_effects_controller.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Tests for engine.effects.controller module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine.effects.controller import (
|
||||
_format_stats,
|
||||
handle_effects_command,
|
||||
set_effect_chain_ref,
|
||||
show_effects_menu,
|
||||
)
|
||||
|
||||
|
||||
class TestHandleEffectsCommand:
|
||||
"""Tests for handle_effects_command function."""
|
||||
|
||||
def test_list_effects(self):
|
||||
"""list command returns formatted effects list."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.config.enabled = True
|
||||
mock_plugin.config.intensity = 0.5
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||
mock_chain.return_value.get_order.return_value = ["noise"]
|
||||
|
||||
result = handle_effects_command("/effects list")
|
||||
|
||||
assert "noise: ON" in result
|
||||
assert "intensity=0.5" in result
|
||||
|
||||
def test_enable_effect(self):
|
||||
"""enable command calls registry.enable."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise on")
|
||||
|
||||
assert "Enabled: noise" in result
|
||||
mock_registry.return_value.enable.assert_called_once_with("noise")
|
||||
|
||||
def test_disable_effect(self):
|
||||
"""disable command calls registry.disable."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise off")
|
||||
|
||||
assert "Disabled: noise" in result
|
||||
mock_registry.return_value.disable.assert_called_once_with("noise")
|
||||
|
||||
def test_set_intensity(self):
|
||||
"""intensity command sets plugin intensity."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.config.intensity = 0.5
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise intensity 0.8")
|
||||
|
||||
assert "intensity to 0.8" in result
|
||||
assert mock_plugin.config.intensity == 0.8
|
||||
|
||||
def test_invalid_intensity_range(self):
|
||||
"""intensity outside 0.0-1.0 returns error."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise intensity 1.5")
|
||||
|
||||
assert "between 0.0 and 1.0" in result
|
||||
|
||||
def test_reorder_pipeline(self):
|
||||
"""reorder command calls chain.reorder."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_registry.return_value.list_all.return_value = {}
|
||||
|
||||
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||
mock_chain_instance = MagicMock()
|
||||
mock_chain_instance.reorder.return_value = True
|
||||
mock_chain.return_value = mock_chain_instance
|
||||
|
||||
result = handle_effects_command("/effects reorder noise,fade")
|
||||
|
||||
assert "Reordered pipeline" in result
|
||||
mock_chain_instance.reorder.assert_called_once_with(["noise", "fade"])
|
||||
|
||||
def test_reorder_failure(self):
|
||||
"""reorder returns error on failure."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_registry.return_value.list_all.return_value = {}
|
||||
|
||||
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||
mock_chain_instance = MagicMock()
|
||||
mock_chain_instance.reorder.return_value = False
|
||||
mock_chain.return_value = mock_chain_instance
|
||||
|
||||
result = handle_effects_command("/effects reorder bad")
|
||||
|
||||
assert "Failed to reorder" in result
|
||||
|
||||
def test_unknown_effect(self):
|
||||
"""unknown effect returns error."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_registry.return_value.list_all.return_value = {}
|
||||
|
||||
result = handle_effects_command("/effects unknown on")
|
||||
|
||||
assert "Unknown effect" in result
|
||||
|
||||
def test_unknown_command(self):
|
||||
"""unknown command returns error."""
|
||||
result = handle_effects_command("/unknown")
|
||||
assert "Unknown command" in result
|
||||
|
||||
def test_non_effects_command(self):
|
||||
"""non-effects command returns error."""
|
||||
result = handle_effects_command("not a command")
|
||||
assert "Unknown command" in result
|
||||
|
||||
def test_invalid_intensity_value(self):
|
||||
"""invalid intensity value returns error."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise intensity bad")
|
||||
|
||||
assert "Invalid intensity" in result
|
||||
|
||||
def test_missing_action(self):
|
||||
"""missing action returns usage."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_registry.return_value.get.return_value = mock_plugin
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
result = handle_effects_command("/effects noise")
|
||||
|
||||
assert "Usage" in result
|
||||
|
||||
def test_stats_command(self):
|
||||
"""stats command returns formatted stats."""
|
||||
with patch("engine.effects.controller.get_monitor") as mock_monitor:
|
||||
mock_monitor.return_value.get_stats.return_value = {
|
||||
"frame_count": 100,
|
||||
"pipeline": {"avg_ms": 1.5, "min_ms": 1.0, "max_ms": 2.0},
|
||||
"effects": {},
|
||||
}
|
||||
|
||||
result = handle_effects_command("/effects stats")
|
||||
|
||||
assert "Performance Stats" in result
|
||||
|
||||
def test_list_only_effects(self):
|
||||
"""list command works with just /effects."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.config.enabled = False
|
||||
mock_plugin.config.intensity = 0.5
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||
mock_chain.return_value = None
|
||||
|
||||
result = handle_effects_command("/effects")
|
||||
|
||||
assert "noise: OFF" in result
|
||||
|
||||
|
||||
class TestShowEffectsMenu:
|
||||
"""Tests for show_effects_menu function."""
|
||||
|
||||
def test_returns_formatted_menu(self):
|
||||
"""returns formatted effects menu."""
|
||||
with patch("engine.effects.controller.get_registry") as mock_registry:
|
||||
mock_plugin = MagicMock()
|
||||
mock_plugin.config.enabled = True
|
||||
mock_plugin.config.intensity = 0.75
|
||||
mock_registry.return_value.list_all.return_value = {"noise": mock_plugin}
|
||||
|
||||
with patch("engine.effects.controller._get_effect_chain") as mock_chain:
|
||||
mock_chain_instance = MagicMock()
|
||||
mock_chain_instance.get_order.return_value = ["noise"]
|
||||
mock_chain.return_value = mock_chain_instance
|
||||
|
||||
result = show_effects_menu()
|
||||
|
||||
assert "EFFECTS MENU" in result
|
||||
assert "noise" in result
|
||||
|
||||
|
||||
class TestFormatStats:
|
||||
"""Tests for _format_stats function."""
|
||||
|
||||
def test_returns_error_when_no_monitor(self):
|
||||
"""returns error when monitor unavailable."""
|
||||
with patch("engine.effects.controller.get_monitor") as mock_monitor:
|
||||
mock_monitor.return_value.get_stats.return_value = {"error": "No data"}
|
||||
|
||||
result = _format_stats()
|
||||
|
||||
assert "No data" in result
|
||||
|
||||
def test_formats_pipeline_stats(self):
|
||||
"""formats pipeline stats correctly."""
|
||||
with patch("engine.effects.controller.get_monitor") as mock_monitor:
|
||||
mock_monitor.return_value.get_stats.return_value = {
|
||||
"frame_count": 50,
|
||||
"pipeline": {"avg_ms": 2.5, "min_ms": 2.0, "max_ms": 3.0},
|
||||
"effects": {"noise": {"avg_ms": 0.5, "min_ms": 0.4, "max_ms": 0.6}},
|
||||
}
|
||||
|
||||
result = _format_stats()
|
||||
|
||||
assert "Pipeline" in result
|
||||
assert "noise" in result
|
||||
|
||||
|
||||
class TestSetEffectChainRef:
|
||||
"""Tests for set_effect_chain_ref function."""
|
||||
|
||||
def test_sets_global_ref(self):
|
||||
"""set_effect_chain_ref updates global reference."""
|
||||
mock_chain = MagicMock()
|
||||
set_effect_chain_ref(mock_chain)
|
||||
|
||||
from engine.effects.controller import _get_effect_chain
|
||||
|
||||
result = _get_effect_chain()
|
||||
assert result == mock_chain
|
||||
202
tests/test_eventbus.py
Normal file
202
tests/test_eventbus.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Tests for engine.eventbus module.
|
||||
"""
|
||||
|
||||
|
||||
from engine.eventbus import EventBus, get_event_bus, set_event_bus
|
||||
from engine.events import EventType, NtfyMessageEvent
|
||||
|
||||
|
||||
class TestEventBusInit:
|
||||
"""Tests for EventBus initialization."""
|
||||
|
||||
def test_init_creates_empty_subscribers(self):
|
||||
"""EventBus starts with no subscribers."""
|
||||
bus = EventBus()
|
||||
assert bus.subscriber_count() == 0
|
||||
|
||||
|
||||
class TestEventBusSubscribe:
|
||||
"""Tests for EventBus.subscribe method."""
|
||||
|
||||
def test_subscribe_adds_callback(self):
|
||||
"""subscribe() adds a callback for an event type."""
|
||||
bus = EventBus()
|
||||
def callback(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||
|
||||
def test_subscribe_multiple_callbacks_same_event(self):
|
||||
"""Multiple callbacks can be subscribed to the same event type."""
|
||||
bus = EventBus()
|
||||
def cb1(e):
|
||||
return None
|
||||
def cb2(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb2)
|
||||
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 2
|
||||
|
||||
def test_subscribe_different_event_types(self):
|
||||
"""Callbacks can be subscribed to different event types."""
|
||||
bus = EventBus()
|
||||
def cb1(e):
|
||||
return None
|
||||
def cb2(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||
bus.subscribe(EventType.MIC_LEVEL, cb2)
|
||||
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||
assert bus.subscriber_count(EventType.MIC_LEVEL) == 1
|
||||
|
||||
|
||||
class TestEventBusUnsubscribe:
|
||||
"""Tests for EventBus.unsubscribe method."""
|
||||
|
||||
def test_unsubscribe_removes_callback(self):
|
||||
"""unsubscribe() removes a callback."""
|
||||
bus = EventBus()
|
||||
def callback(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||
bus.unsubscribe(EventType.NTFY_MESSAGE, callback)
|
||||
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 0
|
||||
|
||||
def test_unsubscribe_nonexistent_callback_no_error(self):
|
||||
"""unsubscribe() handles non-existent callback gracefully."""
|
||||
bus = EventBus()
|
||||
def callback(e):
|
||||
return None
|
||||
|
||||
bus.unsubscribe(EventType.NTFY_MESSAGE, callback)
|
||||
|
||||
|
||||
class TestEventBusPublish:
|
||||
"""Tests for EventBus.publish method."""
|
||||
|
||||
def test_publish_calls_subscriber(self):
|
||||
"""publish() calls the subscriber callback."""
|
||||
bus = EventBus()
|
||||
received = []
|
||||
|
||||
def callback(event):
|
||||
received.append(event)
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||
event = NtfyMessageEvent(title="Test", body="Body")
|
||||
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].title == "Test"
|
||||
|
||||
def test_publish_multiple_subscribers(self):
|
||||
"""publish() calls all subscribers for an event type."""
|
||||
bus = EventBus()
|
||||
received1 = []
|
||||
received2 = []
|
||||
|
||||
def callback1(event):
|
||||
received1.append(event)
|
||||
|
||||
def callback2(event):
|
||||
received2.append(event)
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback1)
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback2)
|
||||
event = NtfyMessageEvent(title="Test", body="Body")
|
||||
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||
|
||||
assert len(received1) == 1
|
||||
assert len(received2) == 1
|
||||
|
||||
def test_publish_different_event_types(self):
|
||||
"""publish() only calls subscribers for the specific event type."""
|
||||
bus = EventBus()
|
||||
ntfy_received = []
|
||||
mic_received = []
|
||||
|
||||
def ntfy_callback(event):
|
||||
ntfy_received.append(event)
|
||||
|
||||
def mic_callback(event):
|
||||
mic_received.append(event)
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, ntfy_callback)
|
||||
bus.subscribe(EventType.MIC_LEVEL, mic_callback)
|
||||
event = NtfyMessageEvent(title="Test", body="Body")
|
||||
bus.publish(EventType.NTFY_MESSAGE, event)
|
||||
|
||||
assert len(ntfy_received) == 1
|
||||
assert len(mic_received) == 0
|
||||
|
||||
|
||||
class TestEventBusClear:
|
||||
"""Tests for EventBus.clear method."""
|
||||
|
||||
def test_clear_removes_all_subscribers(self):
|
||||
"""clear() removes all subscribers."""
|
||||
bus = EventBus()
|
||||
def cb1(e):
|
||||
return None
|
||||
def cb2(e):
|
||||
return None
|
||||
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb1)
|
||||
bus.subscribe(EventType.MIC_LEVEL, cb2)
|
||||
bus.clear()
|
||||
|
||||
assert bus.subscriber_count() == 0
|
||||
|
||||
|
||||
class TestEventBusThreadSafety:
|
||||
"""Tests for EventBus thread safety."""
|
||||
|
||||
def test_concurrent_subscribe_unsubscribe(self):
|
||||
"""subscribe and unsubscribe can be called concurrently."""
|
||||
import threading
|
||||
|
||||
bus = EventBus()
|
||||
callbacks = [lambda e: None for _ in range(10)]
|
||||
|
||||
def subscribe():
|
||||
for cb in callbacks:
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, cb)
|
||||
|
||||
def unsubscribe():
|
||||
for cb in callbacks:
|
||||
bus.unsubscribe(EventType.NTFY_MESSAGE, cb)
|
||||
|
||||
t1 = threading.Thread(target=subscribe)
|
||||
t2 = threading.Thread(target=unsubscribe)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
|
||||
class TestGlobalEventBus:
|
||||
"""Tests for global event bus functions."""
|
||||
|
||||
def test_get_event_bus_returns_singleton(self):
|
||||
"""get_event_bus() returns the same instance."""
|
||||
bus1 = get_event_bus()
|
||||
bus2 = get_event_bus()
|
||||
assert bus1 is bus2
|
||||
|
||||
def test_set_event_bus_replaces_singleton(self):
|
||||
"""set_event_bus() replaces the global event bus."""
|
||||
new_bus = EventBus()
|
||||
set_event_bus(new_bus)
|
||||
try:
|
||||
assert get_event_bus() is new_bus
|
||||
finally:
|
||||
set_event_bus(None)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user