forked from genewildish/Mainline
Compare commits
57 Commits
feature/ve
...
abe49ba7d7
| Author | SHA1 | Date | |
|---|---|---|---|
| abe49ba7d7 | |||
| 6d2c5ba304 | |||
| a95b24a246 | |||
| cdcdb7b172 | |||
| 21fb210c6e | |||
| 36afbacb6b | |||
| 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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,3 +10,5 @@ htmlcov/
|
||||
.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
|
||||
360
AGENTS.md
360
AGENTS.md
@@ -4,145 +4,222 @@
|
||||
|
||||
This project uses:
|
||||
- **mise** (mise.jdx.dev) - tool version manager and task runner
|
||||
- **hk** (hk.jdx.dev) - git hook manager
|
||||
- **uv** - fast Python package installer
|
||||
- **ruff** - linter and formatter
|
||||
- **pytest** - test runner
|
||||
- **ruff** - linter and formatter (line-length 88, target Python 3.10)
|
||||
- **pytest** - test runner with strict marker enforcement
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
mise run install
|
||||
|
||||
# Or equivalently:
|
||||
uv sync --all-extras # includes mic, websocket, sixel support
|
||||
mise run install # Install dependencies
|
||||
# Or: uv sync --all-extras # includes mic, websocket, sixel support
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
|
||||
```bash
|
||||
mise run test # Run tests
|
||||
mise run test-v # Run tests verbose
|
||||
# Testing
|
||||
mise run test # Run all tests
|
||||
mise run test-cov # Run tests with coverage report
|
||||
mise run test-browser # Run e2e browser tests (requires playwright)
|
||||
mise run lint # Run ruff linter
|
||||
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)
|
||||
```
|
||||
|
||||
### Runtime Commands
|
||||
### Running a Single Test
|
||||
|
||||
```bash
|
||||
mise run run # Run mainline (terminal)
|
||||
mise run run-poetry # Run with poetry feed
|
||||
mise run run-firehose # Run in firehose mode
|
||||
mise run run-websocket # Run with WebSocket display only
|
||||
mise run run-sixel # Run with Sixel graphics display
|
||||
mise run run-both # Run with both terminal and WebSocket
|
||||
mise run run-client # Run both + open browser
|
||||
mise run cmd # Run C&C command interface
|
||||
# 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
|
||||
|
||||
**At the start of every agent session**, verify hooks are installed:
|
||||
### Git Hooks
|
||||
|
||||
Install hooks at start of session:
|
||||
```bash
|
||||
ls -la .git/hooks/pre-commit
|
||||
ls -la .git/hooks/pre-commit # Verify installed
|
||||
hk init --mise # Install if missing
|
||||
mise run pre-commit # Run manually
|
||||
```
|
||||
|
||||
If hooks are not installed, install them with:
|
||||
## Code Style Guidelines
|
||||
|
||||
```bash
|
||||
hk init --mise
|
||||
mise run pre-commit
|
||||
### 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
|
||||
```
|
||||
|
||||
**IMPORTANT**: Always review the hk documentation before modifying `hk.pkl`:
|
||||
- [hk Configuration Guide](https://hk.jdx.dev/configuration.html)
|
||||
- [hk Hooks Reference](https://hk.jdx.dev/hooks.html)
|
||||
- [hk Builtins](https://hk.jdx.dev/builtins.html)
|
||||
### Type Hints
|
||||
|
||||
The project uses hk configured in `hk.pkl`:
|
||||
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
||||
- **pre-push**: runs ruff check + benchmark hook
|
||||
- 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
|
||||
|
||||
## Benchmark Runner
|
||||
```python
|
||||
def subscribe(self, event_type: EventType, callback: Callable[[Any], None]) -> None:
|
||||
...
|
||||
|
||||
Run performance benchmarks:
|
||||
|
||||
```bash
|
||||
mise run benchmark # Run all benchmarks (text output)
|
||||
mise run benchmark-json # Run benchmarks (JSON output)
|
||||
mise run benchmark-report # Run benchmarks (Markdown report)
|
||||
def get_sensor_value(self, sensor_name: str) -> float | None:
|
||||
return self._state.get(f"sensor.{sensor_name}")
|
||||
```
|
||||
|
||||
### Benchmark Commands
|
||||
### Naming Conventions
|
||||
|
||||
```bash
|
||||
# Run benchmarks
|
||||
uv run python -m engine.benchmark
|
||||
- **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`)
|
||||
|
||||
# Run with specific displays/effects
|
||||
uv run python -m engine.benchmark --displays null,terminal --effects fade,glitch
|
||||
### Dataclasses
|
||||
|
||||
# Save baseline for hook comparisons
|
||||
uv run python -m engine.benchmark --baseline
|
||||
Use `@dataclass` for simple data containers:
|
||||
|
||||
# Run in hook mode (compares against baseline)
|
||||
uv run python -m engine.benchmark --hook
|
||||
|
||||
# Hook mode with custom threshold (default: 20% degradation)
|
||||
uv run python -m engine.benchmark --hook --threshold 0.3
|
||||
|
||||
# Custom baseline location
|
||||
uv run python -m engine.benchmark --hook --cache /path/to/cache.json
|
||||
```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)
|
||||
```
|
||||
|
||||
### Hook Mode
|
||||
### Abstract Base Classes
|
||||
|
||||
The `--hook` mode compares current benchmarks against a saved baseline. If performance degrades beyond the threshold (default 20%), it exits with code 1. This is useful for preventing performance regressions in feature branches.
|
||||
Use ABC for interface enforcement:
|
||||
|
||||
The pre-push hook runs benchmark in hook mode to catch performance regressions before pushing.
|
||||
```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. **Always run the test suite** - never commit code that fails tests:
|
||||
```bash
|
||||
mise run test
|
||||
```
|
||||
|
||||
2. **Always run the linter**:
|
||||
```bash
|
||||
mise run lint
|
||||
```
|
||||
|
||||
3. **Fix any lint errors** before committing (or let the pre-commit hook handle it).
|
||||
|
||||
4. **Review your changes** using `git diff` to understand what will be committed.
|
||||
1. Run tests: `mise run test`
|
||||
2. Run linter: `mise run lint`
|
||||
3. Review changes: `git diff`
|
||||
|
||||
### On Failing Tests
|
||||
|
||||
When tests fail, **determine whether it's an out-of-date test or a correctly failing test**:
|
||||
|
||||
- **Out-of-date test**: The test was written for old behavior that has legitimately changed. Update the test to match the new expected behavior.
|
||||
|
||||
- **Correctly failing test**: The test correctly identifies a broken contract. Fix the implementation, not the test.
|
||||
- **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.
|
||||
|
||||
### Code Review
|
||||
## Architecture Overview
|
||||
|
||||
Before committing significant changes:
|
||||
- Run `git diff` to review all changes
|
||||
- Ensure new code follows existing patterns in the codebase
|
||||
- Check that type hints are added for new functions
|
||||
- Verify that tests exist for new functionality
|
||||
- **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`.
|
||||
@@ -161,12 +238,11 @@ The project uses pytest with strict marker enforcement. Test configuration is in
|
||||
|
||||
### Test Coverage Strategy
|
||||
|
||||
Current coverage: 56% (336 tests)
|
||||
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
|
||||
- **benchmark.py** (0%): Standalone benchmark tool, runs separately
|
||||
- **scroll.py** (10%): Terminal-dependent rendering logic (unused)
|
||||
|
||||
Key areas with good coverage:
|
||||
- **display/backends/null.py** (95%): Easy to test headlessly
|
||||
@@ -186,11 +262,74 @@ Performance regression tests are in `tests/test_benchmark.py` with `@pytest.mark
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
||||
- **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
|
||||
- **controller.py** coordinates ntfy/mic monitoring and event publishing
|
||||
- **effects/** - plugin architecture with performance monitoring
|
||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||
- 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
|
||||
|
||||
@@ -236,4 +375,39 @@ The rendering pipeline is documented in `docs/PIPELINE.md` using Mermaid diagram
|
||||
**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
|
||||
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
|
||||
|
||||
9
TODO.md
Normal file
9
TODO.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Tasks
|
||||
|
||||
- [ ] Add entropy/chaos score metadata to effects for auto-categorization and intensity control
|
||||
- [ ] Finish ModernGL display backend: integrate window system, implement glyph caching, add event handling, and support border modes.
|
||||
- [x] Integrate UIPanel with pipeline: register stages, link parameter schemas, handle events, implement hot-reload.
|
||||
- [x] Move cached fixture headlines to engine/fixtures/headlines.json and update default source to use fixture.
|
||||
- [x] Add interactive UI panel for pipeline configuration (right-side panel) with stage toggles and param sliders.
|
||||
- [x] Enumerate all effect plugin parameters automatically for UI control (intensity, decay, etc.)
|
||||
- [ ] Implement pipeline hot-rebuild when stage toggles or params change, preserving camera and display state.
|
||||
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
|
||||
@@ -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\)
|
||||
@@ -1,145 +0,0 @@
|
||||
# README Update Design — 2026-03-15
|
||||
|
||||
## Goal
|
||||
|
||||
Restructure and expand `README.md` to:
|
||||
1. Align with the current codebase (Python 3.10+, uv/mise/pytest/ruff toolchain, 6 new fonts)
|
||||
2. Add extensibility-focused content (`Extending` section)
|
||||
3. Add developer workflow coverage (`Development` section)
|
||||
4. Improve navigability via top-level grouping (Approach C)
|
||||
|
||||
---
|
||||
|
||||
## Proposed Structure
|
||||
|
||||
```
|
||||
# MAINLINE
|
||||
> tagline + description
|
||||
|
||||
## Using
|
||||
### Run
|
||||
### Config
|
||||
### Feeds
|
||||
### Fonts
|
||||
### ntfy.sh
|
||||
|
||||
## Internals
|
||||
### How it works
|
||||
### Architecture
|
||||
|
||||
## Extending
|
||||
### NtfyPoller
|
||||
### MicMonitor
|
||||
### Render pipeline
|
||||
|
||||
## Development
|
||||
### Setup
|
||||
### Tasks
|
||||
### Testing
|
||||
### Linting
|
||||
|
||||
## Roadmap
|
||||
|
||||
---
|
||||
*footer*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section-by-section design
|
||||
|
||||
### Using
|
||||
|
||||
All existing content preserved verbatim. Two changes:
|
||||
- **Run**: add `uv run mainline.py` as an alternative invocation; expand bootstrap note to mention `uv sync` / `uv sync --all-extras`
|
||||
- **ntfy.sh**: remove `NtfyPoller` reuse code example (moves to Extending); keep push instructions and topic config
|
||||
|
||||
Subsections moved into Using (currently standalone):
|
||||
- `Feeds` — it's configuration, not a concept
|
||||
- `ntfy.sh` (usage half)
|
||||
|
||||
### Internals
|
||||
|
||||
All existing content preserved verbatim. One change:
|
||||
- **Architecture**: append `tests/` directory listing to the module tree
|
||||
|
||||
### Extending
|
||||
|
||||
Entirely new section. Three subsections:
|
||||
|
||||
**NtfyPoller**
|
||||
- Minimal working import + usage example
|
||||
- Note: stdlib only dependencies
|
||||
|
||||
```python
|
||||
from engine.ntfy import NtfyPoller
|
||||
|
||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||
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
|
||||
```
|
||||
|
||||
**MicMonitor**
|
||||
- Minimal working import + usage example
|
||||
- Note: sounddevice/numpy optional, degrades gracefully
|
||||
|
||||
```python
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
mic = MicMonitor(threshold_db=50)
|
||||
if mic.start(): # returns False if sounddevice unavailable
|
||||
excess = mic.excess # dB above threshold, clamped to 0
|
||||
db = mic.db # raw RMS dB level
|
||||
```
|
||||
|
||||
**Render pipeline**
|
||||
- Brief prose about `engine.render` as importable pipeline
|
||||
- Minimal sketch of serve.py / ESP32 usage pattern
|
||||
- Reference to `Mainline Renderer + ntfy Message Queue for ESP32.md`
|
||||
|
||||
### Development
|
||||
|
||||
Entirely new section. Four subsections:
|
||||
|
||||
**Setup**
|
||||
- Hard requirements: Python 3.10+, uv
|
||||
- `uv sync` / `uv sync --all-extras` / `uv sync --group dev`
|
||||
|
||||
**Tasks** (via mise)
|
||||
- `mise run test`, `test-cov`, `lint`, `lint-fix`, `format`, `run`, `run-poetry`, `run-firehose`
|
||||
|
||||
**Testing**
|
||||
- Tests in `tests/` covering config, filter, mic, ntfy, sources, terminal
|
||||
- `uv run pytest` and `uv run pytest --cov=engine --cov-report=term-missing`
|
||||
|
||||
**Linting**
|
||||
- `uv run ruff check` and `uv run ruff format`
|
||||
- Note: pre-commit hooks run lint via `hk`
|
||||
|
||||
### Roadmap
|
||||
|
||||
Existing `## Ideas / Future` content preserved verbatim. Only change: rename heading to `## Roadmap`.
|
||||
|
||||
### Footer
|
||||
|
||||
Update `Python 3.9+` → `Python 3.10+`.
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
- `README.md` — restructured and expanded as above
|
||||
- No other files
|
||||
|
||||
---
|
||||
|
||||
## What is not changing
|
||||
|
||||
- All existing prose, examples, and config table values — preserved verbatim where retained
|
||||
- The Ideas/Future content — kept intact under the new Roadmap heading
|
||||
- The cyberpunk voice and terse style of the existing README
|
||||
@@ -1,37 +0,0 @@
|
||||
import random
|
||||
|
||||
from engine import config
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import C_DIM, DIM, G_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:
|
||||
for _ in range(min(n_hits, len(result))):
|
||||
gi = random.randint(0, len(result) - 1)
|
||||
scr_row = gi + 1
|
||||
result[gi] = f"\033[{scr_row};1H{self._glitch_bar(ctx.terminal_width)}"
|
||||
return result
|
||||
|
||||
def _glitch_bar(self, w: int) -> str:
|
||||
c = random.choice(["░", "▒", "─", "\xc2"])
|
||||
n = random.randint(3, w // 2)
|
||||
o = random.randint(0, w - n)
|
||||
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
@@ -1,63 +0,0 @@
|
||||
from engine.effects.performance import get_monitor
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class HudEffect(EffectPlugin):
|
||||
name = "hud"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
result = list(buf)
|
||||
monitor = get_monitor()
|
||||
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
if stats and "pipeline" in stats:
|
||||
frame_time = stats["pipeline"].get("avg_ms", 0.0)
|
||||
frame_count = stats.get("frame_count", 0)
|
||||
if frame_count > 0 and frame_time > 0:
|
||||
fps = 1000.0 / frame_time
|
||||
|
||||
w = ctx.terminal_width
|
||||
h = ctx.terminal_height
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
from engine.effects import get_effect_chain
|
||||
|
||||
chain = get_effect_chain()
|
||||
order = chain.get_order()
|
||||
pipeline_str = ",".join(order) if 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
|
||||
@@ -1,340 +0,0 @@
|
||||
"""
|
||||
Animation system - Clock, events, triggers, durations, and animation controller.
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Clock:
|
||||
"""High-resolution clock for animation timing."""
|
||||
|
||||
def __init__(self):
|
||||
self._start_time = time.perf_counter()
|
||||
self._paused = False
|
||||
self._pause_offset = 0.0
|
||||
self._pause_start = 0.0
|
||||
|
||||
def reset(self) -> None:
|
||||
self._start_time = time.perf_counter()
|
||||
self._paused = False
|
||||
self._pause_offset = 0.0
|
||||
self._pause_start = 0.0
|
||||
|
||||
def elapsed(self) -> float:
|
||||
if self._paused:
|
||||
return self._pause_start - self._start_time - self._pause_offset
|
||||
return time.perf_counter() - self._start_time - self._pause_offset
|
||||
|
||||
def elapsed_ms(self) -> float:
|
||||
return self.elapsed() * 1000
|
||||
|
||||
def elapsed_frames(self, fps: float = 60.0) -> int:
|
||||
return int(self.elapsed() * fps)
|
||||
|
||||
def pause(self) -> None:
|
||||
if not self._paused:
|
||||
self._paused = True
|
||||
self._pause_start = time.perf_counter()
|
||||
|
||||
def resume(self) -> None:
|
||||
if self._paused:
|
||||
self._pause_offset += time.perf_counter() - self._pause_start
|
||||
self._paused = False
|
||||
|
||||
|
||||
class TriggerType(Enum):
|
||||
TIME = auto() # Trigger after elapsed time
|
||||
FRAME = auto() # Trigger after N frames
|
||||
CYCLE = auto() # Trigger on cycle repeat
|
||||
CONDITION = auto() # Trigger when condition is met
|
||||
MANUAL = auto() # Trigger manually
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trigger:
|
||||
"""Event trigger configuration."""
|
||||
|
||||
type: TriggerType
|
||||
value: float | int = 0
|
||||
condition: Callable[["AnimationController"], bool] | None = None
|
||||
repeat: bool = False
|
||||
repeat_interval: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
"""An event with trigger, duration, and action."""
|
||||
|
||||
name: str
|
||||
trigger: Trigger
|
||||
action: Callable[["AnimationController", float], None]
|
||||
duration: float = 0.0
|
||||
ease: Callable[[float], float] | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.ease is None:
|
||||
self.ease = linear_ease
|
||||
|
||||
|
||||
def linear_ease(t: float) -> float:
|
||||
return t
|
||||
|
||||
|
||||
def ease_in_out(t: float) -> float:
|
||||
return t * t * (3 - 2 * t)
|
||||
|
||||
|
||||
def ease_out_bounce(t: float) -> float:
|
||||
if t < 1 / 2.75:
|
||||
return 7.5625 * t * t
|
||||
elif t < 2 / 2.75:
|
||||
t -= 1.5 / 2.75
|
||||
return 7.5625 * t * t + 0.75
|
||||
elif t < 2.5 / 2.75:
|
||||
t -= 2.25 / 2.75
|
||||
return 7.5625 * t * t + 0.9375
|
||||
else:
|
||||
t -= 2.625 / 2.75
|
||||
return 7.5625 * t * t + 0.984375
|
||||
|
||||
|
||||
class AnimationController:
|
||||
"""Controls animation parameters with clock and events."""
|
||||
|
||||
def __init__(self, fps: float = 60.0):
|
||||
self.clock = Clock()
|
||||
self.fps = fps
|
||||
self.frame = 0
|
||||
self._events: list[Event] = []
|
||||
self._active_events: dict[str, float] = {}
|
||||
self._params: dict[str, Any] = {}
|
||||
self._cycled = 0
|
||||
|
||||
def add_event(self, event: Event) -> "AnimationController":
|
||||
self._events.append(event)
|
||||
return self
|
||||
|
||||
def set_param(self, key: str, value: Any) -> None:
|
||||
self._params[key] = value
|
||||
|
||||
def get_param(self, key: str, default: Any = None) -> Any:
|
||||
return self._params.get(key, default)
|
||||
|
||||
def update(self) -> dict[str, Any]:
|
||||
"""Update animation state, return current params."""
|
||||
elapsed = self.clock.elapsed()
|
||||
|
||||
for event in self._events:
|
||||
triggered = False
|
||||
|
||||
if event.trigger.type == TriggerType.TIME:
|
||||
if self.clock.elapsed() >= event.trigger.value:
|
||||
triggered = True
|
||||
elif event.trigger.type == TriggerType.FRAME:
|
||||
if self.frame >= event.trigger.value:
|
||||
triggered = True
|
||||
elif event.trigger.type == TriggerType.CYCLE:
|
||||
cycle_duration = event.trigger.value
|
||||
if cycle_duration > 0:
|
||||
current_cycle = int(elapsed / cycle_duration)
|
||||
if current_cycle > self._cycled:
|
||||
self._cycled = current_cycle
|
||||
triggered = True
|
||||
elif event.trigger.type == TriggerType.CONDITION:
|
||||
if event.trigger.condition and event.trigger.condition(self):
|
||||
triggered = True
|
||||
elif event.trigger.type == TriggerType.MANUAL:
|
||||
pass
|
||||
|
||||
if triggered:
|
||||
if event.name not in self._active_events:
|
||||
self._active_events[event.name] = 0.0
|
||||
|
||||
progress = 0.0
|
||||
if event.duration > 0:
|
||||
self._active_events[event.name] += 1 / self.fps
|
||||
progress = min(
|
||||
1.0, self._active_events[event.name] / event.duration
|
||||
)
|
||||
eased_progress = event.ease(progress)
|
||||
event.action(self, eased_progress)
|
||||
|
||||
if progress >= 1.0:
|
||||
if event.trigger.repeat:
|
||||
self._active_events[event.name] = 0.0
|
||||
else:
|
||||
del self._active_events[event.name]
|
||||
else:
|
||||
event.action(self, 1.0)
|
||||
if not event.trigger.repeat:
|
||||
del self._active_events[event.name]
|
||||
else:
|
||||
self._active_events[event.name] = 0.0
|
||||
|
||||
self.frame += 1
|
||||
return dict(self._params)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineParams:
|
||||
"""Snapshot of pipeline parameters for animation."""
|
||||
|
||||
effect_enabled: dict[str, bool] = field(default_factory=dict)
|
||||
effect_intensity: dict[str, float] = field(default_factory=dict)
|
||||
camera_mode: str = "vertical"
|
||||
camera_speed: float = 1.0
|
||||
camera_x: int = 0
|
||||
camera_y: int = 0
|
||||
display_backend: str = "terminal"
|
||||
scroll_speed: float = 1.0
|
||||
|
||||
|
||||
class Preset:
|
||||
"""Packages a starting pipeline config + Animation controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str = "",
|
||||
initial_params: PipelineParams | None = None,
|
||||
animation: AnimationController | None = None,
|
||||
):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.initial_params = initial_params or PipelineParams()
|
||||
self.animation = animation or AnimationController()
|
||||
|
||||
def create_controller(self) -> AnimationController:
|
||||
controller = AnimationController()
|
||||
for key, value in self.initial_params.__dict__.items():
|
||||
controller.set_param(key, value)
|
||||
for event in self.animation._events:
|
||||
controller.add_event(event)
|
||||
return controller
|
||||
|
||||
|
||||
def create_demo_preset() -> Preset:
|
||||
"""Create the demo preset with effect cycling and camera modes."""
|
||||
animation = AnimationController(fps=60)
|
||||
|
||||
effects = ["noise", "fade", "glitch", "firehose"]
|
||||
camera_modes = ["vertical", "horizontal", "omni", "floating", "trace"]
|
||||
|
||||
def make_effect_action(eff):
|
||||
def action(ctrl, t):
|
||||
ctrl.set_param("current_effect", eff)
|
||||
ctrl.set_param("effect_intensity", t)
|
||||
|
||||
return action
|
||||
|
||||
def make_camera_action(cam_mode):
|
||||
def action(ctrl, t):
|
||||
ctrl.set_param("camera_mode", cam_mode)
|
||||
|
||||
return action
|
||||
|
||||
for i, effect in enumerate(effects):
|
||||
effect_duration = 5.0
|
||||
|
||||
animation.add_event(
|
||||
Event(
|
||||
name=f"effect_{effect}",
|
||||
trigger=Trigger(
|
||||
type=TriggerType.TIME,
|
||||
value=i * effect_duration,
|
||||
repeat=True,
|
||||
repeat_interval=len(effects) * effect_duration,
|
||||
),
|
||||
duration=effect_duration,
|
||||
action=make_effect_action(effect),
|
||||
ease=ease_in_out,
|
||||
)
|
||||
)
|
||||
|
||||
for i, mode in enumerate(camera_modes):
|
||||
camera_duration = 10.0
|
||||
animation.add_event(
|
||||
Event(
|
||||
name=f"camera_{mode}",
|
||||
trigger=Trigger(
|
||||
type=TriggerType.TIME,
|
||||
value=i * camera_duration,
|
||||
repeat=True,
|
||||
repeat_interval=len(camera_modes) * camera_duration,
|
||||
),
|
||||
duration=0.5,
|
||||
action=make_camera_action(mode),
|
||||
)
|
||||
)
|
||||
|
||||
animation.add_event(
|
||||
Event(
|
||||
name="pulse",
|
||||
trigger=Trigger(type=TriggerType.CYCLE, value=2.0, repeat=True),
|
||||
duration=1.0,
|
||||
action=lambda ctrl, t: ctrl.set_param("pulse", t),
|
||||
ease=ease_out_bounce,
|
||||
)
|
||||
)
|
||||
|
||||
return Preset(
|
||||
name="demo",
|
||||
description="Demo mode with effect cycling and camera modes",
|
||||
initial_params=PipelineParams(
|
||||
effect_enabled={
|
||||
"noise": False,
|
||||
"fade": False,
|
||||
"glitch": False,
|
||||
"firehose": False,
|
||||
"hud": True,
|
||||
},
|
||||
effect_intensity={
|
||||
"noise": 0.0,
|
||||
"fade": 0.0,
|
||||
"glitch": 0.0,
|
||||
"firehose": 0.0,
|
||||
},
|
||||
camera_mode="vertical",
|
||||
camera_speed=1.0,
|
||||
display_backend="pygame",
|
||||
),
|
||||
animation=animation,
|
||||
)
|
||||
|
||||
|
||||
def create_pipeline_preset() -> Preset:
|
||||
"""Create preset for pipeline visualization."""
|
||||
animation = AnimationController(fps=60)
|
||||
|
||||
animation.add_event(
|
||||
Event(
|
||||
name="camera_trace",
|
||||
trigger=Trigger(type=TriggerType.CYCLE, value=8.0, repeat=True),
|
||||
duration=8.0,
|
||||
action=lambda ctrl, t: ctrl.set_param("camera_mode", "trace"),
|
||||
)
|
||||
)
|
||||
|
||||
animation.add_event(
|
||||
Event(
|
||||
name="highlight_path",
|
||||
trigger=Trigger(type=TriggerType.CYCLE, value=4.0, repeat=True),
|
||||
duration=4.0,
|
||||
action=lambda ctrl, t: ctrl.set_param("path_progress", t),
|
||||
)
|
||||
)
|
||||
|
||||
return Preset(
|
||||
name="pipeline",
|
||||
description="Pipeline visualization with trace camera",
|
||||
initial_params=PipelineParams(
|
||||
camera_mode="trace",
|
||||
camera_speed=1.0,
|
||||
display_backend="pygame",
|
||||
),
|
||||
animation=animation,
|
||||
)
|
||||
1914
engine/app.py
1914
engine/app.py
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,730 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Benchmark runner for mainline - tests performance across effects and displays.
|
||||
Benchmark module for performance testing.
|
||||
|
||||
Usage:
|
||||
python -m engine.benchmark
|
||||
python -m engine.benchmark --output report.md
|
||||
python -m engine.benchmark --displays terminal,websocket --effects glitch,fade
|
||||
python -m engine.benchmark --format json --output benchmark.json
|
||||
|
||||
Headless mode (default): suppress all terminal output during benchmarks.
|
||||
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 json
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
"""Result of a single benchmark run."""
|
||||
|
||||
name: str
|
||||
display: str
|
||||
effect: str | None
|
||||
iterations: int
|
||||
total_time_ms: float
|
||||
avg_time_ms: float
|
||||
std_dev_ms: float
|
||||
min_ms: float
|
||||
max_ms: float
|
||||
fps: float
|
||||
chars_processed: int
|
||||
chars_per_sec: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkReport:
|
||||
"""Complete benchmark report."""
|
||||
|
||||
timestamp: str
|
||||
python_version: str
|
||||
results: list[BenchmarkResult] = field(default_factory=list)
|
||||
summary: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def get_sample_buffer(width: int = 80, height: int = 24) -> list[str]:
|
||||
"""Generate a sample buffer for benchmarking."""
|
||||
lines = []
|
||||
for i in range(height):
|
||||
line = f"\x1b[32mLine {i}\x1b[0m " + "A" * (width - 10)
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def benchmark_display(
|
||||
display_class,
|
||||
buffer: list[str],
|
||||
iterations: int = 100,
|
||||
display=None,
|
||||
reuse: bool = False,
|
||||
) -> BenchmarkResult | None:
|
||||
"""Benchmark a single display.
|
||||
|
||||
Args:
|
||||
display_class: Display class to instantiate
|
||||
buffer: Buffer to display
|
||||
iterations: Number of iterations
|
||||
display: Optional existing display instance to reuse
|
||||
reuse: If True and display provided, use reuse mode
|
||||
"""
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
|
||||
try:
|
||||
sys.stdout = StringIO()
|
||||
sys.stderr = StringIO()
|
||||
|
||||
if display is None:
|
||||
display = display_class()
|
||||
display.init(80, 24, reuse=False)
|
||||
should_cleanup = True
|
||||
else:
|
||||
should_cleanup = False
|
||||
|
||||
times = []
|
||||
chars = sum(len(line) for line in buffer)
|
||||
|
||||
for _ in range(iterations):
|
||||
t0 = time.perf_counter()
|
||||
display.show(buffer)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
times.append(elapsed)
|
||||
|
||||
if should_cleanup and hasattr(display, "cleanup"):
|
||||
display.cleanup(quit_pygame=False)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
times_arr = np.array(times)
|
||||
|
||||
return BenchmarkResult(
|
||||
name=f"display_{display_class.__name__}",
|
||||
display=display_class.__name__,
|
||||
effect=None,
|
||||
iterations=iterations,
|
||||
total_time_ms=sum(times),
|
||||
avg_time_ms=float(np.mean(times_arr)),
|
||||
std_dev_ms=float(np.std(times_arr)),
|
||||
min_ms=float(np.min(times_arr)),
|
||||
max_ms=float(np.max(times_arr)),
|
||||
fps=float(1000.0 / np.mean(times_arr)) if np.mean(times_arr) > 0 else 0.0,
|
||||
chars_processed=chars * iterations,
|
||||
chars_per_sec=float((chars * iterations) / (sum(times) / 1000))
|
||||
if sum(times) > 0
|
||||
else 0.0,
|
||||
)
|
||||
|
||||
|
||||
def benchmark_effect_with_display(
|
||||
effect_class, display, buffer: list[str], iterations: int = 100, reuse: bool = False
|
||||
) -> BenchmarkResult | None:
|
||||
"""Benchmark an effect with a display.
|
||||
|
||||
Args:
|
||||
effect_class: Effect class to instantiate
|
||||
display: Display instance to use
|
||||
buffer: Buffer to process and display
|
||||
iterations: Number of iterations
|
||||
reuse: If True, use reuse mode for display
|
||||
"""
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
|
||||
try:
|
||||
from engine.effects.types import EffectConfig, EffectContext
|
||||
|
||||
sys.stdout = StringIO()
|
||||
sys.stderr = StringIO()
|
||||
|
||||
effect = effect_class()
|
||||
effect.configure(EffectConfig(enabled=True, intensity=1.0))
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=0,
|
||||
mic_excess=0.0,
|
||||
grad_offset=0.0,
|
||||
frame_number=0,
|
||||
has_message=False,
|
||||
)
|
||||
|
||||
times = []
|
||||
chars = sum(len(line) for line in buffer)
|
||||
|
||||
for _ in range(iterations):
|
||||
processed = effect.process(buffer, ctx)
|
||||
t0 = time.perf_counter()
|
||||
display.show(processed)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
times.append(elapsed)
|
||||
|
||||
if not reuse and hasattr(display, "cleanup"):
|
||||
display.cleanup(quit_pygame=False)
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
times_arr = np.array(times)
|
||||
|
||||
return BenchmarkResult(
|
||||
name=f"effect_{effect_class.__name__}_with_{display.__class__.__name__}",
|
||||
display=display.__class__.__name__,
|
||||
effect=effect_class.__name__,
|
||||
iterations=iterations,
|
||||
total_time_ms=sum(times),
|
||||
avg_time_ms=float(np.mean(times_arr)),
|
||||
std_dev_ms=float(np.std(times_arr)),
|
||||
min_ms=float(np.min(times_arr)),
|
||||
max_ms=float(np.max(times_arr)),
|
||||
fps=float(1000.0 / np.mean(times_arr)) if np.mean(times_arr) > 0 else 0.0,
|
||||
chars_processed=chars * iterations,
|
||||
chars_per_sec=float((chars * iterations) / (sum(times) / 1000))
|
||||
if sum(times) > 0
|
||||
else 0.0,
|
||||
)
|
||||
|
||||
|
||||
def get_available_displays():
|
||||
"""Get available display classes."""
|
||||
from engine.display import (
|
||||
DisplayRegistry,
|
||||
NullDisplay,
|
||||
TerminalDisplay,
|
||||
)
|
||||
|
||||
DisplayRegistry.initialize()
|
||||
|
||||
displays = [
|
||||
("null", NullDisplay),
|
||||
("terminal", TerminalDisplay),
|
||||
]
|
||||
|
||||
try:
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
displays.append(("websocket", WebSocketDisplay))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
|
||||
displays.append(("sixel", SixelDisplay))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from engine.display.backends.pygame import PygameDisplay
|
||||
|
||||
displays.append(("pygame", PygameDisplay))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return displays
|
||||
|
||||
|
||||
def get_available_effects():
|
||||
"""Get available effect classes."""
|
||||
try:
|
||||
from engine.effects import get_registry
|
||||
|
||||
try:
|
||||
from effects_plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
effects = []
|
||||
registry = get_registry()
|
||||
|
||||
for name, effect in registry.list_all().items():
|
||||
if effect:
|
||||
effect_cls = type(effect)
|
||||
effects.append((name, effect_cls))
|
||||
|
||||
return effects
|
||||
|
||||
|
||||
def run_benchmarks(
|
||||
displays: list[tuple[str, Any]] | None = None,
|
||||
effects: list[tuple[str, Any]] | None = None,
|
||||
iterations: int = 100,
|
||||
verbose: bool = False,
|
||||
) -> BenchmarkReport:
|
||||
"""Run all benchmarks and return report."""
|
||||
from datetime import datetime
|
||||
|
||||
if displays is None:
|
||||
displays = get_available_displays()
|
||||
|
||||
if effects is None:
|
||||
effects = get_available_effects()
|
||||
|
||||
buffer = get_sample_buffer(80, 24)
|
||||
results = []
|
||||
|
||||
if verbose:
|
||||
print(f"Running benchmarks ({iterations} iterations each)...")
|
||||
|
||||
pygame_display = None
|
||||
for name, display_class in displays:
|
||||
if verbose:
|
||||
print(f"Benchmarking display: {name}")
|
||||
|
||||
result = benchmark_display(display_class, buffer, iterations)
|
||||
if result:
|
||||
results.append(result)
|
||||
if verbose:
|
||||
print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg")
|
||||
|
||||
if name == "pygame":
|
||||
pygame_display = result
|
||||
|
||||
if verbose:
|
||||
print()
|
||||
|
||||
pygame_instance = None
|
||||
if pygame_display:
|
||||
try:
|
||||
from engine.display.backends.pygame import PygameDisplay
|
||||
|
||||
PygameDisplay.reset_state()
|
||||
pygame_instance = PygameDisplay()
|
||||
pygame_instance.init(80, 24, reuse=False)
|
||||
except Exception:
|
||||
pygame_instance = None
|
||||
|
||||
for effect_name, effect_class in effects:
|
||||
for display_name, display_class in displays:
|
||||
if display_name == "websocket":
|
||||
continue
|
||||
|
||||
if display_name == "pygame":
|
||||
if verbose:
|
||||
print(f"Benchmarking effect: {effect_name} with {display_name}")
|
||||
|
||||
if pygame_instance:
|
||||
result = benchmark_effect_with_display(
|
||||
effect_class, pygame_instance, buffer, iterations, reuse=True
|
||||
)
|
||||
if result:
|
||||
results.append(result)
|
||||
if verbose:
|
||||
print(
|
||||
f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg"
|
||||
)
|
||||
continue
|
||||
|
||||
if verbose:
|
||||
print(f"Benchmarking effect: {effect_name} with {display_name}")
|
||||
|
||||
display = display_class()
|
||||
display.init(80, 24)
|
||||
result = benchmark_effect_with_display(
|
||||
effect_class, display, buffer, iterations
|
||||
)
|
||||
if result:
|
||||
results.append(result)
|
||||
if verbose:
|
||||
print(f" {result.fps:.1f} FPS, {result.avg_time_ms:.2f}ms avg")
|
||||
|
||||
if pygame_instance:
|
||||
try:
|
||||
pygame_instance.cleanup(quit_pygame=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
summary = generate_summary(results)
|
||||
|
||||
return BenchmarkReport(
|
||||
timestamp=datetime.now().isoformat(),
|
||||
python_version=sys.version,
|
||||
results=results,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def generate_summary(results: list[BenchmarkResult]) -> dict[str, Any]:
|
||||
"""Generate summary statistics from results."""
|
||||
by_display: dict[str, list[BenchmarkResult]] = {}
|
||||
by_effect: dict[str, list[BenchmarkResult]] = {}
|
||||
|
||||
for r in results:
|
||||
if r.display not in by_display:
|
||||
by_display[r.display] = []
|
||||
by_display[r.display].append(r)
|
||||
|
||||
if r.effect:
|
||||
if r.effect not in by_effect:
|
||||
by_effect[r.effect] = []
|
||||
by_effect[r.effect].append(r)
|
||||
|
||||
summary = {
|
||||
"by_display": {},
|
||||
"by_effect": {},
|
||||
"overall": {
|
||||
"total_tests": len(results),
|
||||
"displays_tested": len(by_display),
|
||||
"effects_tested": len(by_effect),
|
||||
},
|
||||
}
|
||||
|
||||
for display, res in by_display.items():
|
||||
fps_values = [r.fps for r in res]
|
||||
summary["by_display"][display] = {
|
||||
"avg_fps": float(np.mean(fps_values)),
|
||||
"min_fps": float(np.min(fps_values)),
|
||||
"max_fps": float(np.max(fps_values)),
|
||||
"tests": len(res),
|
||||
}
|
||||
|
||||
for effect, res in by_effect.items():
|
||||
fps_values = [r.fps for r in res]
|
||||
summary["by_effect"][effect] = {
|
||||
"avg_fps": float(np.mean(fps_values)),
|
||||
"min_fps": float(np.min(fps_values)),
|
||||
"max_fps": float(np.max(fps_values)),
|
||||
"tests": len(res),
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
DEFAULT_CACHE_PATH = Path.home() / ".mainline_benchmark_cache.json"
|
||||
|
||||
|
||||
def load_baseline(cache_path: Path | None = None) -> dict[str, Any] | None:
|
||||
"""Load baseline benchmark results from cache."""
|
||||
path = cache_path or DEFAULT_CACHE_PATH
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_baseline(
|
||||
results: list[BenchmarkResult],
|
||||
cache_path: Path | None = None,
|
||||
) -> None:
|
||||
"""Save benchmark results as baseline to cache."""
|
||||
path = cache_path or DEFAULT_CACHE_PATH
|
||||
baseline = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"results": {
|
||||
r.name: {
|
||||
"fps": r.fps,
|
||||
"avg_time_ms": r.avg_time_ms,
|
||||
"chars_per_sec": r.chars_per_sec,
|
||||
}
|
||||
for r in results
|
||||
},
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump(baseline, f, indent=2)
|
||||
|
||||
|
||||
def compare_with_baseline(
|
||||
results: list[BenchmarkResult],
|
||||
baseline: dict[str, Any],
|
||||
threshold: float = 0.2,
|
||||
verbose: bool = True,
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Compare current results with baseline. Returns (pass, messages)."""
|
||||
baseline_results = baseline.get("results", {})
|
||||
failures = []
|
||||
warnings = []
|
||||
|
||||
for r in results:
|
||||
if r.name not in baseline_results:
|
||||
warnings.append(f"New test: {r.name} (no baseline)")
|
||||
continue
|
||||
|
||||
b = baseline_results[r.name]
|
||||
if b["fps"] == 0:
|
||||
continue
|
||||
|
||||
degradation = (b["fps"] - r.fps) / b["fps"]
|
||||
if degradation > threshold:
|
||||
failures.append(
|
||||
f"{r.name}: FPS degraded {degradation * 100:.1f}% "
|
||||
f"(baseline: {b['fps']:.1f}, current: {r.fps:.1f})"
|
||||
)
|
||||
elif verbose:
|
||||
print(f" {r.name}: {r.fps:.1f} FPS (baseline: {b['fps']:.1f})")
|
||||
|
||||
passed = len(failures) == 0
|
||||
messages = []
|
||||
if failures:
|
||||
messages.extend(failures)
|
||||
if warnings:
|
||||
messages.extend(warnings)
|
||||
|
||||
return passed, messages
|
||||
|
||||
|
||||
def run_hook_mode(
|
||||
displays: list[tuple[str, Any]] | None = None,
|
||||
effects: list[tuple[str, Any]] | None = None,
|
||||
iterations: int = 20,
|
||||
threshold: float = 0.2,
|
||||
cache_path: Path | None = None,
|
||||
verbose: bool = False,
|
||||
) -> int:
|
||||
"""Run in hook mode: compare against baseline, exit 0 on pass, 1 on fail."""
|
||||
baseline = load_baseline(cache_path)
|
||||
|
||||
if baseline is None:
|
||||
print("No baseline found. Run with --baseline to create one.")
|
||||
return 1
|
||||
|
||||
report = run_benchmarks(displays, effects, iterations, verbose)
|
||||
|
||||
passed, messages = compare_with_baseline(
|
||||
report.results, baseline, threshold, verbose
|
||||
)
|
||||
|
||||
print("\n=== Benchmark Hook Results ===")
|
||||
if passed:
|
||||
print("PASSED - No significant performance degradation")
|
||||
return 0
|
||||
else:
|
||||
print("FAILED - Performance degradation detected:")
|
||||
for msg in messages:
|
||||
print(f" - {msg}")
|
||||
return 1
|
||||
|
||||
|
||||
def format_report_text(report: BenchmarkReport) -> str:
|
||||
"""Format report as human-readable text."""
|
||||
lines = [
|
||||
"# Mainline Performance Benchmark Report",
|
||||
"",
|
||||
f"Generated: {report.timestamp}",
|
||||
f"Python: {report.python_version}",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"Total tests: {report.summary['overall']['total_tests']}",
|
||||
f"Displays tested: {report.summary['overall']['displays_tested']}",
|
||||
f"Effects tested: {report.summary['overall']['effects_tested']}",
|
||||
"",
|
||||
"## By Display",
|
||||
"",
|
||||
]
|
||||
|
||||
for display, stats in report.summary["by_display"].items():
|
||||
lines.append(f"### {display}")
|
||||
lines.append(f"- Avg FPS: {stats['avg_fps']:.1f}")
|
||||
lines.append(f"- Min FPS: {stats['min_fps']:.1f}")
|
||||
lines.append(f"- Max FPS: {stats['max_fps']:.1f}")
|
||||
lines.append(f"- Tests: {stats['tests']}")
|
||||
lines.append("")
|
||||
|
||||
if report.summary["by_effect"]:
|
||||
lines.append("## By Effect")
|
||||
lines.append("")
|
||||
|
||||
for effect, stats in report.summary["by_effect"].items():
|
||||
lines.append(f"### {effect}")
|
||||
lines.append(f"- Avg FPS: {stats['avg_fps']:.1f}")
|
||||
lines.append(f"- Min FPS: {stats['min_fps']:.1f}")
|
||||
lines.append(f"- Max FPS: {stats['max_fps']:.1f}")
|
||||
lines.append(f"- Tests: {stats['tests']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Detailed Results")
|
||||
lines.append("")
|
||||
lines.append("| Display | Effect | FPS | Avg ms | StdDev ms | Min ms | Max ms |")
|
||||
lines.append("|---------|--------|-----|--------|-----------|--------|--------|")
|
||||
|
||||
for r in report.results:
|
||||
effect_col = r.effect if r.effect else "-"
|
||||
lines.append(
|
||||
f"| {r.display} | {effect_col} | {r.fps:.1f} | {r.avg_time_ms:.2f} | "
|
||||
f"{r.std_dev_ms:.2f} | {r.min_ms:.2f} | {r.max_ms:.2f} |"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_report_json(report: BenchmarkReport) -> str:
|
||||
"""Format report as JSON."""
|
||||
data = {
|
||||
"timestamp": report.timestamp,
|
||||
"python_version": report.python_version,
|
||||
"summary": report.summary,
|
||||
"results": [
|
||||
{
|
||||
"name": r.name,
|
||||
"display": r.display,
|
||||
"effect": r.effect,
|
||||
"iterations": r.iterations,
|
||||
"total_time_ms": r.total_time_ms,
|
||||
"avg_time_ms": r.avg_time_ms,
|
||||
"std_dev_ms": r.std_dev_ms,
|
||||
"min_ms": r.min_ms,
|
||||
"max_ms": r.max_ms,
|
||||
"fps": r.fps,
|
||||
"chars_processed": r.chars_processed,
|
||||
"chars_per_sec": r.chars_per_sec,
|
||||
}
|
||||
for r in report.results
|
||||
],
|
||||
}
|
||||
return json.dumps(data, indent=2)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run mainline benchmarks")
|
||||
parser = argparse.ArgumentParser(description="Run performance benchmarks")
|
||||
parser.add_argument(
|
||||
"--displays",
|
||||
help="Comma-separated list of displays to test (default: all)",
|
||||
"--hook",
|
||||
action="store_true",
|
||||
help="Run in hook mode (fail on regression)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--effects",
|
||||
help="Comma-separated list of effects to test (default: all)",
|
||||
"--displays",
|
||||
default="null",
|
||||
help="Comma-separated list of displays to benchmark",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--iterations",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of iterations per test (default: 100)",
|
||||
help="Number of iterations per benchmark",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
help="Output file path (default: stdout)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
help="Output format (default: text)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="store_true",
|
||||
help="Show progress during benchmarking",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hook",
|
||||
action="store_true",
|
||||
help="Run in hook mode: compare against baseline, exit 0 pass, 1 fail",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baseline",
|
||||
action="store_true",
|
||||
help="Save current results as baseline for future hook comparisons",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--threshold",
|
||||
type=float,
|
||||
default=0.2,
|
||||
help="Performance degradation threshold for hook mode (default: 0.2 = 20%%)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to baseline cache file (default: ~/.mainline_benchmark_cache.json)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
cache_path = Path(args.cache) if args.cache else DEFAULT_CACHE_PATH
|
||||
# Run pytest with benchmark markers
|
||||
pytest_args = [
|
||||
"-v",
|
||||
"-m",
|
||||
"benchmark",
|
||||
]
|
||||
|
||||
if args.hook:
|
||||
displays = None
|
||||
if args.displays:
|
||||
display_map = dict(get_available_displays())
|
||||
displays = [
|
||||
(name, display_map[name])
|
||||
for name in args.displays.split(",")
|
||||
if name in display_map
|
||||
# Hook mode: stricter settings
|
||||
pytest_args.extend(
|
||||
[
|
||||
"--benchmark-only",
|
||||
"--benchmark-compare",
|
||||
"--benchmark-compare-fail=min:5%", # Fail if >5% slower
|
||||
]
|
||||
|
||||
effects = None
|
||||
if args.effects:
|
||||
effect_map = dict(get_available_effects())
|
||||
effects = [
|
||||
(name, effect_map[name])
|
||||
for name in args.effects.split(",")
|
||||
if name in effect_map
|
||||
]
|
||||
|
||||
return run_hook_mode(
|
||||
displays,
|
||||
effects,
|
||||
iterations=args.iterations,
|
||||
threshold=args.threshold,
|
||||
cache_path=cache_path,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
displays = None
|
||||
# Add display filter if specified
|
||||
if args.displays:
|
||||
display_map = dict(get_available_displays())
|
||||
displays = [
|
||||
(name, display_map[name])
|
||||
for name in args.displays.split(",")
|
||||
if name in display_map
|
||||
]
|
||||
pytest_args.extend(["-k", args.displays])
|
||||
|
||||
effects = None
|
||||
if args.effects:
|
||||
effect_map = dict(get_available_effects())
|
||||
effects = [
|
||||
(name, effect_map[name])
|
||||
for name in args.effects.split(",")
|
||||
if name in effect_map
|
||||
]
|
||||
# Add iterations
|
||||
if args.iterations:
|
||||
# Set environment variable for benchmark tests
|
||||
import os
|
||||
|
||||
report = run_benchmarks(displays, effects, args.iterations, args.verbose)
|
||||
os.environ["BENCHMARK_ITERATIONS"] = str(args.iterations)
|
||||
|
||||
if args.baseline:
|
||||
save_baseline(report.results, cache_path)
|
||||
print(f"Baseline saved to {cache_path}")
|
||||
return 0
|
||||
# Run pytest
|
||||
import subprocess
|
||||
|
||||
if args.format == "json":
|
||||
output = format_report_json(report)
|
||||
else:
|
||||
output = format_report_text(report)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w") as f:
|
||||
f.write(output)
|
||||
else:
|
||||
print(output)
|
||||
|
||||
return 0
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "tests/test_benchmark.py"] + pytest_args,
|
||||
cwd=None, # Current directory
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
main()
|
||||
|
||||
265
engine/camera.py
265
engine/camera.py
@@ -6,6 +6,8 @@ Provides abstraction for camera motion in different modes:
|
||||
- Horizontal: left/right movement
|
||||
- Omni: combination of both
|
||||
- Floating: sinusoidal/bobbing motion
|
||||
|
||||
The camera defines a visible viewport into a larger Canvas.
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -15,31 +17,108 @@ from enum import Enum, auto
|
||||
|
||||
|
||||
class CameraMode(Enum):
|
||||
VERTICAL = auto()
|
||||
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.VERTICAL
|
||||
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.
|
||||
|
||||
@@ -52,18 +131,48 @@ class Camera:
|
||||
self.custom_update(self, dt)
|
||||
return
|
||||
|
||||
if self.mode == CameraMode.VERTICAL:
|
||||
self._update_vertical(dt)
|
||||
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)
|
||||
|
||||
def _update_vertical(self, dt: float) -> None:
|
||||
# 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)
|
||||
|
||||
@@ -77,31 +186,167 @@ class Camera:
|
||||
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":
|
||||
"""Create a vertical scrolling camera."""
|
||||
return cls(mode=CameraMode.VERTICAL, speed=speed)
|
||||
"""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)
|
||||
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)
|
||||
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)
|
||||
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":
|
||||
|
||||
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
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
Stream controller - manages input sources and orchestrates the render stream.
|
||||
"""
|
||||
|
||||
from engine.config import Config, get_config
|
||||
from engine.display import (
|
||||
DisplayRegistry,
|
||||
KittyDisplay,
|
||||
MultiDisplay,
|
||||
NullDisplay,
|
||||
PygameDisplay,
|
||||
SixelDisplay,
|
||||
TerminalDisplay,
|
||||
WebSocketDisplay,
|
||||
)
|
||||
from engine.effects.controller import handle_effects_command
|
||||
from engine.eventbus import EventBus
|
||||
from engine.events import EventType, StreamEvent
|
||||
from engine.mic import MicMonitor
|
||||
from engine.ntfy import NtfyPoller
|
||||
from engine.scroll import stream
|
||||
|
||||
|
||||
def _get_display(config: Config):
|
||||
"""Get the appropriate display based on config."""
|
||||
DisplayRegistry.initialize()
|
||||
display_mode = config.display.lower()
|
||||
|
||||
displays = []
|
||||
|
||||
if display_mode in ("terminal", "both"):
|
||||
displays.append(TerminalDisplay())
|
||||
|
||||
if display_mode in ("websocket", "both"):
|
||||
ws = WebSocketDisplay(host="0.0.0.0", port=config.websocket_port)
|
||||
ws.start_server()
|
||||
ws.start_http_server()
|
||||
displays.append(ws)
|
||||
|
||||
if display_mode == "sixel":
|
||||
displays.append(SixelDisplay())
|
||||
|
||||
if display_mode == "kitty":
|
||||
displays.append(KittyDisplay())
|
||||
|
||||
if display_mode == "pygame":
|
||||
displays.append(PygameDisplay())
|
||||
|
||||
if not displays:
|
||||
return NullDisplay()
|
||||
|
||||
if len(displays) == 1:
|
||||
return displays[0]
|
||||
|
||||
return MultiDisplay(displays)
|
||||
|
||||
|
||||
class StreamController:
|
||||
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
||||
|
||||
_topics_warmed = False
|
||||
|
||||
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
||||
self.config = config or get_config()
|
||||
self.event_bus = event_bus
|
||||
self.mic: MicMonitor | None = None
|
||||
self.ntfy: NtfyPoller | None = None
|
||||
self.ntfy_cc: NtfyPoller | None = None
|
||||
|
||||
@classmethod
|
||||
def warmup_topics(cls) -> None:
|
||||
"""Warm up ntfy topics lazily (creates them if they don't exist)."""
|
||||
if cls._topics_warmed:
|
||||
return
|
||||
|
||||
import urllib.request
|
||||
|
||||
topics = [
|
||||
"https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd",
|
||||
"https://ntfy.sh/klubhaus_terminal_mainline_cc_resp",
|
||||
"https://ntfy.sh/klubhaus_terminal_mainline",
|
||||
]
|
||||
|
||||
for topic in topics:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
topic,
|
||||
data=b"init",
|
||||
headers={
|
||||
"User-Agent": "mainline/0.1",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cls._topics_warmed = True
|
||||
|
||||
def initialize_sources(self) -> tuple[bool, bool]:
|
||||
"""Initialize microphone and ntfy sources.
|
||||
|
||||
Returns:
|
||||
(mic_ok, ntfy_ok) - success status for each source
|
||||
"""
|
||||
self.mic = MicMonitor(threshold_db=self.config.mic_threshold_db)
|
||||
mic_ok = self.mic.start() if self.mic.available else False
|
||||
|
||||
self.ntfy = NtfyPoller(
|
||||
self.config.ntfy_topic,
|
||||
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||
display_secs=self.config.message_display_secs,
|
||||
)
|
||||
ntfy_ok = self.ntfy.start()
|
||||
|
||||
self.ntfy_cc = NtfyPoller(
|
||||
self.config.ntfy_cc_cmd_topic,
|
||||
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||
display_secs=5,
|
||||
)
|
||||
self.ntfy_cc.subscribe(self._handle_cc_message)
|
||||
ntfy_cc_ok = self.ntfy_cc.start()
|
||||
|
||||
return bool(mic_ok), ntfy_ok and ntfy_cc_ok
|
||||
|
||||
def _handle_cc_message(self, event) -> None:
|
||||
"""Handle incoming C&C message - like a serial port control interface."""
|
||||
import urllib.request
|
||||
|
||||
cmd = event.body.strip() if hasattr(event, "body") else str(event).strip()
|
||||
if not cmd.startswith("/"):
|
||||
return
|
||||
|
||||
response = handle_effects_command(cmd)
|
||||
|
||||
topic_url = self.config.ntfy_cc_resp_topic.replace("/json", "")
|
||||
data = response.encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
topic_url,
|
||||
data=data,
|
||||
headers={"User-Agent": "mainline/0.1", "Content-Type": "text/plain"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self, items: list) -> None:
|
||||
"""Run the stream with initialized sources."""
|
||||
if self.mic is None or self.ntfy is None:
|
||||
self.initialize_sources()
|
||||
|
||||
if self.event_bus:
|
||||
self.event_bus.publish(
|
||||
EventType.STREAM_START,
|
||||
StreamEvent(
|
||||
event_type=EventType.STREAM_START,
|
||||
headline_count=len(items),
|
||||
),
|
||||
)
|
||||
|
||||
display = _get_display(self.config)
|
||||
stream(items, self.ntfy, self.mic, display)
|
||||
if display:
|
||||
display.cleanup()
|
||||
|
||||
if self.event_bus:
|
||||
self.event_bus.publish(
|
||||
EventType.STREAM_END,
|
||||
StreamEvent(
|
||||
event_type=EventType.STREAM_END,
|
||||
headline_count=len(items),
|
||||
),
|
||||
)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self.mic:
|
||||
self.mic.stop()
|
||||
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])
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
Data source abstraction - Treat data sources as first-class citizens in the pipeline.
|
||||
Data sources for the pipeline architecture.
|
||||
|
||||
Each data source implements a common interface:
|
||||
- name: Display name for the source
|
||||
- fetch(): Fetch fresh data
|
||||
- stream(): Stream data continuously (optional)
|
||||
- get_items(): Get current items
|
||||
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
|
||||
@@ -24,6 +24,17 @@ class SourceItem:
|
||||
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.
|
||||
|
||||
@@ -80,6 +91,70 @@ class HeadlinesDataSource(DataSource):
|
||||
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."""
|
||||
|
||||
@@ -94,36 +169,92 @@ class PoetryDataSource(DataSource):
|
||||
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
|
||||
|
||||
|
||||
class PipelineDataSource(DataSource):
|
||||
"""Data source for pipeline visualization (demo mode). Dynamic - updates every frame."""
|
||||
class ImageDataSource(DataSource):
|
||||
"""Data source that loads PNG images from file paths or URLs.
|
||||
|
||||
def __init__(self, viewport_width: int = 80, viewport_height: int = 24):
|
||||
self.viewport_width = viewport_width
|
||||
self.viewport_height = viewport_height
|
||||
self.frame = 0
|
||||
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 "pipeline"
|
||||
return "image"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return True
|
||||
return False # Static images, not updating
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
from engine.pipeline_viz import generate_large_network_viewport
|
||||
def fetch(self) -> list[ImageItem]:
|
||||
"""Return loaded images as ImageItem list."""
|
||||
return self._images
|
||||
|
||||
buffer = generate_large_network_viewport(
|
||||
self.viewport_width, self.viewport_height, self.frame
|
||||
)
|
||||
self.frame += 1
|
||||
content = "\n".join(buffer)
|
||||
return [
|
||||
SourceItem(content=content, source="pipeline", timestamp=f"f{self.frame}")
|
||||
]
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
return self.fetch()
|
||||
def get_items(self) -> list[ImageItem]:
|
||||
"""Return current image items."""
|
||||
return self._images
|
||||
|
||||
|
||||
class MetricsDataSource(DataSource):
|
||||
@@ -340,9 +471,6 @@ class SourceRegistry:
|
||||
def create_poetry(self) -> PoetryDataSource:
|
||||
return PoetryDataSource()
|
||||
|
||||
def create_pipeline(self, width: int = 80, height: int = 24) -> PipelineDataSource:
|
||||
return PipelineDataSource(width, height)
|
||||
|
||||
|
||||
_global_registry: SourceRegistry | None = None
|
||||
|
||||
@@ -5,56 +5,58 @@ Allows swapping output backends via the Display protocol.
|
||||
Supports auto-discovery of display backends.
|
||||
"""
|
||||
|
||||
from enum import Enum, auto
|
||||
from typing import Protocol
|
||||
|
||||
from engine.display.backends.kitty import KittyDisplay
|
||||
# Optional backend - requires moderngl package
|
||||
try:
|
||||
from engine.display.backends.moderngl import ModernGLDisplay
|
||||
|
||||
_MODERNGL_AVAILABLE = True
|
||||
except ImportError:
|
||||
ModernGLDisplay = None
|
||||
_MODERNGL_AVAILABLE = False
|
||||
|
||||
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 BorderMode(Enum):
|
||||
"""Border rendering modes for displays."""
|
||||
|
||||
OFF = auto() # No border
|
||||
SIMPLE = auto() # Traditional border with FPS/frame time
|
||||
UI = auto() # Right-side UI panel with interactive controls
|
||||
|
||||
|
||||
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
|
||||
Required attributes:
|
||||
- width: int
|
||||
- height: int
|
||||
|
||||
The reuse flag allows attaching to an existing display instance
|
||||
rather than creating a new window/connection.
|
||||
Required methods (duck typing - actual signatures may vary):
|
||||
- init(width, height, reuse=False)
|
||||
- show(buffer, border=False)
|
||||
- clear()
|
||||
- cleanup()
|
||||
- get_dimensions() -> (width, height)
|
||||
|
||||
Optional attributes (for UI mode):
|
||||
- ui_panel: UIPanel instance (set by app when border=UI)
|
||||
|
||||
Optional methods:
|
||||
- is_quit_requested() -> bool
|
||||
- clear_quit_request() -> None
|
||||
"""
|
||||
|
||||
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]) -> None:
|
||||
"""Show buffer on display."""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear display."""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Shutdown display."""
|
||||
...
|
||||
|
||||
|
||||
class DisplayRegistry:
|
||||
"""Registry for display backends with auto-discovery."""
|
||||
@@ -64,22 +66,18 @@ class DisplayRegistry:
|
||||
|
||||
@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:
|
||||
@@ -88,19 +86,29 @@ class DisplayRegistry:
|
||||
|
||||
@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)
|
||||
|
||||
if _MODERNGL_AVAILABLE:
|
||||
cls.register("moderngl", ModernGLDisplay) # type: ignore[arg-type]
|
||||
cls._initialized = True
|
||||
|
||||
@classmethod
|
||||
def create_multi(cls, names: list[str]) -> MultiDisplay | None:
|
||||
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."""
|
||||
@@ -112,13 +120,168 @@ def get_monitor():
|
||||
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_simple_border(
|
||||
buf: list[str], width: int, height: int, fps: float = 0.0, frame_time: float = 0.0
|
||||
) -> list[str]:
|
||||
"""Render a traditional border around the buffer."""
|
||||
if not buf or width < 3 or height < 3:
|
||||
return buf
|
||||
|
||||
inner_w = width - 2
|
||||
inner_h = height - 2
|
||||
|
||||
cropped = []
|
||||
for i in range(min(inner_h, len(buf))):
|
||||
line = buf[i]
|
||||
visible_len = len(_strip_ansi(line))
|
||||
if visible_len > inner_w:
|
||||
cropped.append(line[:inner_w])
|
||||
else:
|
||||
cropped.append(line + " " * (inner_w - visible_len))
|
||||
|
||||
while len(cropped) < inner_h:
|
||||
cropped.append(" " * inner_w)
|
||||
|
||||
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 + "┘"
|
||||
|
||||
result = [top_border]
|
||||
for line in cropped:
|
||||
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
|
||||
|
||||
|
||||
def render_ui_panel(
|
||||
buf: list[str],
|
||||
width: int,
|
||||
height: int,
|
||||
ui_panel,
|
||||
fps: float = 0.0,
|
||||
frame_time: float = 0.0,
|
||||
) -> list[str]:
|
||||
"""Render buffer with a right-side UI panel."""
|
||||
from engine.pipeline.ui import UIPanel
|
||||
|
||||
if not isinstance(ui_panel, UIPanel):
|
||||
return _render_simple_border(buf, width, height, fps, frame_time)
|
||||
|
||||
panel_width = min(ui_panel.config.panel_width, width - 4)
|
||||
main_width = width - panel_width - 1
|
||||
|
||||
panel_lines = ui_panel.render(panel_width, height)
|
||||
|
||||
main_buf = buf[: height - 2]
|
||||
main_result = _render_simple_border(
|
||||
main_buf, main_width + 2, height, fps, frame_time
|
||||
)
|
||||
|
||||
combined = []
|
||||
for i in range(height):
|
||||
if i < len(main_result):
|
||||
main_line = main_result[i]
|
||||
if len(main_line) >= 2:
|
||||
main_content = (
|
||||
main_line[1:-1] if main_line[-1] in "│┌┐└┘" else main_line[1:]
|
||||
)
|
||||
main_content = main_content.ljust(main_width)[:main_width]
|
||||
else:
|
||||
main_content = " " * main_width
|
||||
else:
|
||||
main_content = " " * main_width
|
||||
|
||||
panel_idx = i
|
||||
panel_line = (
|
||||
panel_lines[panel_idx][:panel_width].ljust(panel_width)
|
||||
if panel_idx < len(panel_lines)
|
||||
else " " * panel_width
|
||||
)
|
||||
|
||||
separator = "│" if 0 < i < height - 1 else "┼" if i == 0 else "┴"
|
||||
combined.append(main_content + separator + panel_line)
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
def render_border(
|
||||
buf: list[str],
|
||||
width: int,
|
||||
height: int,
|
||||
fps: float = 0.0,
|
||||
frame_time: float = 0.0,
|
||||
border_mode: BorderMode | bool = BorderMode.SIMPLE,
|
||||
) -> list[str]:
|
||||
"""Render a border or UI panel around the buffer.
|
||||
|
||||
Args:
|
||||
buf: Input buffer
|
||||
width: Display width
|
||||
height: Display height
|
||||
fps: FPS for top border
|
||||
frame_time: Frame time for bottom border
|
||||
border_mode: Border rendering mode
|
||||
|
||||
Returns:
|
||||
Buffer with border/panel applied
|
||||
"""
|
||||
# Normalize border_mode to BorderMode enum
|
||||
if isinstance(border_mode, bool):
|
||||
border_mode = BorderMode.SIMPLE if border_mode else BorderMode.OFF
|
||||
|
||||
if border_mode == BorderMode.UI:
|
||||
# UI panel requires a UIPanel instance (injected separately)
|
||||
# For now, this will be called by displays that have a ui_panel attribute
|
||||
# This function signature doesn't include ui_panel, so we'll handle it in render_ui_panel
|
||||
# Fall back to simple border if no panel available
|
||||
return _render_simple_border(buf, width, height, fps, frame_time)
|
||||
elif border_mode == BorderMode.SIMPLE:
|
||||
return _render_simple_border(buf, width, height, fps, frame_time)
|
||||
else:
|
||||
return buf
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Display",
|
||||
"DisplayRegistry",
|
||||
"get_monitor",
|
||||
"render_border",
|
||||
"render_ui_panel",
|
||||
"BorderMode",
|
||||
"TerminalDisplay",
|
||||
"NullDisplay",
|
||||
"WebSocketDisplay",
|
||||
"SixelDisplay",
|
||||
"MultiDisplay",
|
||||
"PygameDisplay",
|
||||
]
|
||||
|
||||
if _MODERNGL_AVAILABLE:
|
||||
__all__.append("ModernGLDisplay")
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
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]) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
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()
|
||||
@@ -30,14 +30,21 @@ class MultiDisplay:
|
||||
for d in self.displays:
|
||||
d.init(width, height, reuse=reuse)
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
for d in self.displays:
|
||||
d.show(buffer)
|
||||
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()
|
||||
|
||||
@@ -9,11 +9,16 @@ class NullDisplay:
|
||||
"""Headless/null display - discards all output.
|
||||
|
||||
This display does nothing - useful for headless benchmarking
|
||||
or when no display output is needed.
|
||||
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.
|
||||
@@ -25,11 +30,50 @@ class NullDisplay:
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._last_buffer = None
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
from engine.display import get_monitor
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
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
|
||||
|
||||
# For debugging: print first few frames to stdout
|
||||
if hasattr(self, "_frame_count"):
|
||||
self._frame_count += 1
|
||||
else:
|
||||
self._frame_count = 0
|
||||
|
||||
# Only print first 5 frames or every 10th frame
|
||||
if self._frame_count <= 5 or self._frame_count % 10 == 0:
|
||||
sys.stdout.write("\n" + "=" * 80 + "\n")
|
||||
sys.stdout.write(
|
||||
f"Frame {self._frame_count} (buffer height: {len(buffer)})\n"
|
||||
)
|
||||
sys.stdout.write("=" * 80 + "\n")
|
||||
for i, line in enumerate(buffer[:30]): # Show first 30 lines
|
||||
sys.stdout.write(f"{i:2}: {line}\n")
|
||||
if len(buffer) > 30:
|
||||
sys.stdout.write(f"... ({len(buffer) - 30} more lines)\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
if monitor:
|
||||
t0 = time.perf_counter()
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
@@ -41,3 +85,19 @@ class NullDisplay:
|
||||
|
||||
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
|
||||
|
||||
@@ -17,7 +17,6 @@ class PygameDisplay:
|
||||
width: int = 80
|
||||
window_width: int = 800
|
||||
window_height: int = 600
|
||||
_pygame_initialized: bool = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -25,6 +24,7 @@ class PygameDisplay:
|
||||
cell_height: int = 18,
|
||||
window_width: int = 800,
|
||||
window_height: int = 600,
|
||||
target_fps: float = 30.0,
|
||||
):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
@@ -32,11 +32,16 @@ class PygameDisplay:
|
||||
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."""
|
||||
@@ -118,6 +123,10 @@ class PygameDisplay:
|
||||
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:
|
||||
@@ -127,11 +136,24 @@ class PygameDisplay:
|
||||
else:
|
||||
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||
|
||||
# Check if font supports box-drawing characters; if not, try to find one
|
||||
self._use_fallback_border = False
|
||||
if self._font:
|
||||
try:
|
||||
# Test rendering some key box-drawing characters
|
||||
test_chars = ["┌", "─", "┐", "│", "└", "┘"]
|
||||
for ch in test_chars:
|
||||
surf = self._font.render(ch, True, (255, 255, 255))
|
||||
# If surface is empty (width=0 or all black), font lacks glyph
|
||||
if surf.get_width() == 0:
|
||||
raise ValueError("Missing glyph")
|
||||
except Exception:
|
||||
# Font doesn't support box-drawing, will use line drawing fallback
|
||||
self._use_fallback_border = True
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
import sys
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
if not self._initialized or not self._pygame:
|
||||
return
|
||||
|
||||
@@ -139,7 +161,15 @@ class PygameDisplay:
|
||||
|
||||
for event in self._pygame.event.get():
|
||||
if event.type == self._pygame.QUIT:
|
||||
sys.exit(0)
|
||||
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
|
||||
@@ -147,39 +177,144 @@ class PygameDisplay:
|
||||
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
|
||||
|
||||
self._screen.fill((0, 0, 0))
|
||||
|
||||
# If border requested but font lacks box-drawing glyphs, use graphical fallback
|
||||
if border and self._use_fallback_border:
|
||||
self._draw_fallback_border(fps, frame_time)
|
||||
# Adjust content area to fit inside border
|
||||
content_offset_x = self.cell_width
|
||||
content_offset_y = self.cell_height
|
||||
self.window_width - 2 * self.cell_width
|
||||
self.window_height - 2 * self.cell_height
|
||||
else:
|
||||
# Normal rendering (with or without text border)
|
||||
content_offset_x = 0
|
||||
content_offset_y = 0
|
||||
|
||||
if border:
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
blit_list = []
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
x_pos = content_offset_x
|
||||
|
||||
for text, fg, bg, _bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bg_surface = self._font.render(text, True, fg, bg)
|
||||
self._screen.blit(bg_surface, (x_pos, row_idx * self.cell_height))
|
||||
else:
|
||||
text_surface = self._font.render(text, True, fg)
|
||||
self._screen.blit(text_surface, (x_pos, row_idx * self.cell_height))
|
||||
# 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, content_offset_y + row_idx * self.cell_height))
|
||||
)
|
||||
x_pos += self._font.size(text)[0]
|
||||
|
||||
self._screen.blits(blit_list)
|
||||
|
||||
# Draw fallback border using graphics if needed
|
||||
if border and self._use_fallback_border:
|
||||
self._draw_fallback_border(fps, frame_time)
|
||||
|
||||
self._pygame.display.flip()
|
||||
|
||||
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("pygame_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def _draw_fallback_border(self, fps: float, frame_time: float) -> None:
|
||||
"""Draw border using pygame graphics primitives instead of text."""
|
||||
if not self._screen or not self._pygame:
|
||||
return
|
||||
|
||||
# Colors
|
||||
border_color = (0, 255, 0) # Green (like terminal border)
|
||||
text_color = (255, 255, 255)
|
||||
|
||||
# Calculate dimensions
|
||||
x1 = 0
|
||||
y1 = 0
|
||||
x2 = self.window_width - 1
|
||||
y2 = self.window_height - 1
|
||||
|
||||
# Draw outer rectangle
|
||||
self._pygame.draw.rect(
|
||||
self._screen, border_color, (x1, y1, x2 - x1 + 1, y2 - y1 + 1), 1
|
||||
)
|
||||
|
||||
# Draw top border with FPS
|
||||
if fps > 0:
|
||||
fps_text = f" FPS:{fps:.0f}"
|
||||
else:
|
||||
fps_text = ""
|
||||
# We need to render this text with a fallback font that has basic ASCII
|
||||
# Use system font which should have these characters
|
||||
try:
|
||||
font = self._font # May not have box chars but should have alphanumeric
|
||||
text_surf = font.render(fps_text, True, text_color, (0, 0, 0))
|
||||
text_rect = text_surf.get_rect()
|
||||
# Position on top border, right-aligned
|
||||
text_x = x2 - text_rect.width - 5
|
||||
text_y = y1 + 2
|
||||
self._screen.blit(text_surf, (text_x, text_y))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Draw bottom border with frame time
|
||||
if frame_time > 0:
|
||||
ft_text = f" {frame_time:.1f}ms"
|
||||
try:
|
||||
ft_surf = self._font.render(ft_text, True, text_color, (0, 0, 0))
|
||||
ft_rect = ft_surf.get_rect()
|
||||
ft_x = x2 - ft_rect.width - 5
|
||||
ft_y = y2 - ft_rect.height - 2
|
||||
self._screen.blit(ft_surf, (ft_x, ft_y))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def clear(self) -> None:
|
||||
if self._screen and self._pygame:
|
||||
self._screen.fill((0, 0, 0))
|
||||
@@ -191,8 +326,17 @@ class PygameDisplay:
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
if self._resized:
|
||||
self._resized = False
|
||||
# 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:
|
||||
@@ -210,3 +354,20 @@ class PygameDisplay:
|
||||
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
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
"""
|
||||
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]) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
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
|
||||
@@ -2,6 +2,7 @@
|
||||
ANSI terminal display backend.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
@@ -10,40 +11,118 @@ class TerminalDisplay:
|
||||
|
||||
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: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
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
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
# 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 show(self, buffer: list[str]) -> None:
|
||||
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()
|
||||
sys.stdout.buffer.write("".join(buffer).encode())
|
||||
|
||||
# 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
|
||||
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
||||
@@ -57,3 +136,11 @@ class TerminalDisplay:
|
||||
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
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"""
|
||||
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
||||
|
||||
TODO: Transform to a true streaming backend with:
|
||||
- Proper WebSocket message streaming (currently sends full buffer each frame)
|
||||
- Connection pooling and backpressure handling
|
||||
- Binary protocol for efficiency (instead of JSON)
|
||||
- Client management with proper async handling
|
||||
- Mark for deprecation if replaced by a new streaming implementation
|
||||
|
||||
Current implementation: Simple broadcast of text frames to all connected clients.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
try:
|
||||
import websockets
|
||||
@@ -14,29 +22,6 @@ except ImportError:
|
||||
websockets = None
|
||||
|
||||
|
||||
class Display(Protocol):
|
||||
"""Protocol for display backends."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def init(self, width: int, height: int) -> None:
|
||||
"""Initialize display with dimensions."""
|
||||
...
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
"""Show buffer on display."""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear display."""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Shutdown display."""
|
||||
...
|
||||
|
||||
|
||||
def get_monitor():
|
||||
"""Get the performance monitor."""
|
||||
try:
|
||||
@@ -101,10 +86,28 @@ class WebSocketDisplay:
|
||||
self.start_server()
|
||||
self.start_http_server()
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
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",
|
||||
@@ -272,3 +275,11 @@ class WebSocketDisplay:
|
||||
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)
|
||||
|
||||
@@ -18,13 +18,6 @@ from engine.effects.types import (
|
||||
create_effect_context,
|
||||
)
|
||||
|
||||
|
||||
def get_effect_chain():
|
||||
from engine.layers import get_effect_chain as _chain
|
||||
|
||||
return _chain()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EffectChain",
|
||||
"EffectRegistry",
|
||||
@@ -34,7 +27,6 @@ __all__ = [
|
||||
"create_effect_context",
|
||||
"get_registry",
|
||||
"set_registry",
|
||||
"get_effect_chain",
|
||||
"get_monitor",
|
||||
"set_monitor",
|
||||
"PerformanceMonitor",
|
||||
|
||||
@@ -2,7 +2,7 @@ import time
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor, get_monitor
|
||||
from engine.effects.registry import EffectRegistry
|
||||
from engine.effects.types import EffectContext
|
||||
from engine.effects.types import EffectContext, PartialUpdate
|
||||
|
||||
|
||||
class EffectChain:
|
||||
@@ -51,6 +51,18 @@ class EffectChain:
|
||||
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:
|
||||
@@ -59,7 +71,11 @@ class EffectChain:
|
||||
chars_in = sum(len(line) for line in result)
|
||||
effect_start = time.perf_counter()
|
||||
try:
|
||||
result = plugin.process(result, ctx)
|
||||
# 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
|
||||
|
||||
@@ -6,14 +6,7 @@ _effect_chain_ref = None
|
||||
|
||||
def _get_effect_chain():
|
||||
global _effect_chain_ref
|
||||
if _effect_chain_ref is not None:
|
||||
return _effect_chain_ref
|
||||
try:
|
||||
from engine.layers import get_effect_chain as _chain
|
||||
|
||||
return _chain()
|
||||
except Exception:
|
||||
return None
|
||||
return _effect_chain_ref
|
||||
|
||||
|
||||
def set_effect_chain_ref(chain) -> None:
|
||||
|
||||
@@ -18,7 +18,7 @@ def discover_plugins():
|
||||
continue
|
||||
|
||||
try:
|
||||
module = __import__(f"effects_plugins.{module_name}", fromlist=[""])
|
||||
module = __import__(f"engine.effects.plugins.{module_name}", fromlist=[""])
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
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
|
||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
class FadeEffect(EffectPlugin):
|
||||
name = "fade"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
config = EffectConfig(enabled=True, intensity=1.0, entropy=0.1)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not ctx.ticker_height:
|
||||
@@ -36,7 +36,7 @@ class FadeEffect(EffectPlugin):
|
||||
if fade >= 1.0:
|
||||
return s
|
||||
if fade <= 0.0:
|
||||
return ""
|
||||
return s # Preserve original line length - don't return empty
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
@@ -9,7 +9,7 @@ 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)
|
||||
config = EffectConfig(enabled=True, intensity=1.0, entropy=0.9)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
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, entropy=0.8)
|
||||
|
||||
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
|
||||
@@ -7,7 +7,7 @@ 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)
|
||||
config = EffectConfig(enabled=True, intensity=0.15, entropy=0.4)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not ctx.ticker_height:
|
||||
@@ -19,7 +19,8 @@ class NoiseEffect(EffectPlugin):
|
||||
for r in range(len(result)):
|
||||
cy = ctx.scroll_cam + r
|
||||
if random.random() < probability:
|
||||
result[r] = self._generate_noise(ctx.terminal_width, cy)
|
||||
original_line = result[r]
|
||||
result[r] = self._generate_noise(len(original_line), cy)
|
||||
return result
|
||||
|
||||
def _generate_noise(self, w: int, cy: int) -> str:
|
||||
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
|
||||
@@ -23,8 +23,32 @@ 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:
|
||||
"""Context passed to effect plugins during processing.
|
||||
|
||||
Contains terminal dimensions, camera state, frame info, and real-time sensor values.
|
||||
"""
|
||||
|
||||
terminal_width: int
|
||||
terminal_height: int
|
||||
scroll_cam: int
|
||||
@@ -35,12 +59,53 @@ class EffectContext:
|
||||
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 compute_entropy(self, effect_name: str, data: Any) -> float:
|
||||
"""Compute entropy score for an effect based on its output.
|
||||
|
||||
Args:
|
||||
effect_name: Name of the effect
|
||||
data: Processed buffer or effect-specific data
|
||||
|
||||
Returns:
|
||||
Entropy score 0.0-1.0 representing visual chaos
|
||||
"""
|
||||
# Default implementation: use effect name as seed for deterministic randomness
|
||||
# Better implementations can analyze actual buffer content
|
||||
import hashlib
|
||||
|
||||
data_str = str(data)[:100] if data else ""
|
||||
hash_val = hashlib.md5(f"{effect_name}:{data_str}".encode()).hexdigest()
|
||||
# Convert hash to float 0.0-1.0
|
||||
entropy = int(hash_val[:8], 16) / 0xFFFFFFFF
|
||||
return min(max(entropy, 0.0), 1.0)
|
||||
|
||||
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
|
||||
entropy: float = 0.0 # Visual chaos metric (0.0 = calm, 1.0 = chaotic)
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -51,6 +116,14 @@ class EffectPlugin(ABC):
|
||||
- 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
|
||||
@@ -63,10 +136,17 @@ class EffectPlugin(ABC):
|
||||
|
||||
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]:
|
||||
@@ -81,6 +161,25 @@ class EffectPlugin(ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
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.
|
||||
@@ -120,3 +219,58 @@ def create_effect_context(
|
||||
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
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
Event emitter protocols - abstract interfaces for event-producing components.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class EventEmitter(Protocol):
|
||||
"""Protocol for components that emit events."""
|
||||
|
||||
def subscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||
def unsubscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||
|
||||
|
||||
class Startable(Protocol):
|
||||
"""Protocol for components that can be started."""
|
||||
|
||||
def start(self) -> Any: ...
|
||||
|
||||
|
||||
class Stoppable(Protocol):
|
||||
"""Protocol for components that can be stopped."""
|
||||
|
||||
def stop(self) -> None: ...
|
||||
@@ -117,11 +117,12 @@ def fetch_poetry():
|
||||
|
||||
|
||||
# ─── CACHE ────────────────────────────────────────────────
|
||||
_CACHE_DIR = pathlib.Path(__file__).resolve().parent.parent
|
||||
# Cache moved to engine/fixtures/headlines.json
|
||||
_CACHE_DIR = pathlib.Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
def _cache_path():
|
||||
return _CACHE_DIR / f".mainline_cache_{config.MODE}.json"
|
||||
return _CACHE_DIR / "headlines.json"
|
||||
|
||||
|
||||
def load_cache():
|
||||
|
||||
19
engine/fixtures/headlines.json
Normal file
19
engine/fixtures/headlines.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"items": [
|
||||
["Breaking: AI systems achieve breakthrough in natural language understanding", "TechDaily", "14:32"],
|
||||
["Scientists discover new exoplanet in habitable zone", "ScienceNews", "13:15"],
|
||||
["Global markets rally as inflation shows signs of cooling", "FinanceWire", "12:48"],
|
||||
["New study reveals benefits of Mediterranean diet for cognitive health", "HealthJournal", "11:22"],
|
||||
["Tech giants announce collaboration on AI safety standards", "TechDaily", "10:55"],
|
||||
["Archaeologists uncover 3000-year-old city in desert", "HistoryNow", "09:30"],
|
||||
["Renewable energy capacity surpasses fossil fuels for first time", "GreenWorld", "08:15"],
|
||||
["Space agency prepares for next Mars mission launch window", "SpaceNews", "07:42"],
|
||||
["New film breaks box office records on opening weekend", "EntertainmentHub", "06:18"],
|
||||
["Local community raises funds for new library project", "CommunityPost", "05:30"],
|
||||
["Quantum computing breakthrough could revolutionize cryptography", "TechWeekly", "15:20"],
|
||||
["New species of deep-sea creature discovered in Pacific trench", "NatureToday", "14:05"],
|
||||
["Electric vehicle sales surpass traditional cars in Europe", "AutoNews", "12:33"],
|
||||
["Renowned artist unveils interactive AI-generated exhibition", "ArtsMonthly", "11:10"],
|
||||
["Climate summit reaches historic agreement on emissions", "WorldNews", "09:55"]
|
||||
]
|
||||
}
|
||||
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",
|
||||
]
|
||||
267
engine/layers.py
267
engine/layers.py
@@ -1,267 +0,0 @@
|
||||
"""
|
||||
Layer compositing — message overlay, ticker zone, firehose, noise.
|
||||
Depends on: config, render, effects.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectContext,
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
noise,
|
||||
vis_offset,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
||||
from engine.terminal import RST, W_COOL
|
||||
|
||||
MSG_META = "\033[38;5;245m"
|
||||
MSG_BORDER = "\033[2;38;5;37m"
|
||||
|
||||
|
||||
def render_message_overlay(
|
||||
msg: tuple[str, str, float] | None,
|
||||
w: int,
|
||||
h: int,
|
||||
msg_cache: tuple,
|
||||
) -> tuple[list[str], tuple]:
|
||||
"""Render ntfy message overlay.
|
||||
|
||||
Args:
|
||||
msg: (title, body, timestamp) or None
|
||||
w: terminal width
|
||||
h: terminal height
|
||||
msg_cache: (cache_key, rendered_rows) for caching
|
||||
|
||||
Returns:
|
||||
(list of ANSI strings, updated cache)
|
||||
"""
|
||||
overlay = []
|
||||
if msg is None:
|
||||
return overlay, msg_cache
|
||||
|
||||
m_title, m_body, m_ts = msg
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
panel_top = max(0, (h - panel_h) // 2)
|
||||
|
||||
row_idx = 0
|
||||
for mr in msg_rows:
|
||||
ln = vis_trunc(mr, w)
|
||||
overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}\033[0m\033[K")
|
||||
row_idx += 1
|
||||
|
||||
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]
|
||||
)
|
||||
overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}\033[0m\033[K")
|
||||
row_idx += 1
|
||||
|
||||
bar = "\u2500" * (w - 4)
|
||||
overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}\033[0m\033[K")
|
||||
|
||||
return overlay, msg_cache
|
||||
|
||||
|
||||
def render_ticker_zone(
|
||||
active: list,
|
||||
scroll_cam: int,
|
||||
camera_x: int = 0,
|
||||
ticker_h: int = 0,
|
||||
w: int = 80,
|
||||
noise_cache: dict | None = None,
|
||||
grad_offset: float = 0.0,
|
||||
) -> tuple[list[str], dict]:
|
||||
"""Render the ticker scroll zone.
|
||||
|
||||
Args:
|
||||
active: list of (content_rows, color, canvas_y, meta_idx)
|
||||
scroll_cam: camera position (viewport top)
|
||||
camera_x: horizontal camera offset
|
||||
ticker_h: height of ticker zone
|
||||
w: terminal width
|
||||
noise_cache: dict of cy -> noise string
|
||||
grad_offset: gradient animation offset
|
||||
|
||||
Returns:
|
||||
(list of ANSI strings, updated noise_cache)
|
||||
"""
|
||||
if noise_cache is None:
|
||||
noise_cache = {}
|
||||
buf = []
|
||||
top_zone = max(1, int(ticker_h * 0.25))
|
||||
bot_zone = max(1, int(ticker_h * 0.10))
|
||||
|
||||
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]
|
||||
|
||||
for r in range(ticker_h):
|
||||
scr_row = r + 1
|
||||
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(vis_offset(colored, camera_x), 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")
|
||||
|
||||
return buf, noise_cache
|
||||
|
||||
|
||||
def apply_glitch(
|
||||
buf: list[str],
|
||||
ticker_buf_start: int,
|
||||
mic_excess: float,
|
||||
w: int,
|
||||
) -> list[str]:
|
||||
"""Apply glitch effect to ticker buffer.
|
||||
|
||||
Args:
|
||||
buf: current buffer
|
||||
ticker_buf_start: index where ticker starts in buffer
|
||||
mic_excess: mic level above threshold
|
||||
w: terminal width
|
||||
|
||||
Returns:
|
||||
Updated buffer with glitches applied
|
||||
"""
|
||||
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)}"
|
||||
|
||||
return buf
|
||||
|
||||
|
||||
def render_firehose(items: list, w: int, fh: int, h: int) -> list[str]:
|
||||
"""Render firehose strip at bottom of screen."""
|
||||
buf = []
|
||||
if 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")
|
||||
return buf
|
||||
|
||||
|
||||
_effect_chain = None
|
||||
|
||||
|
||||
def init_effects() -> None:
|
||||
"""Initialize effect plugins and chain."""
|
||||
global _effect_chain
|
||||
from engine.effects import EffectChain, get_registry
|
||||
|
||||
registry = get_registry()
|
||||
|
||||
import effects_plugins
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||
_effect_chain = chain
|
||||
|
||||
|
||||
def process_effects(
|
||||
buf: list[str],
|
||||
w: int,
|
||||
h: int,
|
||||
scroll_cam: int,
|
||||
ticker_h: 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 | None = None,
|
||||
) -> list[str]:
|
||||
"""Process buffer through effect chain."""
|
||||
if _effect_chain is None:
|
||||
init_effects()
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=w,
|
||||
terminal_height=h,
|
||||
scroll_cam=scroll_cam,
|
||||
camera_x=camera_x,
|
||||
ticker_height=ticker_h,
|
||||
mic_excess=mic_excess,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=has_message,
|
||||
items=items or [],
|
||||
)
|
||||
return _effect_chain.process(buf, ctx)
|
||||
|
||||
|
||||
def get_effect_chain() -> EffectChain | None:
|
||||
"""Get the effect chain instance."""
|
||||
global _effect_chain
|
||||
if _effect_chain is None:
|
||||
init_effects()
|
||||
return _effect_chain
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
Microphone input monitor — standalone, no internal dependencies.
|
||||
Gracefully degrades if sounddevice/numpy are unavailable.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
import numpy as _np
|
||||
import sounddevice as _sd
|
||||
|
||||
_HAS_MIC = True
|
||||
except Exception:
|
||||
_HAS_MIC = False
|
||||
|
||||
|
||||
from engine.events import MicLevelEvent
|
||||
|
||||
|
||||
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
|
||||
self._subscribers: list[Callable[[MicLevelEvent], 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 subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Register a callback to be called when mic level changes."""
|
||||
self._subscribers.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Remove a registered callback."""
|
||||
if callback in self._subscribers:
|
||||
self._subscribers.remove(callback)
|
||||
|
||||
def _emit(self, event: MicLevelEvent) -> None:
|
||||
"""Emit an event to all subscribers."""
|
||||
for cb in self._subscribers:
|
||||
try:
|
||||
cb(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
if self._subscribers:
|
||||
event = MicLevelEvent(
|
||||
db_level=self._db,
|
||||
excess_above_threshold=max(0.0, self._db - self.threshold_db),
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
self._emit(event)
|
||||
|
||||
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
|
||||
@@ -1,611 +0,0 @@
|
||||
"""
|
||||
Pipeline introspection - generates self-documenting diagrams of the render pipeline.
|
||||
|
||||
Pipeline Architecture:
|
||||
- Sources: Data providers (RSS, Poetry, Ntfy, Mic) - static or dynamic
|
||||
- Fetch: Retrieve data from sources
|
||||
- Prepare: Transform raw data (make_block, strip_tags, translate)
|
||||
- Scroll: Camera-based viewport rendering (ticker zone, message overlay)
|
||||
- Effects: Post-processing chain (noise, fade, glitch, firehose, hud)
|
||||
- Render: Final line rendering and layout
|
||||
- Display: Output backends (terminal, pygame, websocket, sixel, kitty)
|
||||
|
||||
Key abstractions:
|
||||
- DataSource: Sources can be static (cached) or dynamic (idempotent fetch)
|
||||
- Camera: Viewport controller (vertical, horizontal, omni, floating, trace)
|
||||
- EffectChain: Ordered effect processing pipeline
|
||||
- Display: Pluggable output backends
|
||||
- SourceRegistry: Source discovery and management
|
||||
- AnimationController: Time-based parameter animation
|
||||
- Preset: Package of initial params + animation for demo modes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineNode:
|
||||
"""Represents a node in the pipeline."""
|
||||
|
||||
name: str
|
||||
module: str
|
||||
class_name: str | None = None
|
||||
func_name: str | None = None
|
||||
description: str = ""
|
||||
inputs: list[str] | None = None
|
||||
outputs: list[str] | None = None
|
||||
metrics: dict | None = None # Performance metrics (avg_ms, min_ms, max_ms)
|
||||
|
||||
|
||||
class PipelineIntrospector:
|
||||
"""Introspects the render pipeline and generates documentation."""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: list[PipelineNode] = []
|
||||
|
||||
def add_node(self, node: PipelineNode) -> None:
|
||||
self.nodes.append(node)
|
||||
|
||||
def generate_mermaid_flowchart(self) -> str:
|
||||
"""Generate a Mermaid flowchart of the pipeline."""
|
||||
lines = ["```mermaid", "flowchart TD"]
|
||||
|
||||
subgraph_groups = {
|
||||
"Sources": [],
|
||||
"Fetch": [],
|
||||
"Prepare": [],
|
||||
"Scroll": [],
|
||||
"Effects": [],
|
||||
"Display": [],
|
||||
"Async": [],
|
||||
"Animation": [],
|
||||
"Viz": [],
|
||||
}
|
||||
|
||||
other_nodes = []
|
||||
|
||||
for node in self.nodes:
|
||||
node_id = node.name.replace("-", "_").replace(" ", "_").replace(":", "_")
|
||||
label = node.name
|
||||
if node.class_name:
|
||||
label = f"{node.name}\\n({node.class_name})"
|
||||
elif node.func_name:
|
||||
label = f"{node.name}\\n({node.func_name})"
|
||||
|
||||
if node.description:
|
||||
label += f"\\n{node.description}"
|
||||
|
||||
if node.metrics:
|
||||
avg = node.metrics.get("avg_ms", 0)
|
||||
if avg > 0:
|
||||
label += f"\\n⏱ {avg:.1f}ms"
|
||||
impact = node.metrics.get("impact_pct", 0)
|
||||
if impact > 0:
|
||||
label += f" ({impact:.0f}%)"
|
||||
|
||||
node_entry = f' {node_id}["{label}"]'
|
||||
|
||||
if "DataSource" in node.name or "SourceRegistry" in node.name:
|
||||
subgraph_groups["Sources"].append(node_entry)
|
||||
elif "fetch" in node.name.lower():
|
||||
subgraph_groups["Fetch"].append(node_entry)
|
||||
elif (
|
||||
"make_block" in node.name
|
||||
or "strip_tags" in node.name
|
||||
or "translate" in node.name
|
||||
):
|
||||
subgraph_groups["Prepare"].append(node_entry)
|
||||
elif (
|
||||
"StreamController" in node.name
|
||||
or "render_ticker" in node.name
|
||||
or "render_message" in node.name
|
||||
or "Camera" in node.name
|
||||
):
|
||||
subgraph_groups["Scroll"].append(node_entry)
|
||||
elif "Effect" in node.name or "effect" in node.module:
|
||||
subgraph_groups["Effects"].append(node_entry)
|
||||
elif "Display:" in node.name:
|
||||
subgraph_groups["Display"].append(node_entry)
|
||||
elif "Ntfy" in node.name or "Mic" in node.name:
|
||||
subgraph_groups["Async"].append(node_entry)
|
||||
elif "Animation" in node.name or "Preset" in node.name:
|
||||
subgraph_groups["Animation"].append(node_entry)
|
||||
elif "pipeline_viz" in node.module or "CameraLarge" in node.name:
|
||||
subgraph_groups["Viz"].append(node_entry)
|
||||
else:
|
||||
other_nodes.append(node_entry)
|
||||
|
||||
for group_name, nodes in subgraph_groups.items():
|
||||
if nodes:
|
||||
lines.append(f" subgraph {group_name}")
|
||||
for node in nodes:
|
||||
lines.append(node)
|
||||
lines.append(" end")
|
||||
|
||||
for node in other_nodes:
|
||||
lines.append(node)
|
||||
|
||||
lines.append("")
|
||||
|
||||
for node in self.nodes:
|
||||
node_id = node.name.replace("-", "_").replace(" ", "_").replace(":", "_")
|
||||
if node.inputs:
|
||||
for inp in node.inputs:
|
||||
inp_id = inp.replace("-", "_").replace(" ", "_").replace(":", "_")
|
||||
lines.append(f" {inp_id} --> {node_id}")
|
||||
|
||||
lines.append("```")
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_mermaid_sequence(self) -> str:
|
||||
"""Generate a Mermaid sequence diagram of message flow."""
|
||||
lines = ["```mermaid", "sequenceDiagram"]
|
||||
|
||||
lines.append(" participant Sources")
|
||||
lines.append(" participant Fetch")
|
||||
lines.append(" participant Scroll")
|
||||
lines.append(" participant Effects")
|
||||
lines.append(" participant Display")
|
||||
|
||||
lines.append(" Sources->>Fetch: headlines")
|
||||
lines.append(" Fetch->>Scroll: content blocks")
|
||||
lines.append(" Scroll->>Effects: buffer")
|
||||
lines.append(" Effects->>Effects: process chain")
|
||||
lines.append(" Effects->>Display: rendered buffer")
|
||||
|
||||
lines.append("```")
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_mermaid_state(self) -> str:
|
||||
"""Generate a Mermaid state diagram of camera modes."""
|
||||
lines = ["```mermaid", "stateDiagram-v2"]
|
||||
|
||||
lines.append(" [*] --> Vertical")
|
||||
lines.append(" Vertical --> Horizontal: set_mode()")
|
||||
lines.append(" Horizontal --> Omni: set_mode()")
|
||||
lines.append(" Omni --> Floating: set_mode()")
|
||||
lines.append(" Floating --> Trace: set_mode()")
|
||||
lines.append(" Trace --> Vertical: set_mode()")
|
||||
|
||||
lines.append(" state Vertical {")
|
||||
lines.append(" [*] --> ScrollUp")
|
||||
lines.append(" ScrollUp --> ScrollUp: +y each frame")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append(" state Horizontal {")
|
||||
lines.append(" [*] --> ScrollLeft")
|
||||
lines.append(" ScrollLeft --> ScrollLeft: +x each frame")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append(" state Omni {")
|
||||
lines.append(" [*] --> Diagonal")
|
||||
lines.append(" Diagonal --> Diagonal: +x, +y")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append(" state Floating {")
|
||||
lines.append(" [*] --> Bobbing")
|
||||
lines.append(" Bobbing --> Bobbing: sin(time)")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append(" state Trace {")
|
||||
lines.append(" [*] --> FollowPath")
|
||||
lines.append(" FollowPath --> FollowPath: node by node")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append("```")
|
||||
return "\n".join(lines)
|
||||
|
||||
def generate_full_diagram(self) -> str:
|
||||
"""Generate full pipeline documentation."""
|
||||
lines = [
|
||||
"# Render Pipeline",
|
||||
"",
|
||||
"## Data Flow",
|
||||
"",
|
||||
self.generate_mermaid_flowchart(),
|
||||
"",
|
||||
"## Message Sequence",
|
||||
"",
|
||||
self.generate_mermaid_sequence(),
|
||||
"",
|
||||
"## Camera States",
|
||||
"",
|
||||
self.generate_mermaid_state(),
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def introspect_sources(self) -> None:
|
||||
"""Introspect data sources."""
|
||||
from engine import sources
|
||||
|
||||
for name in dir(sources):
|
||||
obj = getattr(sources, name)
|
||||
if isinstance(obj, dict):
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name=f"Data Source: {name}",
|
||||
module="engine.sources",
|
||||
description=f"{len(obj)} feeds configured",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_sources_v2(self) -> None:
|
||||
"""Introspect data sources v2 (new abstraction)."""
|
||||
from engine.sources_v2 import SourceRegistry, init_default_sources
|
||||
|
||||
init_default_sources()
|
||||
SourceRegistry()
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="SourceRegistry",
|
||||
module="engine.sources_v2",
|
||||
class_name="SourceRegistry",
|
||||
description="Source discovery and management",
|
||||
)
|
||||
)
|
||||
|
||||
for name, desc in [
|
||||
("HeadlinesDataSource", "RSS feed headlines"),
|
||||
("PoetryDataSource", "Poetry DB"),
|
||||
("PipelineDataSource", "Pipeline viz (dynamic)"),
|
||||
]:
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name=f"DataSource: {name}",
|
||||
module="engine.sources_v2",
|
||||
class_name=name,
|
||||
description=f"{desc}",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_prepare(self) -> None:
|
||||
"""Introspect prepare layer (transformation)."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="make_block",
|
||||
module="engine.render",
|
||||
func_name="make_block",
|
||||
description="Transform headline into display block",
|
||||
inputs=["title", "source", "timestamp", "width"],
|
||||
outputs=["block"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="strip_tags",
|
||||
module="engine.filter",
|
||||
func_name="strip_tags",
|
||||
description="Remove HTML tags from content",
|
||||
inputs=["html"],
|
||||
outputs=["plain_text"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="translate_headline",
|
||||
module="engine.translate",
|
||||
func_name="translate_headline",
|
||||
description="Translate headline to target language",
|
||||
inputs=["title", "target_lang"],
|
||||
outputs=["translated_title"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_fetch(self) -> None:
|
||||
"""Introspect fetch layer."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="fetch_all",
|
||||
module="engine.fetch",
|
||||
func_name="fetch_all",
|
||||
description="Fetch RSS feeds",
|
||||
outputs=["items"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="fetch_poetry",
|
||||
module="engine.fetch",
|
||||
func_name="fetch_poetry",
|
||||
description="Fetch Poetry DB",
|
||||
outputs=["items"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_scroll(self) -> None:
|
||||
"""Introspect scroll engine."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="StreamController",
|
||||
module="engine.controller",
|
||||
class_name="StreamController",
|
||||
description="Main render loop orchestrator",
|
||||
inputs=["items", "ntfy_poller", "mic_monitor", "display"],
|
||||
outputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="render_ticker_zone",
|
||||
module="engine.layers",
|
||||
func_name="render_ticker_zone",
|
||||
description="Render scrolling ticker content",
|
||||
inputs=["active", "camera"],
|
||||
outputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="render_message_overlay",
|
||||
module="engine.layers",
|
||||
func_name="render_message_overlay",
|
||||
description="Render ntfy message overlay",
|
||||
inputs=["msg", "width", "height"],
|
||||
outputs=["overlay", "cache"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_render(self) -> None:
|
||||
"""Introspect render layer."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="big_wrap",
|
||||
module="engine.render",
|
||||
func_name="big_wrap",
|
||||
description="Word-wrap text to width",
|
||||
inputs=["text", "width"],
|
||||
outputs=["lines"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="lr_gradient",
|
||||
module="engine.render",
|
||||
func_name="lr_gradient",
|
||||
description="Apply left-right gradient to lines",
|
||||
inputs=["lines", "position"],
|
||||
outputs=["styled_lines"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_async_sources(self) -> None:
|
||||
"""Introspect async data sources (ntfy, mic)."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="NtfyPoller",
|
||||
module="engine.ntfy",
|
||||
class_name="NtfyPoller",
|
||||
description="Poll ntfy for messages (async)",
|
||||
inputs=["topic"],
|
||||
outputs=["message"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="MicMonitor",
|
||||
module="engine.mic",
|
||||
class_name="MicMonitor",
|
||||
description="Monitor microphone input (async)",
|
||||
outputs=["audio_level"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_eventbus(self) -> None:
|
||||
"""Introspect event bus for decoupled communication."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="EventBus",
|
||||
module="engine.eventbus",
|
||||
class_name="EventBus",
|
||||
description="Thread-safe event publishing",
|
||||
inputs=["event"],
|
||||
outputs=["subscribers"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_animation(self) -> None:
|
||||
"""Introspect animation system."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="AnimationController",
|
||||
module="engine.animation",
|
||||
class_name="AnimationController",
|
||||
description="Time-based parameter animation",
|
||||
inputs=["dt"],
|
||||
outputs=["params"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="Preset",
|
||||
module="engine.animation",
|
||||
class_name="Preset",
|
||||
description="Package of initial params + animation",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_pipeline_viz(self) -> None:
|
||||
"""Introspect pipeline visualization."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="generate_large_network_viewport",
|
||||
module="engine.pipeline_viz",
|
||||
func_name="generate_large_network_viewport",
|
||||
description="Large animated network visualization",
|
||||
inputs=["viewport_w", "viewport_h", "frame"],
|
||||
outputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="CameraLarge",
|
||||
module="engine.pipeline_viz",
|
||||
class_name="CameraLarge",
|
||||
description="Large grid camera (trace mode)",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_camera(self) -> None:
|
||||
"""Introspect camera system."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="Camera",
|
||||
module="engine.camera",
|
||||
class_name="Camera",
|
||||
description="Viewport position controller",
|
||||
inputs=["dt"],
|
||||
outputs=["x", "y"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_effects(self) -> None:
|
||||
"""Introspect effect system."""
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="EffectChain",
|
||||
module="engine.effects",
|
||||
class_name="EffectChain",
|
||||
description="Process effects in sequence",
|
||||
inputs=["buffer", "context"],
|
||||
outputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name="EffectRegistry",
|
||||
module="engine.effects",
|
||||
class_name="EffectRegistry",
|
||||
description="Manage effect plugins",
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_display(self) -> None:
|
||||
"""Introspect display backends."""
|
||||
from engine.display import DisplayRegistry
|
||||
|
||||
DisplayRegistry.initialize()
|
||||
backends = DisplayRegistry.list_backends()
|
||||
|
||||
for backend in backends:
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name=f"Display: {backend}",
|
||||
module="engine.display.backends",
|
||||
class_name=f"{backend.title()}Display",
|
||||
description=f"Render to {backend}",
|
||||
inputs=["buffer"],
|
||||
)
|
||||
)
|
||||
|
||||
def introspect_new_pipeline(self, pipeline=None) -> None:
|
||||
"""Introspect new unified pipeline stages with metrics.
|
||||
|
||||
Args:
|
||||
pipeline: Optional Pipeline instance to collect metrics from
|
||||
"""
|
||||
|
||||
stages_info = [
|
||||
(
|
||||
"ItemsSource",
|
||||
"engine.pipeline.adapters",
|
||||
"ItemsStage",
|
||||
"Provides pre-fetched items",
|
||||
),
|
||||
(
|
||||
"Render",
|
||||
"engine.pipeline.adapters",
|
||||
"RenderStage",
|
||||
"Renders items to buffer",
|
||||
),
|
||||
(
|
||||
"Effect",
|
||||
"engine.pipeline.adapters",
|
||||
"EffectPluginStage",
|
||||
"Applies effect",
|
||||
),
|
||||
(
|
||||
"Display",
|
||||
"engine.pipeline.adapters",
|
||||
"DisplayStage",
|
||||
"Outputs to display",
|
||||
),
|
||||
]
|
||||
|
||||
metrics = None
|
||||
if pipeline and hasattr(pipeline, "get_metrics_summary"):
|
||||
metrics = pipeline.get_metrics_summary()
|
||||
if "error" in metrics:
|
||||
metrics = None
|
||||
|
||||
total_avg = metrics.get("pipeline", {}).get("avg_ms", 0) if metrics else 0
|
||||
|
||||
for stage_name, module, class_name, desc in stages_info:
|
||||
node_metrics = None
|
||||
if metrics and "stages" in metrics:
|
||||
for name, stats in metrics["stages"].items():
|
||||
if stage_name.lower() in name.lower():
|
||||
impact_pct = (
|
||||
(stats.get("avg_ms", 0) / total_avg * 100)
|
||||
if total_avg > 0
|
||||
else 0
|
||||
)
|
||||
node_metrics = {
|
||||
"avg_ms": stats.get("avg_ms", 0),
|
||||
"min_ms": stats.get("min_ms", 0),
|
||||
"max_ms": stats.get("max_ms", 0),
|
||||
"impact_pct": impact_pct,
|
||||
}
|
||||
break
|
||||
|
||||
self.add_node(
|
||||
PipelineNode(
|
||||
name=f"Pipeline: {stage_name}",
|
||||
module=module,
|
||||
class_name=class_name,
|
||||
description=desc,
|
||||
inputs=["data"],
|
||||
outputs=["data"],
|
||||
metrics=node_metrics,
|
||||
)
|
||||
)
|
||||
|
||||
def run(self) -> str:
|
||||
"""Run full introspection."""
|
||||
self.introspect_sources()
|
||||
self.introspect_sources_v2()
|
||||
self.introspect_fetch()
|
||||
self.introspect_prepare()
|
||||
self.introspect_scroll()
|
||||
self.introspect_render()
|
||||
self.introspect_camera()
|
||||
self.introspect_effects()
|
||||
self.introspect_display()
|
||||
self.introspect_async_sources()
|
||||
self.introspect_eventbus()
|
||||
self.introspect_animation()
|
||||
self.introspect_pipeline_viz()
|
||||
|
||||
return self.generate_full_diagram()
|
||||
|
||||
|
||||
def generate_pipeline_diagram() -> str:
|
||||
"""Generate a self-documenting pipeline diagram."""
|
||||
introspector = PipelineIntrospector()
|
||||
return introspector.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(generate_pipeline_diagram())
|
||||
@@ -50,8 +50,7 @@ from engine.pipeline.presets import (
|
||||
FIREHOSE_PRESET,
|
||||
PIPELINE_VIZ_PRESET,
|
||||
POETRY_PRESET,
|
||||
PRESETS,
|
||||
SIXEL_PRESET,
|
||||
UI_PRESET,
|
||||
WEBSOCKET_PRESET,
|
||||
PipelinePreset,
|
||||
create_preset_from_params,
|
||||
@@ -92,8 +91,8 @@ __all__ = [
|
||||
"POETRY_PRESET",
|
||||
"PIPELINE_VIZ_PRESET",
|
||||
"WEBSOCKET_PRESET",
|
||||
"SIXEL_PRESET",
|
||||
"FIREHOSE_PRESET",
|
||||
"UI_PRESET",
|
||||
"get_preset",
|
||||
"list_presets",
|
||||
"create_preset_from_params",
|
||||
|
||||
@@ -5,122 +5,11 @@ This module provides adapters that wrap existing components
|
||||
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||
"""
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from engine.pipeline.core import PipelineContext, Stage
|
||||
|
||||
|
||||
class RenderStage(Stage):
|
||||
"""Stage that renders items to a text buffer for display.
|
||||
|
||||
This mimics the old demo's render pipeline:
|
||||
- Selects headlines and renders them to blocks
|
||||
- Applies camera scroll position
|
||||
- Adds firehose layer if enabled
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
items: list,
|
||||
width: int = 80,
|
||||
height: int = 24,
|
||||
camera_speed: float = 1.0,
|
||||
camera_mode: str = "vertical",
|
||||
firehose_enabled: bool = False,
|
||||
name: str = "render",
|
||||
):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = False
|
||||
self._items = items
|
||||
self._width = width
|
||||
self._height = height
|
||||
self._camera_speed = camera_speed
|
||||
self._camera_mode = camera_mode
|
||||
self._firehose_enabled = firehose_enabled
|
||||
|
||||
self._camera_y = 0.0
|
||||
self._camera_x = 0
|
||||
self._scroll_accum = 0.0
|
||||
self._ticker_next_y = 0
|
||||
self._active: list = []
|
||||
self._seen: set = set()
|
||||
self._pool: list = list(items)
|
||||
self._noise_cache: dict = {}
|
||||
self._frame_count = 0
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source.items"}
|
||||
|
||||
def init(self, ctx: PipelineContext) -> bool:
|
||||
random.shuffle(self._pool)
|
||||
return True
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Render items to a text buffer."""
|
||||
from engine.effects import next_headline
|
||||
from engine.layers import render_firehose, render_ticker_zone
|
||||
from engine.render import make_block
|
||||
|
||||
items = data or self._items
|
||||
w = ctx.params.viewport_width if ctx.params else self._width
|
||||
h = ctx.params.viewport_height if ctx.params else self._height
|
||||
camera_speed = ctx.params.camera_speed if ctx.params else self._camera_speed
|
||||
firehose = ctx.params.firehose_enabled if ctx.params else self._firehose_enabled
|
||||
|
||||
scroll_step = 0.5 / (camera_speed * 10)
|
||||
self._scroll_accum += scroll_step
|
||||
|
||||
GAP = 3
|
||||
|
||||
while self._scroll_accum >= scroll_step:
|
||||
self._scroll_accum -= scroll_step
|
||||
self._camera_y += 1.0
|
||||
|
||||
while (
|
||||
self._ticker_next_y < int(self._camera_y) + h + 10
|
||||
and len(self._active) < 50
|
||||
):
|
||||
t, src, ts = next_headline(self._pool, items, self._seen)
|
||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||
self._active.append((ticker_content, hc, self._ticker_next_y, midx))
|
||||
self._ticker_next_y += len(ticker_content) + GAP
|
||||
|
||||
self._active = [
|
||||
(c, hc, by, mi)
|
||||
for c, hc, by, mi in self._active
|
||||
if by + len(c) > int(self._camera_y)
|
||||
]
|
||||
for k in list(self._noise_cache):
|
||||
if k < int(self._camera_y):
|
||||
del self._noise_cache[k]
|
||||
|
||||
grad_offset = (self._frame_count * 0.01) % 1.0
|
||||
|
||||
buf, self._noise_cache = render_ticker_zone(
|
||||
self._active,
|
||||
scroll_cam=int(self._camera_y),
|
||||
camera_x=self._camera_x,
|
||||
ticker_h=h,
|
||||
w=w,
|
||||
noise_cache=self._noise_cache,
|
||||
grad_offset=grad_offset,
|
||||
)
|
||||
|
||||
if firehose:
|
||||
firehose_buf = render_firehose(items, w, 0, h)
|
||||
buf.extend(firehose_buf)
|
||||
|
||||
self._frame_count += 1
|
||||
return buf
|
||||
|
||||
|
||||
class EffectPluginStage(Stage):
|
||||
"""Adapter wrapping EffectPlugin as a Stage."""
|
||||
|
||||
@@ -130,6 +19,35 @@ class EffectPluginStage(Stage):
|
||||
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}"}
|
||||
@@ -138,11 +56,23 @@ class EffectPluginStage(Stage):
|
||||
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 import EffectContext
|
||||
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
|
||||
@@ -160,6 +90,21 @@ class EffectPluginStage(Stage):
|
||||
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)
|
||||
|
||||
|
||||
@@ -178,7 +123,19 @@ class DisplayStage(Stage):
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
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
|
||||
@@ -213,6 +170,18 @@ class DataSourceStage(Stage):
|
||||
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"):
|
||||
@@ -220,26 +189,114 @@ class DataSourceStage(Stage):
|
||||
return data
|
||||
|
||||
|
||||
class ItemsStage(Stage):
|
||||
"""Stage that holds pre-fetched items and provides them to the pipeline."""
|
||||
class PassthroughStage(Stage):
|
||||
"""Simple stage that passes data through unchanged.
|
||||
|
||||
def __init__(self, items, name: str = "headlines"):
|
||||
self._items = items
|
||||
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 = "source"
|
||||
self.optional = False
|
||||
self.category = "render"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "render"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"source.{self.name}"}
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
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:
|
||||
"""Return the pre-fetched items."""
|
||||
return self._items
|
||||
"""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):
|
||||
@@ -257,16 +314,76 @@ class CameraStage(Stage):
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source.items"}
|
||||
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:
|
||||
return None
|
||||
if data is None or (isinstance(data, list) and len(data) == 0):
|
||||
return data
|
||||
if hasattr(self._camera, "apply"):
|
||||
return self._camera.apply(
|
||||
data, ctx.params.viewport_width if ctx.params else 80
|
||||
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:
|
||||
@@ -274,6 +391,342 @@ class CameraStage(Stage):
|
||||
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)
|
||||
@@ -294,6 +747,99 @@ def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
|
||||
return CameraStage(camera, name)
|
||||
|
||||
|
||||
def create_items_stage(items, name: str = "headlines") -> ItemsStage:
|
||||
"""Create a Stage that holds pre-fetched items."""
|
||||
return ItemsStage(items, 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
|
||||
|
||||
@@ -83,12 +83,61 @@ class Pipeline:
|
||||
|
||||
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."""
|
||||
"""Resolve stage execution order using topological sort with capability matching."""
|
||||
ordered = []
|
||||
visited = set()
|
||||
temp_mark = set()
|
||||
@@ -103,9 +152,10 @@ class Pipeline:
|
||||
stage = self._stages.get(name)
|
||||
if stage:
|
||||
for dep in stage.dependencies:
|
||||
dep_stage = self._stages.get(dep)
|
||||
if dep_stage:
|
||||
visit(dep)
|
||||
# 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)
|
||||
@@ -117,6 +167,79 @@ class Pipeline:
|
||||
|
||||
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:
|
||||
@@ -126,7 +249,24 @@ class Pipeline:
|
||||
return True
|
||||
|
||||
def execute(self, data: Any | None = None) -> StageResult:
|
||||
"""Execute the pipeline with the given input data."""
|
||||
"""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()
|
||||
|
||||
@@ -141,16 +281,64 @@ class Pipeline:
|
||||
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,
|
||||
@@ -173,6 +361,42 @@ class Pipeline:
|
||||
)
|
||||
)
|
||||
|
||||
# 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(
|
||||
@@ -182,6 +406,12 @@ class Pipeline:
|
||||
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
|
||||
@@ -214,6 +444,22 @@ class Pipeline:
|
||||
"""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:
|
||||
@@ -254,6 +500,10 @@ class Pipeline:
|
||||
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."""
|
||||
@@ -303,8 +553,11 @@ def create_pipeline_from_params(params: PipelineParams) -> Pipeline:
|
||||
|
||||
def create_default_pipeline() -> Pipeline:
|
||||
"""Create a default pipeline with all standard components."""
|
||||
from engine.pipeline.adapters import DataSourceStage
|
||||
from engine.sources_v2 import HeadlinesDataSource
|
||||
from engine.data_sources.sources import HeadlinesDataSource
|
||||
from engine.pipeline.adapters import (
|
||||
DataSourceStage,
|
||||
SourceItemsToBufferStage,
|
||||
)
|
||||
|
||||
pipeline = Pipeline()
|
||||
|
||||
@@ -312,6 +565,9 @@ def create_default_pipeline() -> Pipeline:
|
||||
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:
|
||||
|
||||
@@ -5,17 +5,42 @@ 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."""
|
||||
@@ -35,18 +60,78 @@ class Stage(ABC):
|
||||
- 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", "display", "camera"
|
||||
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.
|
||||
|
||||
@@ -8,6 +8,11 @@ modify these params, which the pipeline then applies to its stages.
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from engine.display import BorderMode
|
||||
except ImportError:
|
||||
BorderMode = object # Fallback for type checking
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineParams:
|
||||
@@ -23,6 +28,7 @@ class PipelineParams:
|
||||
|
||||
# Display config
|
||||
display: str = "terminal"
|
||||
border: bool | BorderMode = False
|
||||
|
||||
# Camera config
|
||||
camera_mode: str = "vertical"
|
||||
@@ -31,7 +37,7 @@ class PipelineParams:
|
||||
|
||||
# Effect config
|
||||
effect_order: list[str] = field(
|
||||
default_factory=lambda: ["noise", "fade", "glitch", "firehose", "hud"]
|
||||
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)
|
||||
@@ -126,19 +132,19 @@ DEFAULT_HEADLINE_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="terminal",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
DEFAULT_PYGAME_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
DEFAULT_PIPELINE_PARAMS = PipelineParams(
|
||||
source="pipeline",
|
||||
display="pygame",
|
||||
camera_mode="trace",
|
||||
effect_order=["hud"], # Just HUD for pipeline viz
|
||||
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)
|
||||
@@ -1,16 +1,35 @@
|
||||
"""
|
||||
Pipeline presets - Pre-configured pipeline configurations.
|
||||
|
||||
Provides PipelinePreset as a unified preset system that wraps
|
||||
the existing Preset class from animation.py for backwards compatibility.
|
||||
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.animation import Preset as AnimationPreset
|
||||
from engine.display import BorderMode
|
||||
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 {}
|
||||
|
||||
|
||||
_YAML_PRESETS = _load_toml_presets()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelinePreset:
|
||||
"""Pre-configured pipeline with stages and animation.
|
||||
@@ -18,7 +37,6 @@ class PipelinePreset:
|
||||
A PipelinePreset packages:
|
||||
- Initial params: Starting configuration
|
||||
- Stages: List of stage configurations to create
|
||||
- Animation: Optional animation controller
|
||||
|
||||
This is the new unified preset that works with the Pipeline class.
|
||||
"""
|
||||
@@ -27,61 +45,71 @@ class PipelinePreset:
|
||||
description: str = ""
|
||||
source: str = "headlines"
|
||||
display: str = "terminal"
|
||||
camera: str = "vertical"
|
||||
camera: str = "scroll"
|
||||
effects: list[str] = field(default_factory=list)
|
||||
initial_params: PipelineParams | None = None
|
||||
animation_preset: AnimationPreset | None = None
|
||||
border: bool | BorderMode = (
|
||||
False # Border mode: False=off, True=simple, BorderMode.UI for panel
|
||||
)
|
||||
|
||||
def to_params(self) -> PipelineParams:
|
||||
"""Convert to PipelineParams."""
|
||||
if self.initial_params:
|
||||
return self.initial_params.copy()
|
||||
from engine.display import BorderMode
|
||||
|
||||
params = PipelineParams()
|
||||
params.source = self.source
|
||||
params.display = self.display
|
||||
params.border = (
|
||||
self.border
|
||||
if isinstance(self.border, bool)
|
||||
else BorderMode.UI
|
||||
if self.border == BorderMode.UI
|
||||
else False
|
||||
)
|
||||
params.camera_mode = self.camera
|
||||
params.effect_order = self.effects.copy()
|
||||
return params
|
||||
|
||||
@classmethod
|
||||
def from_animation_preset(cls, preset: AnimationPreset) -> "PipelinePreset":
|
||||
"""Create a PipelinePreset from an existing animation Preset."""
|
||||
params = preset.initial_params
|
||||
def from_yaml(cls, name: str, data: dict[str, Any]) -> "PipelinePreset":
|
||||
"""Create a PipelinePreset from YAML data."""
|
||||
return cls(
|
||||
name=preset.name,
|
||||
description=preset.description,
|
||||
source=params.source,
|
||||
display=params.display,
|
||||
camera=params.camera_mode,
|
||||
effects=params.effect_order.copy(),
|
||||
initial_params=params,
|
||||
animation_preset=preset,
|
||||
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),
|
||||
)
|
||||
|
||||
def create_animation_controller(self):
|
||||
"""Create an AnimationController from this preset."""
|
||||
if self.animation_preset:
|
||||
return self.animation_preset.create_controller()
|
||||
return None
|
||||
|
||||
|
||||
# Built-in presets
|
||||
DEMO_PRESET = PipelinePreset(
|
||||
name="demo",
|
||||
description="Demo mode with effect cycling and camera modes",
|
||||
source="headlines",
|
||||
display="terminal",
|
||||
camera="vertical",
|
||||
effects=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
UI_PRESET = PipelinePreset(
|
||||
name="ui",
|
||||
description="Interactive UI mode with right-side control panel",
|
||||
source="fixture",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch"],
|
||||
border=BorderMode.UI,
|
||||
)
|
||||
|
||||
POETRY_PRESET = PipelinePreset(
|
||||
name="poetry",
|
||||
description="Poetry feed with subtle effects",
|
||||
source="poetry",
|
||||
display="terminal",
|
||||
camera="vertical",
|
||||
effects=["fade", "hud"],
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["fade"],
|
||||
)
|
||||
|
||||
PIPELINE_VIZ_PRESET = PipelinePreset(
|
||||
@@ -90,7 +118,7 @@ PIPELINE_VIZ_PRESET = PipelinePreset(
|
||||
source="pipeline",
|
||||
display="terminal",
|
||||
camera="trace",
|
||||
effects=["hud"],
|
||||
effects=[],
|
||||
)
|
||||
|
||||
WEBSOCKET_PRESET = PipelinePreset(
|
||||
@@ -98,37 +126,59 @@ WEBSOCKET_PRESET = PipelinePreset(
|
||||
description="WebSocket display mode",
|
||||
source="headlines",
|
||||
display="websocket",
|
||||
camera="vertical",
|
||||
effects=["noise", "fade", "glitch", "hud"],
|
||||
)
|
||||
|
||||
SIXEL_PRESET = PipelinePreset(
|
||||
name="sixel",
|
||||
description="Sixel graphics display mode",
|
||||
source="headlines",
|
||||
display="sixel",
|
||||
camera="vertical",
|
||||
effects=["noise", "fade", "glitch", "hud"],
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch"],
|
||||
)
|
||||
|
||||
FIREHOSE_PRESET = PipelinePreset(
|
||||
name="firehose",
|
||||
description="High-speed firehose mode",
|
||||
source="headlines",
|
||||
display="terminal",
|
||||
camera="vertical",
|
||||
effects=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
FIXTURE_PRESET = PipelinePreset(
|
||||
name="fixture",
|
||||
description="Use cached headline fixtures",
|
||||
source="fixture",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade"],
|
||||
border=False,
|
||||
)
|
||||
|
||||
|
||||
PRESETS: dict[str, PipelinePreset] = {
|
||||
"demo": DEMO_PRESET,
|
||||
"poetry": POETRY_PRESET,
|
||||
"pipeline": PIPELINE_VIZ_PRESET,
|
||||
"websocket": WEBSOCKET_PRESET,
|
||||
"sixel": SIXEL_PRESET,
|
||||
"firehose": FIREHOSE_PRESET,
|
||||
}
|
||||
# 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,
|
||||
"firehose": FIREHOSE_PRESET,
|
||||
"ui": UI_PRESET,
|
||||
"fixture": FIXTURE_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:
|
||||
@@ -150,6 +200,5 @@ def create_preset_from_params(
|
||||
source=params.source,
|
||||
display=params.display,
|
||||
camera=params.camera_mode,
|
||||
effects=params.effect_order.copy(),
|
||||
initial_params=params,
|
||||
effects=params.effect_order.copy() if hasattr(params, "effect_order") else [],
|
||||
)
|
||||
|
||||
@@ -6,18 +6,25 @@ 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[Stage]]] = {}
|
||||
_categories: dict[str, dict[str, type[Any]]] = {}
|
||||
_discovered: bool = False
|
||||
_instances: dict[str, Stage] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, category: str, stage_class: type[Stage]) -> None:
|
||||
def register(cls, category: str, stage_class: type[Any]) -> None:
|
||||
"""Register a stage class in a category.
|
||||
|
||||
Args:
|
||||
@@ -27,12 +34,11 @@ class StageRegistry:
|
||||
if category not in cls._categories:
|
||||
cls._categories[category] = {}
|
||||
|
||||
# Use class name as key
|
||||
key = getattr(stage_class, "__name__", stage_class.__class__.__name__)
|
||||
cls._categories[category][key] = stage_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, category: str, name: str) -> type[Stage] | None:
|
||||
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)
|
||||
|
||||
@@ -81,19 +87,29 @@ def discover_stages() -> None:
|
||||
|
||||
# Import and register all stage implementations
|
||||
try:
|
||||
from engine.sources_v2 import (
|
||||
from engine.data_sources.sources import (
|
||||
HeadlinesDataSource,
|
||||
PipelineDataSource,
|
||||
PoetryDataSource,
|
||||
)
|
||||
|
||||
StageRegistry.register("source", HeadlinesDataSource)
|
||||
StageRegistry.register("source", PoetryDataSource)
|
||||
StageRegistry.register("source", PipelineDataSource)
|
||||
|
||||
StageRegistry._categories["source"]["headlines"] = HeadlinesDataSource
|
||||
StageRegistry._categories["source"]["poetry"] = PoetryDataSource
|
||||
StageRegistry._categories["source"]["pipeline"] = PipelineDataSource
|
||||
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
|
||||
|
||||
@@ -102,6 +118,14 @@ def discover_stages() -> None:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register buffer stages (framebuffer, etc.)
|
||||
try:
|
||||
from engine.pipeline.stages.framebuffer import FrameBufferStage
|
||||
|
||||
StageRegistry.register("effect", FrameBufferStage)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register display stages
|
||||
_register_display_stages()
|
||||
|
||||
|
||||
158
engine/pipeline/stages/framebuffer.py
Normal file
158
engine/pipeline/stages/framebuffer.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Frame buffer stage - stores previous frames for temporal effects.
|
||||
|
||||
Provides:
|
||||
- frame_history: list of previous buffers (most recent first)
|
||||
- intensity_history: list of corresponding intensity maps
|
||||
- current_intensity: intensity map for current frame
|
||||
|
||||
Capability: "framebuffer.history"
|
||||
"""
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from engine.display import _strip_ansi
|
||||
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameBufferConfig:
|
||||
"""Configuration for FrameBufferStage."""
|
||||
|
||||
history_depth: int = 2 # Number of previous frames to keep
|
||||
|
||||
|
||||
class FrameBufferStage(Stage):
|
||||
"""Stores frame history and computes intensity maps."""
|
||||
|
||||
name = "framebuffer"
|
||||
category = "effect" # It's an effect that enriches context with frame history
|
||||
|
||||
def __init__(self, config: FrameBufferConfig | None = None, history_depth: int = 2):
|
||||
self.config = config or FrameBufferConfig(history_depth=history_depth)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"framebuffer.history"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
# Depends on rendered output (since we want to capture final buffer)
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER} # Pass through unchanged
|
||||
|
||||
def init(self, ctx: PipelineContext) -> bool:
|
||||
"""Initialize framebuffer state in context."""
|
||||
ctx.set("frame_history", [])
|
||||
ctx.set("intensity_history", [])
|
||||
return True
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Store frame in history and compute intensity.
|
||||
|
||||
Args:
|
||||
data: Current text buffer (list[str])
|
||||
ctx: Pipeline context
|
||||
|
||||
Returns:
|
||||
Same buffer (pass-through)
|
||||
"""
|
||||
if not isinstance(data, list):
|
||||
return data
|
||||
|
||||
# Compute intensity map for current buffer (per-row, length = buffer rows)
|
||||
intensity_map = self._compute_buffer_intensity(data, len(data))
|
||||
|
||||
# Store in context
|
||||
ctx.set("current_intensity", intensity_map)
|
||||
|
||||
with self._lock:
|
||||
# Get existing histories
|
||||
history = ctx.get("frame_history", [])
|
||||
intensity_hist = ctx.get("intensity_history", [])
|
||||
|
||||
# Prepend current frame to history
|
||||
history.insert(0, data.copy())
|
||||
intensity_hist.insert(0, intensity_map)
|
||||
|
||||
# Trim to configured depth
|
||||
max_depth = self.config.history_depth
|
||||
ctx.set("frame_history", history[:max_depth])
|
||||
ctx.set("intensity_history", intensity_hist[:max_depth])
|
||||
|
||||
return data
|
||||
|
||||
def _compute_buffer_intensity(
|
||||
self, buf: list[str], max_rows: int = 24
|
||||
) -> list[float]:
|
||||
"""Compute average intensity per row in buffer.
|
||||
|
||||
Uses ANSI color if available; falls back to character density.
|
||||
|
||||
Args:
|
||||
buf: Text buffer (list of strings)
|
||||
max_rows: Maximum number of rows to process
|
||||
|
||||
Returns:
|
||||
List of intensity values (0.0-1.0) per row
|
||||
"""
|
||||
intensities = []
|
||||
# Limit to viewport height
|
||||
lines = buf[:max_rows]
|
||||
|
||||
for line in lines:
|
||||
# Strip ANSI codes for length calc
|
||||
|
||||
plain = _strip_ansi(line)
|
||||
if not plain:
|
||||
intensities.append(0.0)
|
||||
continue
|
||||
|
||||
# Simple heuristic: ratio of non-space characters
|
||||
# More sophisticated version could parse ANSI RGB brightness
|
||||
filled = sum(1 for c in plain if c not in (" ", "\t"))
|
||||
total = len(plain)
|
||||
intensity = filled / total if total > 0 else 0.0
|
||||
intensities.append(max(0.0, min(1.0, intensity)))
|
||||
|
||||
# Pad to max_rows if needed
|
||||
while len(intensities) < max_rows:
|
||||
intensities.append(0.0)
|
||||
|
||||
return intensities
|
||||
|
||||
def get_frame(
|
||||
self, index: int = 0, ctx: PipelineContext | None = None
|
||||
) -> list[str] | None:
|
||||
"""Get frame from history by index (0 = current, 1 = previous, etc)."""
|
||||
if ctx is None:
|
||||
return None
|
||||
history = ctx.get("frame_history", [])
|
||||
if 0 <= index < len(history):
|
||||
return history[index]
|
||||
return None
|
||||
|
||||
def get_intensity(
|
||||
self, index: int = 0, ctx: PipelineContext | None = None
|
||||
) -> list[float] | None:
|
||||
"""Get intensity map from history by index."""
|
||||
if ctx is None:
|
||||
return None
|
||||
intensity_hist = ctx.get("intensity_history", [])
|
||||
if 0 <= index < len(intensity_hist):
|
||||
return intensity_hist[index]
|
||||
return None
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources."""
|
||||
pass
|
||||
549
engine/pipeline/ui.py
Normal file
549
engine/pipeline/ui.py
Normal file
@@ -0,0 +1,549 @@
|
||||
"""
|
||||
Pipeline UI panel - Interactive controls for pipeline configuration.
|
||||
|
||||
Provides:
|
||||
- Stage list with enable/disable toggles
|
||||
- Parameter sliders for selected effect
|
||||
- Keyboard/mouse interaction
|
||||
|
||||
This module implements the right-side UI panel that appears in border="ui" mode.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIConfig:
|
||||
"""Configuration for the UI panel."""
|
||||
|
||||
panel_width: int = 24 # Characters wide
|
||||
stage_list_height: int = 12 # Number of stages to show at once
|
||||
param_height: int = 8 # Space for parameter controls
|
||||
scroll_offset: int = 0 # Scroll position in stage list
|
||||
start_with_preset_picker: bool = False # Show preset picker immediately
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageControl:
|
||||
"""Represents a stage in the UI panel with its toggle state."""
|
||||
|
||||
name: str
|
||||
stage_name: str # Actual pipeline stage name
|
||||
category: str
|
||||
enabled: bool = True
|
||||
selected: bool = False
|
||||
params: dict[str, Any] = field(default_factory=dict) # Current param values
|
||||
param_schema: dict[str, dict] = field(default_factory=dict) # Param metadata
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Toggle enabled state."""
|
||||
self.enabled = not self.enabled
|
||||
|
||||
def get_param(self, name: str) -> Any:
|
||||
"""Get current parameter value."""
|
||||
return self.params.get(name)
|
||||
|
||||
def set_param(self, name: str, value: Any) -> None:
|
||||
"""Set parameter value."""
|
||||
self.params[name] = value
|
||||
|
||||
|
||||
class UIPanel:
|
||||
"""Interactive UI panel for pipeline configuration.
|
||||
|
||||
Manages:
|
||||
- Stage list with enable/disable checkboxes
|
||||
- Parameter sliders for selected stage
|
||||
- Keyboard/mouse event handling
|
||||
- Scroll state for long stage lists
|
||||
|
||||
The panel is rendered as a right border (panel_width characters wide)
|
||||
alongside the main viewport.
|
||||
"""
|
||||
|
||||
def __init__(self, config: UIConfig | None = None):
|
||||
self.config = config or UIConfig()
|
||||
self.stages: dict[str, StageControl] = {} # stage_name -> StageControl
|
||||
self.scroll_offset = 0
|
||||
self.selected_stage: str | None = None
|
||||
self._focused_param: str | None = None # For slider adjustment
|
||||
self._callbacks: dict[str, Callable] = {} # Event callbacks
|
||||
self._presets: list[str] = [] # Available preset names
|
||||
self._current_preset: str = "" # Current preset name
|
||||
self._show_preset_picker: bool = (
|
||||
config.start_with_preset_picker if config else False
|
||||
) # Picker overlay visible
|
||||
self._show_panel: bool = True # UI panel visibility
|
||||
self._preset_scroll_offset: int = 0 # Scroll in preset list
|
||||
|
||||
def register_stage(self, stage: Any, enabled: bool = True) -> StageControl:
|
||||
"""Register a stage for UI control.
|
||||
|
||||
Args:
|
||||
stage: Stage instance (must have .name, .category attributes)
|
||||
enabled: Initial enabled state
|
||||
|
||||
Returns:
|
||||
The created StageControl instance
|
||||
"""
|
||||
control = StageControl(
|
||||
name=stage.name,
|
||||
stage_name=stage.name,
|
||||
category=stage.category,
|
||||
enabled=enabled,
|
||||
)
|
||||
self.stages[stage.name] = control
|
||||
return control
|
||||
|
||||
def unregister_stage(self, stage_name: str) -> None:
|
||||
"""Remove a stage from UI control."""
|
||||
if stage_name in self.stages:
|
||||
del self.stages[stage_name]
|
||||
|
||||
def get_enabled_stages(self) -> list[str]:
|
||||
"""Get list of stage names that are currently enabled."""
|
||||
return [name for name, ctrl in self.stages.items() if ctrl.enabled]
|
||||
|
||||
def select_stage(self, stage_name: str | None = None) -> None:
|
||||
"""Select a stage (for parameter editing)."""
|
||||
if stage_name in self.stages:
|
||||
self.selected_stage = stage_name
|
||||
self.stages[stage_name].selected = True
|
||||
# Deselect others
|
||||
for name, ctrl in self.stages.items():
|
||||
if name != stage_name:
|
||||
ctrl.selected = False
|
||||
# Auto-focus first parameter when stage selected
|
||||
if self.stages[stage_name].params:
|
||||
self._focused_param = next(iter(self.stages[stage_name].params.keys()))
|
||||
else:
|
||||
self._focused_param = None
|
||||
|
||||
def toggle_stage(self, stage_name: str) -> bool:
|
||||
"""Toggle a stage's enabled state.
|
||||
|
||||
Returns:
|
||||
New enabled state
|
||||
"""
|
||||
if stage_name in self.stages:
|
||||
ctrl = self.stages[stage_name]
|
||||
ctrl.enabled = not ctrl.enabled
|
||||
return ctrl.enabled
|
||||
return False
|
||||
|
||||
def adjust_selected_param(self, delta: float) -> None:
|
||||
"""Adjust the currently focused parameter of selected stage.
|
||||
|
||||
Args:
|
||||
delta: Amount to add (positive or negative)
|
||||
"""
|
||||
if self.selected_stage and self._focused_param:
|
||||
ctrl = self.stages[self.selected_stage]
|
||||
if self._focused_param in ctrl.params:
|
||||
current = ctrl.params[self._focused_param]
|
||||
# Determine step size from schema
|
||||
schema = ctrl.param_schema.get(self._focused_param, {})
|
||||
step = schema.get("step", 0.1 if isinstance(current, float) else 1)
|
||||
new_val = current + delta * step
|
||||
# Clamp to min/max if specified
|
||||
if "min" in schema:
|
||||
new_val = max(schema["min"], new_val)
|
||||
if "max" in schema:
|
||||
new_val = min(schema["max"], new_val)
|
||||
# Only emit if value actually changed
|
||||
if new_val != current:
|
||||
ctrl.params[self._focused_param] = new_val
|
||||
self._emit_event(
|
||||
"param_changed",
|
||||
stage_name=self.selected_stage,
|
||||
param_name=self._focused_param,
|
||||
value=new_val,
|
||||
)
|
||||
|
||||
def scroll_stages(self, delta: int) -> None:
|
||||
"""Scroll the stage list."""
|
||||
max_offset = max(0, len(self.stages) - self.config.stage_list_height)
|
||||
self.scroll_offset = max(0, min(max_offset, self.scroll_offset + delta))
|
||||
|
||||
def render(self, width: int, height: int) -> list[str]:
|
||||
"""Render the UI panel.
|
||||
|
||||
Args:
|
||||
width: Total display width (panel uses last `panel_width` cols)
|
||||
height: Total display height
|
||||
|
||||
Returns:
|
||||
List of strings, each of length `panel_width`, to overlay on right side
|
||||
"""
|
||||
panel_width = min(
|
||||
self.config.panel_width, width - 4
|
||||
) # Reserve at least 2 for main
|
||||
lines = []
|
||||
|
||||
# If panel is hidden, render empty space
|
||||
if not self._show_panel:
|
||||
return [" " * panel_width for _ in range(height)]
|
||||
|
||||
# If preset picker is active, render that overlay instead of normal panel
|
||||
if self._show_preset_picker:
|
||||
picker_lines = self._render_preset_picker(panel_width)
|
||||
# Pad to full panel height if needed
|
||||
while len(picker_lines) < height:
|
||||
picker_lines.append(" " * panel_width)
|
||||
return [
|
||||
line.ljust(panel_width)[:panel_width] for line in picker_lines[:height]
|
||||
]
|
||||
|
||||
# Header
|
||||
title_line = "┌" + "─" * (panel_width - 2) + "┐"
|
||||
lines.append(title_line)
|
||||
|
||||
# Stage list section (occupies most of the panel)
|
||||
list_height = self.config.stage_list_height
|
||||
stage_names = list(self.stages.keys())
|
||||
for i in range(list_height):
|
||||
idx = i + self.scroll_offset
|
||||
if idx < len(stage_names):
|
||||
stage_name = stage_names[idx]
|
||||
ctrl = self.stages[stage_name]
|
||||
status = "✓" if ctrl.enabled else "✗"
|
||||
sel = ">" if ctrl.selected else " "
|
||||
# Truncate to fit panel (leave room for ">✓ " prefix and padding)
|
||||
max_name_len = panel_width - 5
|
||||
display_name = ctrl.name[:max_name_len]
|
||||
line = f"│{sel}{status} {display_name:<{max_name_len}}"
|
||||
lines.append(line[:panel_width])
|
||||
else:
|
||||
lines.append("│" + " " * (panel_width - 2) + "│")
|
||||
|
||||
# Separator
|
||||
lines.append("├" + "─" * (panel_width - 2) + "┤")
|
||||
|
||||
# Parameter section (if stage selected)
|
||||
if self.selected_stage and self.selected_stage in self.stages:
|
||||
ctrl = self.stages[self.selected_stage]
|
||||
if ctrl.params:
|
||||
# Render each parameter as "name: [=====] value" with focus indicator
|
||||
for param_name, param_value in ctrl.params.items():
|
||||
schema = ctrl.param_schema.get(param_name, {})
|
||||
is_focused = param_name == self._focused_param
|
||||
# Format value based on type
|
||||
if isinstance(param_value, float):
|
||||
val_str = f"{param_value:.2f}"
|
||||
elif isinstance(param_value, int):
|
||||
val_str = f"{param_value}"
|
||||
elif isinstance(param_value, bool):
|
||||
val_str = str(param_value)
|
||||
else:
|
||||
val_str = str(param_value)
|
||||
|
||||
# Build parameter line
|
||||
if (
|
||||
isinstance(param_value, (int, float))
|
||||
and "min" in schema
|
||||
and "max" in schema
|
||||
):
|
||||
# Render as slider
|
||||
min_val = schema["min"]
|
||||
max_val = schema["max"]
|
||||
# Normalize to 0-1 for bar length
|
||||
if max_val != min_val:
|
||||
ratio = (param_value - min_val) / (max_val - min_val)
|
||||
else:
|
||||
ratio = 0
|
||||
bar_width = (
|
||||
panel_width - len(param_name) - len(val_str) - 10
|
||||
) # approx space for "[] : ="
|
||||
if bar_width < 1:
|
||||
bar_width = 1
|
||||
filled = int(round(ratio * bar_width))
|
||||
bar = "[" + "=" * filled + " " * (bar_width - filled) + "]"
|
||||
param_line = f"│ {param_name}: {bar} {val_str}"
|
||||
else:
|
||||
# Simple name=value
|
||||
param_line = f"│ {param_name}={val_str}"
|
||||
|
||||
# Highlight focused parameter
|
||||
if is_focused:
|
||||
# Invert colors conceptually - for now use > prefix
|
||||
param_line = "│> " + param_line[2:]
|
||||
|
||||
# Truncate to fit panel width
|
||||
if len(param_line) > panel_width - 1:
|
||||
param_line = param_line[: panel_width - 1]
|
||||
lines.append(param_line + "│")
|
||||
else:
|
||||
lines.append("│ (no params)".ljust(panel_width - 1) + "│")
|
||||
else:
|
||||
lines.append("│ (select a stage)".ljust(panel_width - 1) + "│")
|
||||
|
||||
# Info line before footer
|
||||
info_parts = []
|
||||
if self._current_preset:
|
||||
info_parts.append(f"Preset: {self._current_preset}")
|
||||
if self._presets:
|
||||
info_parts.append("[P] presets")
|
||||
info_str = " | ".join(info_parts) if info_parts else ""
|
||||
if info_str:
|
||||
padded = info_str.ljust(panel_width - 2)
|
||||
lines.append("│" + padded + "│")
|
||||
|
||||
# Footer with instructions
|
||||
footer_line = self._render_footer(panel_width)
|
||||
lines.append(footer_line)
|
||||
|
||||
# Ensure all lines are exactly panel_width
|
||||
return [line.ljust(panel_width)[:panel_width] for line in lines]
|
||||
|
||||
def _render_footer(self, width: int) -> str:
|
||||
"""Render footer with key hints."""
|
||||
if width >= 40:
|
||||
# Show preset name and key hints
|
||||
preset_info = (
|
||||
f"Preset: {self._current_preset}" if self._current_preset else ""
|
||||
)
|
||||
hints = " [S]elect [Space]UI [Tab]Params [Arrows/HJKL]Adjust "
|
||||
if self._presets:
|
||||
hints += "[P]Preset "
|
||||
combined = f"{preset_info}{hints}"
|
||||
if len(combined) > width - 4:
|
||||
combined = combined[: width - 4]
|
||||
footer = "└" + "─" * (width - 2) + "┘"
|
||||
return footer # Just the line, we'll add info above in render
|
||||
else:
|
||||
return "└" + "─" * (width - 2) + "┘"
|
||||
|
||||
def process_key_event(self, key: str | int, modifiers: int = 0) -> bool:
|
||||
"""Process a keyboard event.
|
||||
|
||||
Args:
|
||||
key: Key symbol (e.g., ' ', 's', pygame.K_UP, etc.)
|
||||
modifiers: Modifier bits (Shift, Ctrl, Alt)
|
||||
|
||||
Returns:
|
||||
True if event was handled, False if not
|
||||
"""
|
||||
# Normalize to string for simplicity
|
||||
key_str = self._normalize_key(key, modifiers)
|
||||
|
||||
# Space: toggle UI panel visibility (only when preset picker not active)
|
||||
if key_str == " " and not self._show_preset_picker:
|
||||
self._show_panel = not getattr(self, "_show_panel", True)
|
||||
return True
|
||||
|
||||
# Space: toggle UI panel visibility (only when preset picker not active)
|
||||
if key_str == " " and not self._show_preset_picker:
|
||||
self._show_panel = not getattr(self, "_show_panel", True)
|
||||
return True
|
||||
|
||||
# S: select stage (cycle)
|
||||
if key_str == "s" and modifiers == 0:
|
||||
stages = list(self.stages.keys())
|
||||
if not stages:
|
||||
return False
|
||||
if self.selected_stage:
|
||||
current_idx = stages.index(self.selected_stage)
|
||||
next_idx = (current_idx + 1) % len(stages)
|
||||
else:
|
||||
next_idx = 0
|
||||
self.select_stage(stages[next_idx])
|
||||
return True
|
||||
|
||||
# P: toggle preset picker (only when panel is visible)
|
||||
if key_str == "p" and self._show_panel:
|
||||
self._show_preset_picker = not self._show_preset_picker
|
||||
if self._show_preset_picker:
|
||||
self._preset_scroll_offset = 0
|
||||
return True
|
||||
|
||||
# HJKL or Arrow Keys: scroll stage list, preset list, or adjust param
|
||||
# vi-style: K=up, J=down (J is actually next line in vi, but we use for down)
|
||||
# We'll use J for down, K for up, H for left, L for right
|
||||
elif key_str in ("up", "down", "kp8", "kp2", "j", "k"):
|
||||
# If preset picker is open, scroll preset list
|
||||
if self._show_preset_picker:
|
||||
delta = -1 if key_str in ("up", "kp8", "k") else 1
|
||||
self._preset_scroll_offset = max(0, self._preset_scroll_offset + delta)
|
||||
# Ensure scroll doesn't go past end
|
||||
max_offset = max(0, len(self._presets) - 1)
|
||||
self._preset_scroll_offset = min(max_offset, self._preset_scroll_offset)
|
||||
return True
|
||||
# If param is focused, adjust param value
|
||||
elif self.selected_stage and self._focused_param:
|
||||
delta = -1.0 if key_str in ("up", "kp8", "k") else 1.0
|
||||
self.adjust_selected_param(delta)
|
||||
return True
|
||||
# Otherwise scroll stages
|
||||
else:
|
||||
delta = -1 if key_str in ("up", "kp8", "k") else 1
|
||||
self.scroll_stages(delta)
|
||||
return True
|
||||
|
||||
# Left/Right or H/L: adjust param (if param selected)
|
||||
elif key_str in ("left", "right", "kp4", "kp6", "h", "l"):
|
||||
if self.selected_stage:
|
||||
delta = -0.1 if key_str in ("left", "kp4", "h") else 0.1
|
||||
self.adjust_selected_param(delta)
|
||||
return True
|
||||
|
||||
# Tab: cycle through parameters
|
||||
if key_str == "tab" and self.selected_stage:
|
||||
ctrl = self.stages[self.selected_stage]
|
||||
param_names = list(ctrl.params.keys())
|
||||
if param_names:
|
||||
if self._focused_param in param_names:
|
||||
current_idx = param_names.index(self._focused_param)
|
||||
next_idx = (current_idx + 1) % len(param_names)
|
||||
else:
|
||||
next_idx = 0
|
||||
self._focused_param = param_names[next_idx]
|
||||
return True
|
||||
|
||||
# Preset picker navigation
|
||||
if self._show_preset_picker:
|
||||
# Enter: select currently highlighted preset
|
||||
if key_str == "return":
|
||||
if self._presets:
|
||||
idx = self._preset_scroll_offset
|
||||
if idx < len(self._presets):
|
||||
self._current_preset = self._presets[idx]
|
||||
self._emit_event(
|
||||
"preset_changed", preset_name=self._current_preset
|
||||
)
|
||||
self._show_preset_picker = False
|
||||
return True
|
||||
# Escape: close picker without changing
|
||||
elif key_str == "escape":
|
||||
self._show_preset_picker = False
|
||||
return True
|
||||
|
||||
# Escape: deselect stage (only when picker not active)
|
||||
elif key_str == "escape" and self.selected_stage:
|
||||
self.selected_stage = None
|
||||
for ctrl in self.stages.values():
|
||||
ctrl.selected = False
|
||||
self._focused_param = None
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _normalize_key(self, key: str | int, modifiers: int) -> str:
|
||||
"""Normalize key to a string identifier."""
|
||||
# Handle pygame keysyms if imported
|
||||
try:
|
||||
import pygame
|
||||
|
||||
if isinstance(key, int):
|
||||
# Map pygame constants to strings
|
||||
key_map = {
|
||||
pygame.K_UP: "up",
|
||||
pygame.K_DOWN: "down",
|
||||
pygame.K_LEFT: "left",
|
||||
pygame.K_RIGHT: "right",
|
||||
pygame.K_SPACE: " ",
|
||||
pygame.K_ESCAPE: "escape",
|
||||
pygame.K_s: "s",
|
||||
pygame.K_w: "w",
|
||||
# HJKL navigation (vi-style)
|
||||
pygame.K_h: "h",
|
||||
pygame.K_j: "j",
|
||||
pygame.K_k: "k",
|
||||
pygame.K_l: "l",
|
||||
}
|
||||
# Check for keypad keys with KP prefix
|
||||
if hasattr(pygame, "K_KP8") and key == pygame.K_KP8:
|
||||
return "kp8"
|
||||
if hasattr(pygame, "K_KP2") and key == pygame.K_KP2:
|
||||
return "kp2"
|
||||
if hasattr(pygame, "K_KP4") and key == pygame.K_KP4:
|
||||
return "kp4"
|
||||
if hasattr(pygame, "K_KP6") and key == pygame.K_KP6:
|
||||
return "kp6"
|
||||
return key_map.get(key, f"pygame_{key}")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Already a string?
|
||||
if isinstance(key, str):
|
||||
return key.lower()
|
||||
|
||||
return str(key)
|
||||
|
||||
def set_event_callback(self, event_type: str, callback: Callable) -> None:
|
||||
"""Register a callback for UI events.
|
||||
|
||||
Args:
|
||||
event_type: Event type ("stage_toggled", "param_changed", "stage_selected", "preset_changed")
|
||||
callback: Function to call when event occurs
|
||||
"""
|
||||
self._callbacks[event_type] = callback
|
||||
|
||||
def _emit_event(self, event_type: str, **data) -> None:
|
||||
"""Emit an event to registered callbacks."""
|
||||
callback = self._callbacks.get(event_type)
|
||||
if callback:
|
||||
try:
|
||||
callback(**data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_presets(self, presets: list[str], current: str) -> None:
|
||||
"""Set available presets and current selection.
|
||||
|
||||
Args:
|
||||
presets: List of preset names
|
||||
current: Currently active preset name
|
||||
"""
|
||||
self._presets = presets
|
||||
self._current_preset = current
|
||||
|
||||
def cycle_preset(self, direction: int = 1) -> str:
|
||||
"""Cycle to next/previous preset.
|
||||
|
||||
Args:
|
||||
direction: 1 for next, -1 for previous
|
||||
|
||||
Returns:
|
||||
New preset name
|
||||
"""
|
||||
if not self._presets:
|
||||
return self._current_preset
|
||||
try:
|
||||
current_idx = self._presets.index(self._current_preset)
|
||||
except ValueError:
|
||||
current_idx = 0
|
||||
next_idx = (current_idx + direction) % len(self._presets)
|
||||
self._current_preset = self._presets[next_idx]
|
||||
self._emit_event("preset_changed", preset_name=self._current_preset)
|
||||
return self._current_preset
|
||||
|
||||
def _render_preset_picker(self, panel_width: int) -> list[str]:
|
||||
"""Render a full-screen preset picker overlay."""
|
||||
lines = []
|
||||
picker_height = min(len(self._presets) + 2, self.config.stage_list_height)
|
||||
# Create a centered box
|
||||
title = " Select Preset "
|
||||
box_width = min(40, panel_width - 2)
|
||||
lines.append("┌" + "─" * (box_width - 2) + "┐")
|
||||
lines.append("│" + title.center(box_width - 2) + "│")
|
||||
lines.append("├" + "─" * (box_width - 2) + "┤")
|
||||
# List presets with selection
|
||||
visible_start = self._preset_scroll_offset
|
||||
visible_end = visible_start + picker_height - 2
|
||||
for i in range(visible_start, min(visible_end, len(self._presets))):
|
||||
preset_name = self._presets[i]
|
||||
is_current = preset_name == self._current_preset
|
||||
prefix = "▶ " if is_current else " "
|
||||
line = f"│ {prefix}{preset_name}"
|
||||
if len(line) < box_width - 1:
|
||||
line = line.ljust(box_width - 1)
|
||||
lines.append(line[: box_width - 1] + "│")
|
||||
# Footer with help
|
||||
help_text = "[P] close [↑↓] navigate [Enter] select"
|
||||
footer = "├" + "─" * (box_width - 2) + "┤"
|
||||
lines.append(footer)
|
||||
lines.append("│" + help_text.center(box_width - 2) + "│")
|
||||
lines.append("└" + "─" * (box_width - 2) + "┘")
|
||||
return lines
|
||||
219
engine/pipeline/validation.py
Normal file
219
engine/pipeline/validation.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Pipeline validation and MVP (Minimum Viable Pipeline) injection.
|
||||
|
||||
Provides validation functions to ensure pipelines meet minimum requirements
|
||||
and can auto-inject sensible defaults when fields are missing or invalid.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from engine.display import BorderMode, DisplayRegistry
|
||||
from engine.effects import get_registry
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
# Known valid values
|
||||
VALID_SOURCES = ["headlines", "poetry", "fixture", "empty", "pipeline-inspect"]
|
||||
VALID_CAMERAS = [
|
||||
"feed",
|
||||
"scroll",
|
||||
"vertical",
|
||||
"horizontal",
|
||||
"omni",
|
||||
"floating",
|
||||
"bounce",
|
||||
"none",
|
||||
"",
|
||||
]
|
||||
VALID_DISPLAYS = None # Will be populated at runtime from DisplayRegistry
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of validation with changes and warnings."""
|
||||
|
||||
valid: bool
|
||||
warnings: list[str]
|
||||
changes: list[str]
|
||||
config: Any # PipelineConfig (forward ref)
|
||||
params: PipelineParams
|
||||
|
||||
|
||||
# MVP defaults
|
||||
MVP_DEFAULTS = {
|
||||
"source": "fixture",
|
||||
"display": "terminal",
|
||||
"camera": "", # Empty = no camera stage (static viewport)
|
||||
"effects": [],
|
||||
"border": False,
|
||||
}
|
||||
|
||||
|
||||
def validate_pipeline_config(
|
||||
config: Any, params: PipelineParams, allow_unsafe: bool = False
|
||||
) -> ValidationResult:
|
||||
"""Validate pipeline configuration against MVP requirements.
|
||||
|
||||
Args:
|
||||
config: PipelineConfig object (has source, display, camera, effects fields)
|
||||
params: PipelineParams object (has border field)
|
||||
allow_unsafe: If True, don't inject defaults or enforce MVP
|
||||
|
||||
Returns:
|
||||
ValidationResult with validity, warnings, changes, and validated config/params
|
||||
"""
|
||||
warnings = []
|
||||
changes = []
|
||||
|
||||
if allow_unsafe:
|
||||
# Still do basic validation but don't inject defaults
|
||||
# Always return valid=True when allow_unsafe is set
|
||||
warnings.extend(_validate_source(config.source))
|
||||
warnings.extend(_validate_display(config.display))
|
||||
warnings.extend(_validate_camera(config.camera))
|
||||
warnings.extend(_validate_effects(config.effects))
|
||||
warnings.extend(_validate_border(params.border))
|
||||
return ValidationResult(
|
||||
valid=True, # Always valid with allow_unsafe
|
||||
warnings=warnings,
|
||||
changes=[],
|
||||
config=config,
|
||||
params=params,
|
||||
)
|
||||
|
||||
# MVP injection mode
|
||||
# Source
|
||||
source_issues = _validate_source(config.source)
|
||||
if source_issues:
|
||||
warnings.extend(source_issues)
|
||||
config.source = MVP_DEFAULTS["source"]
|
||||
changes.append(f"source → {MVP_DEFAULTS['source']}")
|
||||
|
||||
# Display
|
||||
display_issues = _validate_display(config.display)
|
||||
if display_issues:
|
||||
warnings.extend(display_issues)
|
||||
config.display = MVP_DEFAULTS["display"]
|
||||
changes.append(f"display → {MVP_DEFAULTS['display']}")
|
||||
|
||||
# Camera
|
||||
camera_issues = _validate_camera(config.camera)
|
||||
if camera_issues:
|
||||
warnings.extend(camera_issues)
|
||||
config.camera = MVP_DEFAULTS["camera"]
|
||||
changes.append("camera → static (no camera stage)")
|
||||
|
||||
# Effects
|
||||
effect_issues = _validate_effects(config.effects)
|
||||
if effect_issues:
|
||||
warnings.extend(effect_issues)
|
||||
# Only change if all effects are invalid
|
||||
if len(config.effects) == 0 or all(
|
||||
e not in _get_valid_effects() for e in config.effects
|
||||
):
|
||||
config.effects = MVP_DEFAULTS["effects"]
|
||||
changes.append("effects → [] (none)")
|
||||
else:
|
||||
# Remove invalid effects, keep valid ones
|
||||
valid_effects = [e for e in config.effects if e in _get_valid_effects()]
|
||||
if valid_effects != config.effects:
|
||||
config.effects = valid_effects
|
||||
changes.append(f"effects → {valid_effects}")
|
||||
|
||||
# Border (in params)
|
||||
border_issues = _validate_border(params.border)
|
||||
if border_issues:
|
||||
warnings.extend(border_issues)
|
||||
params.border = MVP_DEFAULTS["border"]
|
||||
changes.append(f"border → {MVP_DEFAULTS['border']}")
|
||||
|
||||
valid = len(warnings) == 0
|
||||
if changes:
|
||||
# If we made changes, pipeline should be valid now
|
||||
valid = True
|
||||
|
||||
return ValidationResult(
|
||||
valid=valid,
|
||||
warnings=warnings,
|
||||
changes=changes,
|
||||
config=config,
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
def _validate_source(source: str) -> list[str]:
|
||||
"""Validate source field."""
|
||||
if not source:
|
||||
return ["source is empty"]
|
||||
if source not in VALID_SOURCES:
|
||||
return [f"unknown source '{source}', valid sources: {VALID_SOURCES}"]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_display(display: str) -> list[str]:
|
||||
"""Validate display field."""
|
||||
if not display:
|
||||
return ["display is empty"]
|
||||
# Check if display is available (lazy load registry)
|
||||
try:
|
||||
available = DisplayRegistry.list_backends()
|
||||
if display not in available:
|
||||
return [f"display '{display}' not available, available: {available}"]
|
||||
except Exception as e:
|
||||
return [f"error checking display availability: {e}"]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_camera(camera: str | None) -> list[str]:
|
||||
"""Validate camera field."""
|
||||
if camera is None:
|
||||
return ["camera is None"]
|
||||
# Empty string is valid (static, no camera stage)
|
||||
if camera == "":
|
||||
return []
|
||||
if camera not in VALID_CAMERAS:
|
||||
return [f"unknown camera '{camera}', valid cameras: {VALID_CAMERAS}"]
|
||||
return []
|
||||
|
||||
|
||||
def _get_valid_effects() -> set[str]:
|
||||
"""Get set of valid effect names."""
|
||||
registry = get_registry()
|
||||
return set(registry.list_all().keys())
|
||||
|
||||
|
||||
def _validate_effects(effects: list[str]) -> list[str]:
|
||||
"""Validate effects list."""
|
||||
if effects is None:
|
||||
return ["effects is None"]
|
||||
valid_effects = _get_valid_effects()
|
||||
issues = []
|
||||
for effect in effects:
|
||||
if effect not in valid_effects:
|
||||
issues.append(
|
||||
f"unknown effect '{effect}', valid effects: {sorted(valid_effects)}"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _validate_border(border: bool | BorderMode) -> list[str]:
|
||||
"""Validate border field."""
|
||||
if isinstance(border, bool):
|
||||
return []
|
||||
if isinstance(border, BorderMode):
|
||||
return []
|
||||
return [f"invalid border value, must be bool or BorderMode, got {type(border)}"]
|
||||
|
||||
|
||||
def get_mvp_summary(config: Any, params: PipelineParams) -> str:
|
||||
"""Get a human-readable summary of the MVP pipeline configuration."""
|
||||
camera_text = "none" if not config.camera else config.camera
|
||||
effects_text = "none" if not config.effects else ", ".join(config.effects)
|
||||
return (
|
||||
f"MVP Pipeline Configuration:\n"
|
||||
f" Source: {config.source}\n"
|
||||
f" Display: {config.display}\n"
|
||||
f" Camera: {camera_text} (static if empty)\n"
|
||||
f" Effects: {effects_text}\n"
|
||||
f" Border: {params.border}"
|
||||
)
|
||||
@@ -1,364 +0,0 @@
|
||||
"""
|
||||
Pipeline visualization - Large animated network visualization with camera modes.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
NODE_NETWORK = {
|
||||
"sources": [
|
||||
{"id": "RSS", "label": "RSS FEEDS", "x": 20, "y": 20},
|
||||
{"id": "POETRY", "label": "POETRY DB", "x": 100, "y": 20},
|
||||
{"id": "NTFY", "label": "NTFY MSG", "x": 180, "y": 20},
|
||||
{"id": "MIC", "label": "MICROPHONE", "x": 260, "y": 20},
|
||||
],
|
||||
"fetch": [
|
||||
{"id": "FETCH", "label": "FETCH LAYER", "x": 140, "y": 100},
|
||||
{"id": "CACHE", "label": "CACHE", "x": 220, "y": 100},
|
||||
],
|
||||
"scroll": [
|
||||
{"id": "STREAM", "label": "STREAM CTRL", "x": 60, "y": 180},
|
||||
{"id": "CAMERA", "label": "CAMERA", "x": 140, "y": 180},
|
||||
{"id": "RENDER", "label": "RENDER", "x": 220, "y": 180},
|
||||
],
|
||||
"effects": [
|
||||
{"id": "NOISE", "label": "NOISE", "x": 20, "y": 260},
|
||||
{"id": "FADE", "label": "FADE", "x": 80, "y": 260},
|
||||
{"id": "GLITCH", "label": "GLITCH", "x": 140, "y": 260},
|
||||
{"id": "FIRE", "label": "FIREHOSE", "x": 200, "y": 260},
|
||||
{"id": "HUD", "label": "HUD", "x": 260, "y": 260},
|
||||
],
|
||||
"display": [
|
||||
{"id": "TERM", "label": "TERMINAL", "x": 20, "y": 340},
|
||||
{"id": "WEB", "label": "WEBSOCKET", "x": 80, "y": 340},
|
||||
{"id": "PYGAME", "label": "PYGAME", "x": 140, "y": 340},
|
||||
{"id": "SIXEL", "label": "SIXEL", "x": 200, "y": 340},
|
||||
{"id": "KITTY", "label": "KITTY", "x": 260, "y": 340},
|
||||
],
|
||||
}
|
||||
|
||||
ALL_NODES = []
|
||||
for group_nodes in NODE_NETWORK.values():
|
||||
ALL_NODES.extend(group_nodes)
|
||||
|
||||
NETWORK_PATHS = [
|
||||
["RSS", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "NOISE", "TERM"],
|
||||
["POETRY", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "FADE", "WEB"],
|
||||
["NTFY", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "GLITCH", "PYGAME"],
|
||||
["MIC", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "FIRE", "SIXEL"],
|
||||
["RSS", "FETCH", "CACHE", "STREAM", "CAMERA", "RENDER", "HUD", "KITTY"],
|
||||
]
|
||||
|
||||
GRID_WIDTH = 300
|
||||
GRID_HEIGHT = 400
|
||||
|
||||
|
||||
def get_node_by_id(node_id: str):
|
||||
for node in ALL_NODES:
|
||||
if node["id"] == node_id:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def draw_network_to_grid(frame: int = 0) -> list[list[str]]:
|
||||
grid = [[" " for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)]
|
||||
|
||||
active_path_idx = (frame // 60) % len(NETWORK_PATHS)
|
||||
active_path = NETWORK_PATHS[active_path_idx]
|
||||
|
||||
for node in ALL_NODES:
|
||||
x, y = node["x"], node["y"]
|
||||
label = node["label"]
|
||||
is_active = node["id"] in active_path
|
||||
is_highlight = node["id"] == active_path[(frame // 15) % len(active_path)]
|
||||
|
||||
node_w, node_h = 20, 7
|
||||
|
||||
for dy in range(node_h):
|
||||
for dx in range(node_w):
|
||||
gx, gy = x + dx, y + dy
|
||||
if 0 <= gx < GRID_WIDTH and 0 <= gy < GRID_HEIGHT:
|
||||
if dy == 0:
|
||||
char = "┌" if dx == 0 else ("┐" if dx == node_w - 1 else "─")
|
||||
elif dy == node_h - 1:
|
||||
char = "└" if dx == 0 else ("┘" if dx == node_w - 1 else "─")
|
||||
elif dy == node_h // 2:
|
||||
if dx == 0 or dx == node_w - 1:
|
||||
char = "│"
|
||||
else:
|
||||
pad = (node_w - 2 - len(label)) // 2
|
||||
if dx - 1 == pad and len(label) <= node_w - 2:
|
||||
char = (
|
||||
label[dx - 1 - pad]
|
||||
if dx - 1 - pad < len(label)
|
||||
else " "
|
||||
)
|
||||
else:
|
||||
char = " "
|
||||
else:
|
||||
char = "│" if dx == 0 or dx == node_w - 1 else " "
|
||||
|
||||
if char.strip():
|
||||
if is_highlight:
|
||||
grid[gy][gx] = "\033[1;38;5;46m" + char + "\033[0m"
|
||||
elif is_active:
|
||||
grid[gy][gx] = "\033[1;38;5;220m" + char + "\033[0m"
|
||||
else:
|
||||
grid[gy][gx] = "\033[38;5;240m" + char + "\033[0m"
|
||||
|
||||
for i, node_id in enumerate(active_path[:-1]):
|
||||
curr = get_node_by_id(node_id)
|
||||
next_id = active_path[i + 1]
|
||||
next_node = get_node_by_id(next_id)
|
||||
if curr and next_node:
|
||||
x1, y1 = curr["x"] + 7, curr["y"] + 2
|
||||
x2, y2 = next_node["x"] + 7, next_node["y"] + 2
|
||||
|
||||
step = 1 if x2 >= x1 else -1
|
||||
for x in range(x1, x2 + step, step):
|
||||
if 0 <= x < GRID_WIDTH and 0 <= y1 < GRID_HEIGHT:
|
||||
grid[y1][x] = "\033[38;5;45m─\033[0m"
|
||||
|
||||
step = 1 if y2 >= y1 else -1
|
||||
for y in range(y1, y2 + step, step):
|
||||
if 0 <= x2 < GRID_WIDTH and 0 <= y < GRID_HEIGHT:
|
||||
grid[y][x2] = "\033[38;5;45m│\033[0m"
|
||||
|
||||
return grid
|
||||
|
||||
|
||||
class TraceCamera:
|
||||
def __init__(self):
|
||||
self.x = 0
|
||||
self.y = 0
|
||||
self.target_x = 0
|
||||
self.target_y = 0
|
||||
self.current_node_idx = 0
|
||||
self.path = []
|
||||
self.frame = 0
|
||||
|
||||
def update(self, dt: float, frame: int = 0) -> None:
|
||||
self.frame = frame
|
||||
active_path = NETWORK_PATHS[(frame // 60) % len(NETWORK_PATHS)]
|
||||
|
||||
if self.path != active_path:
|
||||
self.path = active_path
|
||||
self.current_node_idx = 0
|
||||
|
||||
if self.current_node_idx < len(self.path):
|
||||
node_id = self.path[self.current_node_idx]
|
||||
node = get_node_by_id(node_id)
|
||||
if node:
|
||||
self.target_x = max(0, node["x"] - 40)
|
||||
self.target_y = max(0, node["y"] - 10)
|
||||
|
||||
self.current_node_idx += 1
|
||||
|
||||
self.x += int((self.target_x - self.x) * 0.1)
|
||||
self.y += int((self.target_y - self.y) * 0.1)
|
||||
|
||||
|
||||
class CameraLarge:
|
||||
def __init__(self, viewport_w: int, viewport_h: int, frame: int):
|
||||
self.viewport_w = viewport_w
|
||||
self.viewport_h = viewport_h
|
||||
self.frame = frame
|
||||
self.x = 0
|
||||
self.y = 0
|
||||
self.mode = "trace"
|
||||
self.trace_camera = TraceCamera()
|
||||
|
||||
def set_vertical_mode(self):
|
||||
self.mode = "vertical"
|
||||
|
||||
def set_horizontal_mode(self):
|
||||
self.mode = "horizontal"
|
||||
|
||||
def set_omni_mode(self):
|
||||
self.mode = "omni"
|
||||
|
||||
def set_floating_mode(self):
|
||||
self.mode = "floating"
|
||||
|
||||
def set_trace_mode(self):
|
||||
self.mode = "trace"
|
||||
|
||||
def update(self, dt: float):
|
||||
self.frame += 1
|
||||
|
||||
if self.mode == "vertical":
|
||||
self.y = int((self.frame * 0.5) % (GRID_HEIGHT - self.viewport_h))
|
||||
elif self.mode == "horizontal":
|
||||
self.x = int((self.frame * 0.5) % (GRID_WIDTH - self.viewport_w))
|
||||
elif self.mode == "omni":
|
||||
self.x = int((self.frame * 0.3) % (GRID_WIDTH - self.viewport_w))
|
||||
self.y = int((self.frame * 0.5) % (GRID_HEIGHT - self.viewport_h))
|
||||
elif self.mode == "floating":
|
||||
self.x = int(50 + math.sin(self.frame * 0.02) * 30)
|
||||
self.y = int(50 + math.cos(self.frame * 0.015) * 30)
|
||||
elif self.mode == "trace":
|
||||
self.trace_camera.update(dt, self.frame)
|
||||
self.x = self.trace_camera.x
|
||||
self.y = self.trace_camera.y
|
||||
|
||||
|
||||
def generate_mermaid_graph(frame: int = 0) -> str:
|
||||
effects = ["NOISE", "FADE", "GLITCH", "FIREHOSE"]
|
||||
active_effect = effects[(frame // 30) % 4]
|
||||
|
||||
cam_modes = ["VERTICAL", "HORIZONTAL", "OMNI", "FLOATING", "TRACE"]
|
||||
active_cam = cam_modes[(frame // 100) % 5]
|
||||
|
||||
return f"""graph LR
|
||||
subgraph SOURCES
|
||||
RSS[RSS Feeds]
|
||||
Poetry[Poetry DB]
|
||||
Ntfy[Ntfy Msg]
|
||||
Mic[Microphone]
|
||||
end
|
||||
|
||||
subgraph FETCH
|
||||
Fetch(fetch_all)
|
||||
Cache[(Cache)]
|
||||
end
|
||||
|
||||
subgraph SCROLL
|
||||
Scroll(StreamController)
|
||||
Camera({active_cam})
|
||||
end
|
||||
|
||||
subgraph EFFECTS
|
||||
Noise[NOISE]
|
||||
Fade[FADE]
|
||||
Glitch[GLITCH]
|
||||
Fire[FIREHOSE]
|
||||
Hud[HUD]
|
||||
end
|
||||
|
||||
subgraph DISPLAY
|
||||
Term[Terminal]
|
||||
Web[WebSocket]
|
||||
Pygame[PyGame]
|
||||
Sixel[Sixel]
|
||||
end
|
||||
|
||||
RSS --> Fetch
|
||||
Poetry --> Fetch
|
||||
Ntfy --> Fetch
|
||||
Fetch --> Cache
|
||||
Cache --> Scroll
|
||||
Scroll --> Noise
|
||||
Scroll --> Fade
|
||||
Scroll --> Glitch
|
||||
Scroll --> Fire
|
||||
Scroll --> Hud
|
||||
|
||||
Noise --> Term
|
||||
Fade --> Web
|
||||
Glitch --> Pygame
|
||||
Fire --> Sixel
|
||||
|
||||
style {active_effect} fill:#90EE90
|
||||
style Camera fill:#87CEEB
|
||||
"""
|
||||
|
||||
|
||||
def generate_network_pipeline(
|
||||
width: int = 80, height: int = 24, frame: int = 0
|
||||
) -> list[str]:
|
||||
try:
|
||||
from engine.beautiful_mermaid import render_mermaid_ascii
|
||||
|
||||
mermaid_graph = generate_mermaid_graph(frame)
|
||||
ascii_output = render_mermaid_ascii(mermaid_graph, padding_x=2, padding_y=1)
|
||||
|
||||
lines = ascii_output.split("\n")
|
||||
|
||||
result = []
|
||||
for y in range(height):
|
||||
if y < len(lines):
|
||||
line = lines[y]
|
||||
if len(line) < width:
|
||||
line = line + " " * (width - len(line))
|
||||
elif len(line) > width:
|
||||
line = line[:width]
|
||||
result.append(line)
|
||||
else:
|
||||
result.append(" " * width)
|
||||
|
||||
status_y = height - 2
|
||||
if status_y < height:
|
||||
fps = 60 - (frame % 15)
|
||||
|
||||
cam_modes = ["VERTICAL", "HORIZONTAL", "OMNI", "FLOATING", "TRACE"]
|
||||
cam = cam_modes[(frame // 100) % 5]
|
||||
effects = ["NOISE", "FADE", "GLITCH", "FIREHOSE"]
|
||||
eff = effects[(frame // 30) % 4]
|
||||
|
||||
anim = "▓▒░ "[frame % 4]
|
||||
status = f" FPS:{fps:3.0f} │ {anim} {eff} │ Cam:{cam}"
|
||||
status = status[: width - 4].ljust(width - 4)
|
||||
result[status_y] = "║ " + status + " ║"
|
||||
|
||||
if height > 0:
|
||||
result[0] = "═" * width
|
||||
result[height - 1] = "═" * width
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return [
|
||||
f"Error: {e}" + " " * (width - len(f"Error: {e}")) for _ in range(height)
|
||||
]
|
||||
|
||||
|
||||
def generate_large_network_viewport(
|
||||
viewport_w: int = 80, viewport_h: int = 24, frame: int = 0
|
||||
) -> list[str]:
|
||||
cam_modes = ["VERTICAL", "HORIZONTAL", "OMNI", "FLOATING", "TRACE"]
|
||||
camera_mode = cam_modes[(frame // 100) % 5]
|
||||
|
||||
camera = CameraLarge(viewport_w, viewport_h, frame)
|
||||
|
||||
if camera_mode == "TRACE":
|
||||
camera.set_trace_mode()
|
||||
elif camera_mode == "VERTICAL":
|
||||
camera.set_vertical_mode()
|
||||
elif camera_mode == "HORIZONTAL":
|
||||
camera.set_horizontal_mode()
|
||||
elif camera_mode == "OMNI":
|
||||
camera.set_omni_mode()
|
||||
elif camera_mode == "FLOATING":
|
||||
camera.set_floating_mode()
|
||||
|
||||
camera.update(1 / 60)
|
||||
|
||||
grid = draw_network_to_grid(frame)
|
||||
|
||||
result = []
|
||||
for vy in range(viewport_h):
|
||||
line = ""
|
||||
for vx in range(viewport_w):
|
||||
gx = camera.x + vx
|
||||
gy = camera.y + vy
|
||||
if 0 <= gx < GRID_WIDTH and 0 <= gy < GRID_HEIGHT:
|
||||
line += grid[gy][gx]
|
||||
else:
|
||||
line += " "
|
||||
result.append(line)
|
||||
|
||||
fps = 60 - (frame % 15)
|
||||
|
||||
active_path = NETWORK_PATHS[(frame // 60) % len(NETWORK_PATHS)]
|
||||
active_node = active_path[(frame // 15) % len(active_path)]
|
||||
|
||||
anim = "▓▒░ "[frame % 4]
|
||||
status = f" FPS:{fps:3.0f} │ {anim} {camera_mode:9s} │ Node:{active_node}"
|
||||
status = status[: viewport_w - 4].ljust(viewport_w - 4)
|
||||
if viewport_h > 2:
|
||||
result[viewport_h - 2] = "║ " + status + " ║"
|
||||
|
||||
if viewport_h > 0:
|
||||
result[0] = "═" * viewport_w
|
||||
result[viewport_h - 1] = "═" * viewport_w
|
||||
|
||||
return result
|
||||
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)
|
||||
151
engine/scroll.py
151
engine/scroll.py
@@ -1,151 +0,0 @@
|
||||
"""
|
||||
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||
Orchestrates viewport, frame timing, and layers.
|
||||
"""
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
from engine import config
|
||||
from engine.camera import Camera
|
||||
from engine.display import (
|
||||
Display,
|
||||
TerminalDisplay,
|
||||
)
|
||||
from engine.display import (
|
||||
get_monitor as _get_display_monitor,
|
||||
)
|
||||
from engine.frame import calculate_scroll_step
|
||||
from engine.layers import (
|
||||
apply_glitch,
|
||||
process_effects,
|
||||
render_firehose,
|
||||
render_message_overlay,
|
||||
render_ticker_zone,
|
||||
)
|
||||
from engine.viewport import th, tw
|
||||
|
||||
USE_EFFECT_CHAIN = True
|
||||
|
||||
|
||||
def stream(
|
||||
items,
|
||||
ntfy_poller,
|
||||
mic_monitor,
|
||||
display: Display | None = None,
|
||||
camera: Camera | None = None,
|
||||
):
|
||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||
if display is None:
|
||||
display = TerminalDisplay()
|
||||
if camera is None:
|
||||
camera = Camera.vertical()
|
||||
|
||||
random.shuffle(items)
|
||||
pool = list(items)
|
||||
seen = set()
|
||||
queued = 0
|
||||
|
||||
time.sleep(0.5)
|
||||
w, h = tw(), th()
|
||||
display.init(w, h)
|
||||
display.clear()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh
|
||||
GAP = 3
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||
|
||||
active = []
|
||||
ticker_next_y = ticker_view_h
|
||||
noise_cache = {}
|
||||
scroll_motion_accum = 0.0
|
||||
msg_cache = (None, None)
|
||||
frame_number = 0
|
||||
|
||||
while True:
|
||||
if queued >= config.HEADLINE_LIMIT and not active:
|
||||
break
|
||||
|
||||
t0 = time.monotonic()
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||
|
||||
msg = ntfy_poller.get_active_message()
|
||||
msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache)
|
||||
|
||||
buf = []
|
||||
ticker_h = ticker_view_h
|
||||
|
||||
scroll_motion_accum += config.FRAME_DT
|
||||
while scroll_motion_accum >= scroll_step_interval:
|
||||
scroll_motion_accum -= scroll_step_interval
|
||||
camera.update(config.FRAME_DT)
|
||||
|
||||
while (
|
||||
ticker_next_y < camera.y + ticker_view_h + 10
|
||||
and queued < config.HEADLINE_LIMIT
|
||||
):
|
||||
from engine.effects import next_headline
|
||||
from engine.render import make_block
|
||||
|
||||
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
|
||||
|
||||
active = [
|
||||
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > camera.y
|
||||
]
|
||||
for k in list(noise_cache):
|
||||
if k < camera.y:
|
||||
del noise_cache[k]
|
||||
|
||||
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||
ticker_buf_start = len(buf)
|
||||
|
||||
ticker_buf, noise_cache = render_ticker_zone(
|
||||
active, camera.y, camera.x, ticker_h, w, noise_cache, grad_offset
|
||||
)
|
||||
buf.extend(ticker_buf)
|
||||
|
||||
mic_excess = mic_monitor.excess
|
||||
render_start = time.perf_counter()
|
||||
|
||||
if USE_EFFECT_CHAIN:
|
||||
buf = process_effects(
|
||||
buf,
|
||||
w,
|
||||
h,
|
||||
camera.y,
|
||||
ticker_h,
|
||||
camera.x,
|
||||
mic_excess,
|
||||
grad_offset,
|
||||
frame_number,
|
||||
msg is not None,
|
||||
items,
|
||||
)
|
||||
else:
|
||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||
firehose_buf = render_firehose(items, w, fh, h)
|
||||
buf.extend(firehose_buf)
|
||||
|
||||
if msg_overlay:
|
||||
buf.extend(msg_overlay)
|
||||
|
||||
render_elapsed = (time.perf_counter() - render_start) * 1000
|
||||
monitor = _get_display_monitor()
|
||||
if monitor:
|
||||
chars = sum(len(line) for line in buf)
|
||||
monitor.record_effect("render", render_elapsed, chars, chars)
|
||||
|
||||
display.show(buf)
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||
frame_number += 1
|
||||
|
||||
display.cleanup()
|
||||
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
|
||||
89
mise.toml
89
mise.toml
@@ -2,77 +2,31 @@
|
||||
python = "3.12"
|
||||
hk = "latest"
|
||||
pkl = "latest"
|
||||
uv = "latest"
|
||||
|
||||
[tasks]
|
||||
# =====================
|
||||
# Testing
|
||||
# Core
|
||||
# =====================
|
||||
|
||||
test = "uv run pytest"
|
||||
test-v = { run = "uv run pytest -v", depends = ["sync-all"] }
|
||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html", depends = ["sync-all"] }
|
||||
test-cov-open = { run = "mise run test-cov && open htmlcov/index.html", depends = ["sync-all"] }
|
||||
|
||||
test-browser-install = { run = "uv run playwright install chromium", depends = ["sync-all"] }
|
||||
test-browser = { run = "uv run pytest tests/e2e/", depends = ["test-browser-install"] }
|
||||
|
||||
# =====================
|
||||
# Linting & Formatting
|
||||
# =====================
|
||||
|
||||
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 Modes
|
||||
# Run
|
||||
# =====================
|
||||
|
||||
run = "uv run mainline.py"
|
||||
run-poetry = "uv run mainline.py --poetry"
|
||||
run-firehose = "uv run mainline.py --firehose"
|
||||
|
||||
run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] }
|
||||
run-sixel = { run = "uv run mainline.py --display sixel", depends = ["sync-all"] }
|
||||
run-kitty = { run = "uv run mainline.py --display kitty", depends = ["sync-all"] }
|
||||
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
|
||||
run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] }
|
||||
run-client = { run = "mise run run-both & sleep 2 && $(open http://localhost:8766 2>/dev/null || xdg-open http://localhost:8766 2>/dev/null || echo 'Open http://localhost:8766 manually'); wait", depends = ["sync-all"] }
|
||||
run-terminal = { run = "uv run mainline.py --display terminal", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Pipeline Architecture (unified Stage-based)
|
||||
# Presets
|
||||
# =====================
|
||||
|
||||
run-pipeline = { run = "uv run mainline.py --pipeline --display pygame", depends = ["sync-all"] }
|
||||
run-pipeline-demo = { run = "uv run mainline.py --pipeline --pipeline-preset demo --display pygame", depends = ["sync-all"] }
|
||||
run-pipeline-poetry = { run = "uv run mainline.py --pipeline --pipeline-preset poetry --display pygame", depends = ["sync-all"] }
|
||||
run-pipeline-websocket = { run = "uv run mainline.py --pipeline --pipeline-preset websocket", depends = ["sync-all"] }
|
||||
run-pipeline-firehose = { run = "uv run mainline.py --pipeline --pipeline-preset firehose --display pygame", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Presets (Animation-controlled modes)
|
||||
# =====================
|
||||
|
||||
run-preset-demo = { run = "uv run mainline.py --preset demo --display pygame", depends = ["sync-all"] }
|
||||
run-preset-pipeline = { run = "uv run mainline.py --preset pipeline --display pygame", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Command & Control
|
||||
# =====================
|
||||
|
||||
cmd = "uv run cmdline.py"
|
||||
cmd-stats = { run = "uv run cmdline.py -w \"/effects stats\"", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Benchmark
|
||||
# =====================
|
||||
|
||||
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
||||
benchmark-json = { run = "uv run python -m engine.benchmark --format json --output benchmark.json", depends = ["sync-all"] }
|
||||
benchmark-report = { run = "uv run python -m engine.benchmark --output BENCHMARK.md", depends = ["sync-all"] }
|
||||
|
||||
# Initialize ntfy topics (warm up before first use)
|
||||
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"
|
||||
run-demo = { run = "uv run mainline.py --preset demo --display pygame", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Daemon
|
||||
@@ -89,20 +43,37 @@ daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
||||
sync = "uv sync"
|
||||
sync-all = "uv sync --all-extras"
|
||||
install = "mise run sync"
|
||||
install-dev = { run = "mise run sync-all && uv sync --group dev", depends = ["sync-all"] }
|
||||
bootstrap = { run = "mise run sync-all && uv run mainline.py --help", depends = ["sync-all"] }
|
||||
|
||||
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 = { 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"
|
||||
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
|
||||
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"feedparser>=6.0.0",
|
||||
"Pillow>=10.0.0",
|
||||
"pyright>=1.1.408",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -44,6 +45,7 @@ browser = [
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-benchmark>=4.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"ruff>=0.1.0",
|
||||
@@ -59,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",
|
||||
|
||||
@@ -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()
|
||||
56
test_ui_simple.py
Normal file
56
test_ui_simple.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Simple test for UIPanel integration.
|
||||
"""
|
||||
|
||||
from engine.pipeline.ui import UIPanel, UIConfig, StageControl
|
||||
|
||||
# Create panel
|
||||
panel = UIPanel(UIConfig(panel_width=24))
|
||||
|
||||
# Add some mock stages
|
||||
panel.register_stage(
|
||||
type(
|
||||
"Stage", (), {"name": "noise", "category": "effect", "is_enabled": lambda: True}
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
panel.register_stage(
|
||||
type(
|
||||
"Stage", (), {"name": "fade", "category": "effect", "is_enabled": lambda: True}
|
||||
),
|
||||
enabled=False,
|
||||
)
|
||||
panel.register_stage(
|
||||
type(
|
||||
"Stage",
|
||||
(),
|
||||
{"name": "glitch", "category": "effect", "is_enabled": lambda: True},
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
panel.register_stage(
|
||||
type(
|
||||
"Stage",
|
||||
(),
|
||||
{"name": "font", "category": "transform", "is_enabled": lambda: True},
|
||||
),
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
# Select first stage
|
||||
panel.select_stage("noise")
|
||||
|
||||
# Render at 80x24
|
||||
lines = panel.render(80, 24)
|
||||
print("\n".join(lines))
|
||||
|
||||
print("\nStage list:")
|
||||
for name, ctrl in panel.stages.items():
|
||||
print(f" {name}: enabled={ctrl.enabled}, selected={ctrl.selected}")
|
||||
|
||||
print("\nToggle 'fade' and re-render:")
|
||||
panel.toggle_stage("fade")
|
||||
lines = panel.render(80, 24)
|
||||
print("\n".join(lines))
|
||||
|
||||
print("\nEnabled stages:", panel.get_enabled_stages())
|
||||
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()
|
||||
@@ -1,55 +1,205 @@
|
||||
"""
|
||||
Tests for engine.app module.
|
||||
Integration tests for engine/app.py - pipeline orchestration.
|
||||
|
||||
Tests the main entry point and pipeline mode initialization.
|
||||
"""
|
||||
|
||||
from engine.app import _normalize_preview_rows
|
||||
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 TestNormalizePreviewRows:
|
||||
"""Tests for _normalize_preview_rows function."""
|
||||
class TestMain:
|
||||
"""Test main() entry point."""
|
||||
|
||||
def test_empty_rows(self):
|
||||
"""Empty input returns empty list."""
|
||||
result = _normalize_preview_rows([])
|
||||
assert result == [""]
|
||||
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_strips_left_padding(self):
|
||||
"""Left padding is stripped."""
|
||||
result = _normalize_preview_rows([" content", " more"])
|
||||
assert all(not r.startswith(" ") for r in result)
|
||||
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_preserves_content(self):
|
||||
"""Content is preserved."""
|
||||
result = _normalize_preview_rows([" hello world "])
|
||||
assert "hello world" in result[0]
|
||||
|
||||
def test_handles_all_empty_rows(self):
|
||||
"""All empty rows returns single empty string."""
|
||||
result = _normalize_preview_rows(["", " ", ""])
|
||||
assert result == [""]
|
||||
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 TestAppConstants:
|
||||
"""Tests for app module constants."""
|
||||
class TestRunPipelineMode:
|
||||
"""Test run_pipeline_mode() initialization."""
|
||||
|
||||
def test_title_defined(self):
|
||||
"""TITLE is defined."""
|
||||
from engine.app import TITLE
|
||||
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"
|
||||
|
||||
assert len(TITLE) > 0
|
||||
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_title_lines_are_strings(self):
|
||||
"""TITLE contains string lines."""
|
||||
from engine.app import TITLE
|
||||
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
|
||||
|
||||
assert all(isinstance(line, str) for line in TITLE)
|
||||
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
|
||||
|
||||
class TestAppImports:
|
||||
"""Tests for app module imports."""
|
||||
# Verify fetch_all was NOT called (cache was used)
|
||||
mock_fetch.assert_not_called()
|
||||
mock_load.assert_called_once()
|
||||
|
||||
def test_app_imports_without_error(self):
|
||||
"""Module imports without error."""
|
||||
from engine import app
|
||||
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
|
||||
|
||||
assert app is not None
|
||||
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()
|
||||
|
||||
@@ -37,8 +37,8 @@ class TestBenchmarkNullDisplay:
|
||||
"""Effects should meet minimum processing throughput."""
|
||||
import time
|
||||
|
||||
from effects_plugins import discover_plugins
|
||||
from engine.effects import EffectContext, get_registry
|
||||
from engine.effects.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
registry = get_registry()
|
||||
|
||||
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
|
||||
@@ -1,19 +1,18 @@
|
||||
|
||||
from engine.camera import Camera, CameraMode
|
||||
|
||||
|
||||
def test_camera_vertical_default():
|
||||
"""Test default vertical camera."""
|
||||
cam = Camera()
|
||||
assert cam.mode == CameraMode.VERTICAL
|
||||
assert cam.mode == CameraMode.FEED
|
||||
assert cam.x == 0
|
||||
assert cam.y == 0
|
||||
|
||||
|
||||
def test_camera_vertical_factory():
|
||||
"""Test vertical factory method."""
|
||||
cam = Camera.vertical(speed=2.0)
|
||||
assert cam.mode == CameraMode.VERTICAL
|
||||
cam = Camera.feed(speed=2.0)
|
||||
assert cam.mode == CameraMode.FEED
|
||||
assert cam.speed == 2.0
|
||||
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""
|
||||
Tests for engine.controller module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine import config
|
||||
from engine.controller import StreamController, _get_display
|
||||
|
||||
|
||||
class TestGetDisplay:
|
||||
"""Tests for _get_display function."""
|
||||
|
||||
@patch("engine.controller.WebSocketDisplay")
|
||||
@patch("engine.controller.TerminalDisplay")
|
||||
def test_get_display_terminal(self, mock_terminal, mock_ws):
|
||||
"""returns TerminalDisplay for display=terminal."""
|
||||
mock_terminal.return_value = MagicMock()
|
||||
mock_ws.return_value = MagicMock()
|
||||
|
||||
cfg = config.Config(display="terminal")
|
||||
display = _get_display(cfg)
|
||||
|
||||
mock_terminal.assert_called()
|
||||
assert isinstance(display, MagicMock)
|
||||
|
||||
@patch("engine.controller.WebSocketDisplay")
|
||||
@patch("engine.controller.TerminalDisplay")
|
||||
def test_get_display_websocket(self, mock_terminal, mock_ws):
|
||||
"""returns WebSocketDisplay for display=websocket."""
|
||||
mock_ws_instance = MagicMock()
|
||||
mock_ws.return_value = mock_ws_instance
|
||||
mock_terminal.return_value = MagicMock()
|
||||
|
||||
cfg = config.Config(display="websocket")
|
||||
_get_display(cfg)
|
||||
|
||||
mock_ws.assert_called()
|
||||
mock_ws_instance.start_server.assert_called()
|
||||
mock_ws_instance.start_http_server.assert_called()
|
||||
|
||||
@patch("engine.controller.SixelDisplay")
|
||||
def test_get_display_sixel(self, mock_sixel):
|
||||
"""returns SixelDisplay for display=sixel."""
|
||||
mock_sixel.return_value = MagicMock()
|
||||
cfg = config.Config(display="sixel")
|
||||
_get_display(cfg)
|
||||
|
||||
mock_sixel.assert_called()
|
||||
|
||||
def test_get_display_unknown_returns_null(self):
|
||||
"""returns NullDisplay for unknown display mode."""
|
||||
cfg = config.Config(display="unknown")
|
||||
display = _get_display(cfg)
|
||||
|
||||
from engine.display import NullDisplay
|
||||
|
||||
assert isinstance(display, NullDisplay)
|
||||
|
||||
@patch("engine.controller.WebSocketDisplay")
|
||||
@patch("engine.controller.TerminalDisplay")
|
||||
@patch("engine.controller.MultiDisplay")
|
||||
def test_get_display_both(self, mock_multi, mock_terminal, mock_ws):
|
||||
"""returns MultiDisplay for display=both."""
|
||||
mock_terminal_instance = MagicMock()
|
||||
mock_ws_instance = MagicMock()
|
||||
mock_terminal.return_value = mock_terminal_instance
|
||||
mock_ws.return_value = mock_ws_instance
|
||||
|
||||
cfg = config.Config(display="both")
|
||||
_get_display(cfg)
|
||||
|
||||
mock_multi.assert_called()
|
||||
call_args = mock_multi.call_args[0][0]
|
||||
assert mock_terminal_instance in call_args
|
||||
assert mock_ws_instance in call_args
|
||||
|
||||
|
||||
class TestStreamController:
|
||||
"""Tests for StreamController class."""
|
||||
|
||||
def test_init_default_config(self):
|
||||
"""StreamController initializes with default config."""
|
||||
controller = StreamController()
|
||||
assert controller.config is not None
|
||||
assert isinstance(controller.config, config.Config)
|
||||
|
||||
def test_init_custom_config(self):
|
||||
"""StreamController accepts custom config."""
|
||||
custom_config = config.Config(headline_limit=500)
|
||||
controller = StreamController(config=custom_config)
|
||||
assert controller.config.headline_limit == 500
|
||||
|
||||
def test_init_sources_none_by_default(self):
|
||||
"""Sources are None until initialized."""
|
||||
controller = StreamController()
|
||||
assert controller.mic is None
|
||||
assert controller.ntfy is None
|
||||
|
||||
@patch("engine.controller.MicMonitor")
|
||||
@patch("engine.controller.NtfyPoller")
|
||||
def test_initialize_sources(self, mock_ntfy, mock_mic):
|
||||
"""initialize_sources creates mic and ntfy instances."""
|
||||
mock_mic_instance = MagicMock()
|
||||
mock_mic_instance.available = True
|
||||
mock_mic_instance.start.return_value = True
|
||||
mock_mic.return_value = mock_mic_instance
|
||||
|
||||
mock_ntfy_instance = MagicMock()
|
||||
mock_ntfy_instance.start.return_value = True
|
||||
mock_ntfy.return_value = mock_ntfy_instance
|
||||
|
||||
controller = StreamController()
|
||||
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||
|
||||
assert mic_ok is True
|
||||
assert ntfy_ok is True
|
||||
assert controller.mic is not None
|
||||
assert controller.ntfy is not None
|
||||
|
||||
@patch("engine.controller.MicMonitor")
|
||||
@patch("engine.controller.NtfyPoller")
|
||||
def test_initialize_sources_mic_unavailable(self, mock_ntfy, mock_mic):
|
||||
"""initialize_sources handles unavailable mic."""
|
||||
mock_mic_instance = MagicMock()
|
||||
mock_mic_instance.available = False
|
||||
mock_mic.return_value = mock_mic_instance
|
||||
|
||||
mock_ntfy_instance = MagicMock()
|
||||
mock_ntfy_instance.start.return_value = True
|
||||
mock_ntfy.return_value = mock_ntfy_instance
|
||||
|
||||
controller = StreamController()
|
||||
mic_ok, ntfy_ok = controller.initialize_sources()
|
||||
|
||||
assert mic_ok is False
|
||||
assert ntfy_ok is True
|
||||
|
||||
@patch("engine.controller.MicMonitor")
|
||||
def test_initialize_sources_cc_subscribed(self, mock_mic):
|
||||
"""initialize_sources subscribes C&C handler."""
|
||||
mock_mic_instance = MagicMock()
|
||||
mock_mic_instance.available = False
|
||||
mock_mic_instance.start.return_value = False
|
||||
mock_mic.return_value = mock_mic_instance
|
||||
|
||||
with patch("engine.controller.NtfyPoller") as mock_ntfy:
|
||||
mock_ntfy_instance = MagicMock()
|
||||
mock_ntfy_instance.start.return_value = True
|
||||
mock_ntfy.return_value = mock_ntfy_instance
|
||||
|
||||
controller = StreamController()
|
||||
controller.initialize_sources()
|
||||
|
||||
mock_ntfy_instance.subscribe.assert_called()
|
||||
|
||||
|
||||
class TestStreamControllerCleanup:
|
||||
"""Tests for StreamController cleanup."""
|
||||
|
||||
@patch("engine.controller.MicMonitor")
|
||||
def test_cleanup_stops_mic(self, mock_mic):
|
||||
"""cleanup stops the microphone if running."""
|
||||
mock_mic_instance = MagicMock()
|
||||
mock_mic.return_value = mock_mic_instance
|
||||
|
||||
controller = StreamController()
|
||||
controller.mic = mock_mic_instance
|
||||
controller.cleanup()
|
||||
|
||||
mock_mic_instance.stop.assert_called_once()
|
||||
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)
|
||||
@@ -2,9 +2,12 @@
|
||||
Tests for engine.display module.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay
|
||||
import pytest
|
||||
|
||||
from engine.display import DisplayRegistry, NullDisplay, TerminalDisplay, render_border
|
||||
from engine.display.backends.multi import MultiDisplay
|
||||
|
||||
|
||||
@@ -74,11 +77,13 @@ class TestDisplayRegistry:
|
||||
DisplayRegistry.initialize()
|
||||
assert DisplayRegistry.get("terminal") == TerminalDisplay
|
||||
assert DisplayRegistry.get("null") == NullDisplay
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
from engine.display.backends.pygame import PygameDisplay
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
assert DisplayRegistry.get("websocket") == WebSocketDisplay
|
||||
assert DisplayRegistry.get("sixel") == SixelDisplay
|
||||
assert DisplayRegistry.get("pygame") == PygameDisplay
|
||||
# Removed backends (sixel, kitty) should not be present
|
||||
assert DisplayRegistry.get("sixel") is None
|
||||
|
||||
def test_initialize_idempotent(self):
|
||||
"""initialize can be called multiple times safely."""
|
||||
@@ -115,6 +120,115 @@ class TestTerminalDisplay:
|
||||
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."""
|
||||
@@ -141,6 +255,113 @@ class TestNullDisplay:
|
||||
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."""
|
||||
@@ -165,10 +386,10 @@ class TestMultiDisplay:
|
||||
multi = MultiDisplay([mock_display1, mock_display2])
|
||||
|
||||
buffer = ["line1", "line2"]
|
||||
multi.show(buffer)
|
||||
multi.show(buffer, border=False)
|
||||
|
||||
mock_display1.show.assert_called_once_with(buffer)
|
||||
mock_display2.show.assert_called_once_with(buffer)
|
||||
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."""
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""
|
||||
Tests for engine.emitters module.
|
||||
"""
|
||||
|
||||
from engine.emitters import EventEmitter, Startable, Stoppable
|
||||
|
||||
|
||||
class TestEventEmitterProtocol:
|
||||
"""Tests for EventEmitter protocol."""
|
||||
|
||||
def test_protocol_exists(self):
|
||||
"""EventEmitter protocol is defined."""
|
||||
assert EventEmitter is not None
|
||||
|
||||
def test_protocol_has_subscribe_method(self):
|
||||
"""EventEmitter has subscribe method in protocol."""
|
||||
assert hasattr(EventEmitter, "subscribe")
|
||||
|
||||
def test_protocol_has_unsubscribe_method(self):
|
||||
"""EventEmitter has unsubscribe method in protocol."""
|
||||
assert hasattr(EventEmitter, "unsubscribe")
|
||||
|
||||
|
||||
class TestStartableProtocol:
|
||||
"""Tests for Startable protocol."""
|
||||
|
||||
def test_protocol_exists(self):
|
||||
"""Startable protocol is defined."""
|
||||
assert Startable is not None
|
||||
|
||||
def test_protocol_has_start_method(self):
|
||||
"""Startable has start method in protocol."""
|
||||
assert hasattr(Startable, "start")
|
||||
|
||||
|
||||
class TestStoppableProtocol:
|
||||
"""Tests for Stoppable protocol."""
|
||||
|
||||
def test_protocol_exists(self):
|
||||
"""Stoppable protocol is defined."""
|
||||
assert Stoppable is not None
|
||||
|
||||
def test_protocol_has_stop_method(self):
|
||||
"""Stoppable has stop method in protocol."""
|
||||
assert hasattr(Stoppable, "stop")
|
||||
|
||||
|
||||
class TestProtocolCompliance:
|
||||
"""Tests that existing classes comply with protocols."""
|
||||
|
||||
def test_ntfy_poller_complies_with_protocol(self):
|
||||
"""NtfyPoller implements EventEmitter protocol."""
|
||||
from engine.ntfy import NtfyPoller
|
||||
|
||||
poller = NtfyPoller("http://example.com/topic")
|
||||
assert hasattr(poller, "subscribe")
|
||||
assert hasattr(poller, "unsubscribe")
|
||||
assert callable(poller.subscribe)
|
||||
assert callable(poller.unsubscribe)
|
||||
|
||||
def test_mic_monitor_complies_with_protocol(self):
|
||||
"""MicMonitor implements EventEmitter and Startable protocols."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
assert hasattr(monitor, "subscribe")
|
||||
assert hasattr(monitor, "unsubscribe")
|
||||
assert hasattr(monitor, "start")
|
||||
assert hasattr(monitor, "stop")
|
||||
236
tests/test_framebuffer_stage.py
Normal file
236
tests/test_framebuffer_stage.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Tests for FrameBufferStage.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.pipeline.core import DataType, PipelineContext
|
||||
from engine.pipeline.params import PipelineParams
|
||||
from engine.pipeline.stages.framebuffer import FrameBufferConfig, FrameBufferStage
|
||||
|
||||
|
||||
def make_ctx(width: int = 80, height: int = 24) -> PipelineContext:
|
||||
"""Create a PipelineContext for testing."""
|
||||
ctx = PipelineContext()
|
||||
params = PipelineParams()
|
||||
params.viewport_width = width
|
||||
params.viewport_height = height
|
||||
ctx.params = params
|
||||
return ctx
|
||||
|
||||
|
||||
class TestFrameBufferStage:
|
||||
"""Tests for FrameBufferStage."""
|
||||
|
||||
def test_init(self):
|
||||
"""FrameBufferStage initializes with default config."""
|
||||
stage = FrameBufferStage()
|
||||
assert stage.name == "framebuffer"
|
||||
assert stage.category == "effect"
|
||||
assert stage.config.history_depth == 2
|
||||
|
||||
def test_capabilities(self):
|
||||
"""Stage provides framebuffer.history capability."""
|
||||
stage = FrameBufferStage()
|
||||
assert "framebuffer.history" in stage.capabilities
|
||||
|
||||
def test_dependencies(self):
|
||||
"""Stage depends on render.output."""
|
||||
stage = FrameBufferStage()
|
||||
assert "render.output" in stage.dependencies
|
||||
|
||||
def test_inlet_outlet_types(self):
|
||||
"""Stage accepts and produces TEXT_BUFFER."""
|
||||
stage = FrameBufferStage()
|
||||
assert DataType.TEXT_BUFFER in stage.inlet_types
|
||||
assert DataType.TEXT_BUFFER in stage.outlet_types
|
||||
|
||||
def test_init_context(self):
|
||||
"""init initializes context state."""
|
||||
stage = FrameBufferStage()
|
||||
ctx = make_ctx()
|
||||
|
||||
result = stage.init(ctx)
|
||||
|
||||
assert result is True
|
||||
assert ctx.get("frame_history") == []
|
||||
assert ctx.get("intensity_history") == []
|
||||
|
||||
def test_process_stores_buffer_in_history(self):
|
||||
"""process stores buffer in history."""
|
||||
stage = FrameBufferStage()
|
||||
ctx = make_ctx()
|
||||
stage.init(ctx)
|
||||
|
||||
buffer = ["line1", "line2", "line3"]
|
||||
result = stage.process(buffer, ctx)
|
||||
|
||||
assert result == buffer # Pass-through
|
||||
history = ctx.get("frame_history")
|
||||
assert len(history) == 1
|
||||
assert history[0] == buffer
|
||||
|
||||
def test_process_computes_intensity(self):
|
||||
"""process computes intensity map."""
|
||||
stage = FrameBufferStage()
|
||||
ctx = make_ctx()
|
||||
stage.init(ctx)
|
||||
|
||||
buffer = ["hello world", "test line", ""]
|
||||
stage.process(buffer, ctx)
|
||||
|
||||
intensity = ctx.get("current_intensity")
|
||||
assert intensity is not None
|
||||
assert len(intensity) == 3 # Three rows
|
||||
# Non-empty lines should have intensity > 0
|
||||
assert intensity[0] > 0
|
||||
assert intensity[1] > 0
|
||||
# Empty line should have intensity 0
|
||||
assert intensity[2] == 0.0
|
||||
|
||||
def test_process_keeps_multiple_frames(self):
|
||||
"""process keeps configured depth of frames."""
|
||||
config = FrameBufferConfig(history_depth=3)
|
||||
stage = FrameBufferStage(config)
|
||||
ctx = make_ctx()
|
||||
stage.init(ctx)
|
||||
|
||||
# Process several frames
|
||||
for i in range(5):
|
||||
buffer = [f"frame {i}"]
|
||||
stage.process(buffer, ctx)
|
||||
|
||||
history = ctx.get("frame_history")
|
||||
assert len(history) == 3 # Only last 3 kept
|
||||
# Should be in reverse chronological order (most recent first)
|
||||
assert history[0] == ["frame 4"]
|
||||
assert history[1] == ["frame 3"]
|
||||
assert history[2] == ["frame 2"]
|
||||
|
||||
def test_process_keeps_intensity_sync(self):
|
||||
"""process keeps intensity history in sync with frame history."""
|
||||
config = FrameBufferConfig(history_depth=3)
|
||||
stage = FrameBufferStage(config)
|
||||
ctx = make_ctx()
|
||||
stage.init(ctx)
|
||||
|
||||
buffers = [
|
||||
["a"],
|
||||
["bb"],
|
||||
["ccc"],
|
||||
]
|
||||
for buf in buffers:
|
||||
stage.process(buf, ctx)
|
||||
|
||||
frame_hist = ctx.get("frame_history")
|
||||
intensity_hist = ctx.get("intensity_history")
|
||||
assert len(frame_hist) == len(intensity_hist) == 3
|
||||
|
||||
# Each frame's intensity should match
|
||||
for i, frame in enumerate(frame_hist):
|
||||
computed_intensity = stage._compute_buffer_intensity(frame, len(frame))
|
||||
assert intensity_hist[i] == pytest.approx(computed_intensity)
|
||||
|
||||
def test_get_frame(self):
|
||||
"""get_frame retrieves frames from history by index."""
|
||||
config = FrameBufferConfig(history_depth=3)
|
||||
stage = FrameBufferStage(config)
|
||||
ctx = make_ctx()
|
||||
stage.init(ctx)
|
||||
|
||||
buffers = [["f1"], ["f2"], ["f3"]]
|
||||
for buf in buffers:
|
||||
stage.process(buf, ctx)
|
||||
|
||||
assert stage.get_frame(0, ctx) == ["f3"] # Most recent
|
||||
assert stage.get_frame(1, ctx) == ["f2"]
|
||||
assert stage.get_frame(2, ctx) == ["f1"]
|
||||
assert stage.get_frame(3, ctx) is None # Out of range
|
||||
|
||||
def test_get_intensity(self):
|
||||
"""get_intensity retrieves intensity maps by index."""
|
||||
stage = FrameBufferStage()
|
||||
ctx = make_ctx()
|
||||
stage.init(ctx)
|
||||
|
||||
buffers = [["line"], ["longer line"]]
|
||||
for buf in buffers:
|
||||
stage.process(buf, ctx)
|
||||
|
||||
intensity0 = stage.get_intensity(0, ctx)
|
||||
intensity1 = stage.get_intensity(1, ctx)
|
||||
assert intensity0 is not None
|
||||
assert intensity1 is not None
|
||||
# Longer line should have higher intensity (more non-space chars)
|
||||
assert sum(intensity1) > sum(intensity0)
|
||||
|
||||
def test_compute_buffer_intensity_simple(self):
|
||||
"""_compute_buffer_intensity computes simple density."""
|
||||
stage = FrameBufferStage()
|
||||
|
||||
buf = ["abc", " ", "de"]
|
||||
intensities = stage._compute_buffer_intensity(buf, max_rows=3)
|
||||
|
||||
assert len(intensities) == 3
|
||||
# "abc" -> 3/3 = 1.0
|
||||
assert pytest.approx(intensities[0]) == 1.0
|
||||
# " " -> 0/2 = 0.0
|
||||
assert pytest.approx(intensities[1]) == 0.0
|
||||
# "de" -> 2/2 = 1.0
|
||||
assert pytest.approx(intensities[2]) == 1.0
|
||||
|
||||
def test_compute_buffer_intensity_with_ansi(self):
|
||||
"""_compute_buffer_intensity strips ANSI codes."""
|
||||
stage = FrameBufferStage()
|
||||
|
||||
# Line with ANSI color codes
|
||||
buf = ["\033[31mred\033[0m", "normal"]
|
||||
intensities = stage._compute_buffer_intensity(buf, max_rows=2)
|
||||
|
||||
assert len(intensities) == 2
|
||||
# Should treat "red" as 3 non-space chars
|
||||
assert pytest.approx(intensities[0]) == 1.0 # "red" = 3/3
|
||||
assert pytest.approx(intensities[1]) == 1.0 # "normal" = 6/6
|
||||
|
||||
def test_compute_buffer_intensity_padding(self):
|
||||
"""_compute_buffer_intensity pads to max_rows."""
|
||||
stage = FrameBufferStage()
|
||||
|
||||
buf = ["short"]
|
||||
intensities = stage._compute_buffer_intensity(buf, max_rows=5)
|
||||
|
||||
assert len(intensities) == 5
|
||||
assert intensities[0] > 0
|
||||
assert all(i == 0.0 for i in intensities[1:])
|
||||
|
||||
def test_thread_safety(self):
|
||||
"""process is thread-safe."""
|
||||
from threading import Thread
|
||||
|
||||
stage = FrameBufferStage()
|
||||
ctx = make_ctx()
|
||||
stage.init(ctx)
|
||||
|
||||
results = []
|
||||
|
||||
def worker(idx):
|
||||
buffer = [f"thread {idx}"]
|
||||
stage.process(buffer, ctx)
|
||||
results.append(len(ctx.get("frame_history", [])))
|
||||
|
||||
threads = [Thread(target=worker, args=(i,)) for i in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# All threads should see consistent state
|
||||
assert len(ctx.get("frame_history")) <= 2 # Depth limit
|
||||
# All worker threads should have completed without errors
|
||||
assert len(results) == 10
|
||||
|
||||
def test_cleanup(self):
|
||||
"""cleanup does nothing but can be called."""
|
||||
stage = FrameBufferStage()
|
||||
# Should not raise
|
||||
stage.cleanup()
|
||||
240
tests/test_glitch_effect.py
Normal file
240
tests/test_glitch_effect.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Tests for Glitch effect - regression tests for stability issues.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.display import NullDisplay
|
||||
from engine.effects.types import EffectConfig, EffectContext
|
||||
|
||||
|
||||
def strip_ansi(s: str) -> str:
|
||||
"""Remove ANSI escape sequences from string."""
|
||||
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
|
||||
|
||||
|
||||
class TestGlitchEffectStability:
|
||||
"""Regression tests for Glitch effect stability."""
|
||||
|
||||
@pytest.fixture
|
||||
def effect_context(self):
|
||||
"""Create a consistent effect context for testing."""
|
||||
return EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
frame_number=0,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def stable_buffer(self):
|
||||
"""Create a stable buffer for testing."""
|
||||
return ["line" + str(i).zfill(2) + " " * 60 for i in range(24)]
|
||||
|
||||
def test_glitch_preserves_line_count(self, effect_context, stable_buffer):
|
||||
"""Glitch should not change the number of lines in buffer."""
|
||||
from engine.effects.plugins.glitch import GlitchEffect
|
||||
|
||||
effect = GlitchEffect()
|
||||
result = effect.process(stable_buffer, effect_context)
|
||||
|
||||
assert len(result) == len(stable_buffer), (
|
||||
f"Line count changed from {len(stable_buffer)} to {len(result)}"
|
||||
)
|
||||
|
||||
def test_glitch_preserves_line_lengths(self, effect_context, stable_buffer):
|
||||
"""Glitch should not change individual line lengths - prevents viewport jumping.
|
||||
|
||||
Note: Effects may add ANSI color codes, so we check VISIBLE length (stripped).
|
||||
"""
|
||||
from engine.effects.plugins.glitch import GlitchEffect
|
||||
|
||||
effect = GlitchEffect()
|
||||
|
||||
# Run multiple times to catch randomness
|
||||
for _ in range(10):
|
||||
result = effect.process(stable_buffer, effect_context)
|
||||
for i, (orig, new) in enumerate(zip(stable_buffer, result, strict=False)):
|
||||
visible_new = strip_ansi(new)
|
||||
assert len(visible_new) == len(orig), (
|
||||
f"Line {i} visible length changed from {len(orig)} to {len(visible_new)}"
|
||||
)
|
||||
|
||||
def test_glitch_no_cursor_positioning(self, effect_context, stable_buffer):
|
||||
"""Glitch should not use cursor positioning escape sequences.
|
||||
|
||||
Regression test: Previously glitch used \\033[{row};1H which caused
|
||||
conflicts with HUD and border rendering.
|
||||
"""
|
||||
from engine.effects.plugins.glitch import GlitchEffect
|
||||
|
||||
effect = GlitchEffect()
|
||||
result = effect.process(stable_buffer, effect_context)
|
||||
|
||||
# Check no cursor positioning in output
|
||||
cursor_pos_pattern = re.compile(r"\033\[[0-9]+;[0-9]+H")
|
||||
for i, line in enumerate(result):
|
||||
match = cursor_pos_pattern.search(line)
|
||||
assert match is None, (
|
||||
f"Line {i} contains cursor positioning: {repr(line[:50])}"
|
||||
)
|
||||
|
||||
def test_glitch_output_deterministic_given_seed(
|
||||
self, effect_context, stable_buffer
|
||||
):
|
||||
"""Glitch output should be deterministic given the same random seed."""
|
||||
from engine.effects.plugins.glitch import GlitchEffect
|
||||
|
||||
effect = GlitchEffect()
|
||||
effect.config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
# With fixed random state, should get same result
|
||||
import random
|
||||
|
||||
random.seed(42)
|
||||
result1 = effect.process(stable_buffer, effect_context)
|
||||
|
||||
random.seed(42)
|
||||
result2 = effect.process(stable_buffer, effect_context)
|
||||
|
||||
assert result1 == result2, (
|
||||
"Glitch should be deterministic with fixed random seed"
|
||||
)
|
||||
|
||||
|
||||
class TestEffectViewportStability:
|
||||
"""Tests to catch effects that cause viewport instability."""
|
||||
|
||||
def test_null_display_stable_without_effects(self):
|
||||
"""NullDisplay should produce identical output without effects."""
|
||||
display = NullDisplay()
|
||||
display.init(80, 24)
|
||||
|
||||
buffer = ["test line " + "x" * 60 for _ in range(24)]
|
||||
|
||||
display.show(buffer)
|
||||
output1 = display._last_buffer
|
||||
|
||||
display.show(buffer)
|
||||
output2 = display._last_buffer
|
||||
|
||||
assert output1 == output2, (
|
||||
"NullDisplay output should be identical for identical inputs"
|
||||
)
|
||||
|
||||
def test_effect_chain_preserves_dimensions(self):
|
||||
"""Effect chain should preserve buffer dimensions."""
|
||||
from engine.effects.plugins.fade import FadeEffect
|
||||
from engine.effects.plugins.glitch import GlitchEffect
|
||||
from engine.effects.plugins.noise import NoiseEffect
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
)
|
||||
|
||||
buffer = ["x" * 80 for _ in range(24)]
|
||||
original_len = len(buffer)
|
||||
original_widths = [len(line) for line in buffer]
|
||||
|
||||
effects = [NoiseEffect(), FadeEffect(), GlitchEffect()]
|
||||
|
||||
for effect in effects:
|
||||
buffer = effect.process(buffer, ctx)
|
||||
|
||||
# Check dimensions preserved (check VISIBLE length, not raw)
|
||||
# Effects may add ANSI codes which increase raw length but not visible width
|
||||
assert len(buffer) == original_len, (
|
||||
f"{effect.name} changed line count from {original_len} to {len(buffer)}"
|
||||
)
|
||||
for i, (orig_w, new_line) in enumerate(
|
||||
zip(original_widths, buffer, strict=False)
|
||||
):
|
||||
visible_len = len(strip_ansi(new_line))
|
||||
assert visible_len == orig_w, (
|
||||
f"{effect.name} changed line {i} visible width from {orig_w} to {visible_len}"
|
||||
)
|
||||
|
||||
|
||||
class TestEffectTestMatrix:
|
||||
"""Effect test matrix - test each effect for stability."""
|
||||
|
||||
@pytest.fixture
|
||||
def effect_names(self):
|
||||
"""List of all effect names to test."""
|
||||
return ["noise", "fade", "glitch", "firehose", "border"]
|
||||
|
||||
@pytest.fixture
|
||||
def stable_input_buffer(self):
|
||||
"""A predictable buffer for testing."""
|
||||
return [f"row{i:02d}" + " " * 70 for i in range(24)]
|
||||
|
||||
@pytest.mark.parametrize("effect_name", ["noise", "fade", "glitch"])
|
||||
def test_effect_preserves_buffer_dimensions(self, effect_name, stable_input_buffer):
|
||||
"""Each effect should preserve input buffer dimensions."""
|
||||
try:
|
||||
if effect_name == "border":
|
||||
# Border is handled differently
|
||||
pytest.skip("Border handled by display")
|
||||
else:
|
||||
effect_module = __import__(
|
||||
f"engine.effects.plugins.{effect_name}",
|
||||
fromlist=[f"{effect_name.title()}Effect"],
|
||||
)
|
||||
effect_class = getattr(effect_module, f"{effect_name.title()}Effect")
|
||||
effect = effect_class()
|
||||
except ImportError:
|
||||
pytest.skip(f"Effect {effect_name} not available")
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
)
|
||||
|
||||
result = effect.process(stable_input_buffer, ctx)
|
||||
|
||||
# Check dimensions preserved (check VISIBLE length)
|
||||
assert len(result) == len(stable_input_buffer), (
|
||||
f"{effect_name} changed line count"
|
||||
)
|
||||
for i, (orig, new) in enumerate(zip(stable_input_buffer, result, strict=False)):
|
||||
visible_new = strip_ansi(new)
|
||||
assert len(visible_new) == len(orig), (
|
||||
f"{effect_name} changed line {i} visible length from {len(orig)} to {len(visible_new)}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("effect_name", ["noise", "fade", "glitch"])
|
||||
def test_effect_no_cursor_positioning(self, effect_name, stable_input_buffer):
|
||||
"""Effects should not use cursor positioning (causes display conflicts)."""
|
||||
try:
|
||||
effect_module = __import__(
|
||||
f"engine.effects.plugins.{effect_name}",
|
||||
fromlist=[f"{effect_name.title()}Effect"],
|
||||
)
|
||||
effect_class = getattr(effect_module, f"{effect_name.title()}Effect")
|
||||
effect = effect_class()
|
||||
except ImportError:
|
||||
pytest.skip(f"Effect {effect_name} not available")
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=80,
|
||||
terminal_height=24,
|
||||
scroll_cam=0,
|
||||
ticker_height=20,
|
||||
)
|
||||
|
||||
result = effect.process(stable_input_buffer, ctx)
|
||||
|
||||
cursor_pos_pattern = re.compile(r"\033\[[0-9]+;[0-9]+H")
|
||||
for i, line in enumerate(result):
|
||||
match = cursor_pos_pattern.search(line)
|
||||
assert match is None, (
|
||||
f"{effect_name} uses cursor positioning on line {i}: {repr(line[:50])}"
|
||||
)
|
||||
@@ -1,11 +1,10 @@
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor, set_monitor
|
||||
from engine.effects.types import EffectContext
|
||||
|
||||
|
||||
def test_hud_effect_adds_hud_lines():
|
||||
"""Test that HUD effect adds HUD lines to the buffer."""
|
||||
from effects_plugins.hud import HudEffect
|
||||
from engine.effects.plugins.hud import HudEffect
|
||||
|
||||
set_monitor(PerformanceMonitor())
|
||||
|
||||
@@ -51,7 +50,7 @@ def test_hud_effect_adds_hud_lines():
|
||||
|
||||
def test_hud_effect_shows_current_effect():
|
||||
"""Test that HUD displays the correct effect name."""
|
||||
from effects_plugins.hud import HudEffect
|
||||
from engine.effects.plugins.hud import HudEffect
|
||||
|
||||
set_monitor(PerformanceMonitor())
|
||||
|
||||
@@ -80,7 +79,7 @@ def test_hud_effect_shows_current_effect():
|
||||
|
||||
def test_hud_effect_shows_intensity():
|
||||
"""Test that HUD displays intensity percentage."""
|
||||
from effects_plugins.hud import HudEffect
|
||||
from engine.effects.plugins.hud import HudEffect
|
||||
|
||||
set_monitor(PerformanceMonitor())
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
"""
|
||||
Tests for engine.layers module.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine import layers
|
||||
|
||||
|
||||
class TestRenderMessageOverlay:
|
||||
"""Tests for render_message_overlay function."""
|
||||
|
||||
def test_no_message_returns_empty(self):
|
||||
"""Returns empty list when msg is None."""
|
||||
result, cache = layers.render_message_overlay(None, 80, 24, (None, None))
|
||||
assert result == []
|
||||
assert cache[0] is None
|
||||
|
||||
def test_message_returns_overlay_lines(self):
|
||||
"""Returns non-empty list when message is present."""
|
||||
msg = ("Test Title", "Test Body", time.monotonic())
|
||||
result, cache = layers.render_message_overlay(msg, 80, 24, (None, None))
|
||||
assert len(result) > 0
|
||||
assert cache[0] is not None
|
||||
|
||||
def test_cache_key_changes_with_text(self):
|
||||
"""Cache key changes when message text changes."""
|
||||
msg1 = ("Title1", "Body1", time.monotonic())
|
||||
msg2 = ("Title2", "Body2", time.monotonic())
|
||||
|
||||
_, cache1 = layers.render_message_overlay(msg1, 80, 24, (None, None))
|
||||
_, cache2 = layers.render_message_overlay(msg2, 80, 24, cache1)
|
||||
|
||||
assert cache1[0] != cache2[0]
|
||||
|
||||
def test_cache_reuse_avoids_recomputation(self):
|
||||
"""Cache is returned when same message is passed (interface test)."""
|
||||
msg = ("Same Title", "Same Body", time.monotonic())
|
||||
|
||||
result1, cache1 = layers.render_message_overlay(msg, 80, 24, (None, None))
|
||||
result2, cache2 = layers.render_message_overlay(msg, 80, 24, cache1)
|
||||
|
||||
assert len(result1) > 0
|
||||
assert len(result2) > 0
|
||||
assert cache1[0] == cache2[0]
|
||||
|
||||
|
||||
class TestRenderFirehose:
|
||||
"""Tests for render_firehose function."""
|
||||
|
||||
def test_no_firehose_returns_empty(self):
|
||||
"""Returns empty list when firehose height is 0."""
|
||||
items = [("Headline", "Source", "12:00")]
|
||||
result = layers.render_firehose(items, 80, 0, 24)
|
||||
assert result == []
|
||||
|
||||
def test_firehose_returns_lines(self):
|
||||
"""Returns lines when firehose height > 0."""
|
||||
items = [("Headline", "Source", "12:00")]
|
||||
result = layers.render_firehose(items, 80, 4, 24)
|
||||
assert len(result) == 4
|
||||
|
||||
def test_firehose_includes_ansi_escapes(self):
|
||||
"""Returns lines containing ANSI escape sequences."""
|
||||
items = [("Headline", "Source", "12:00")]
|
||||
result = layers.render_firehose(items, 80, 1, 24)
|
||||
assert "\033[" in result[0]
|
||||
|
||||
|
||||
class TestApplyGlitch:
|
||||
"""Tests for apply_glitch function."""
|
||||
|
||||
def test_empty_buffer_unchanged(self):
|
||||
"""Empty buffer is returned unchanged."""
|
||||
result = layers.apply_glitch([], 0, 0.0, 80)
|
||||
assert result == []
|
||||
|
||||
def test_buffer_length_preserved(self):
|
||||
"""Buffer length is preserved after glitch application."""
|
||||
buf = [f"\033[{i + 1};1Htest\033[K" for i in range(10)]
|
||||
result = layers.apply_glitch(buf, 0, 0.5, 80)
|
||||
assert len(result) == len(buf)
|
||||
|
||||
|
||||
class TestRenderTickerZone:
|
||||
"""Tests for render_ticker_zone function - focusing on interface."""
|
||||
|
||||
def test_returns_list(self):
|
||||
"""Returns a list of strings."""
|
||||
result, cache = layers.render_ticker_zone(
|
||||
[],
|
||||
scroll_cam=0,
|
||||
camera_x=0,
|
||||
ticker_h=10,
|
||||
w=80,
|
||||
noise_cache={},
|
||||
grad_offset=0.0,
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
|
||||
def test_returns_dict_for_cache(self):
|
||||
"""Returns a dict for the noise cache."""
|
||||
result, cache = layers.render_ticker_zone(
|
||||
[],
|
||||
scroll_cam=0,
|
||||
camera_x=0,
|
||||
ticker_h=10,
|
||||
w=80,
|
||||
noise_cache={},
|
||||
grad_offset=0.0,
|
||||
)
|
||||
assert isinstance(cache, dict)
|
||||
@@ -1,149 +0,0 @@
|
||||
"""
|
||||
Tests for engine.mic module.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from engine.events import MicLevelEvent
|
||||
|
||||
|
||||
class TestMicMonitorImport:
|
||||
"""Tests for module import behavior."""
|
||||
|
||||
def test_mic_monitor_imports_without_error(self):
|
||||
"""MicMonitor can be imported even without sounddevice."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
assert MicMonitor is not None
|
||||
|
||||
|
||||
class TestMicMonitorInit:
|
||||
"""Tests for MicMonitor initialization."""
|
||||
|
||||
def test_init_sets_threshold(self):
|
||||
"""Threshold is set correctly."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor(threshold_db=60)
|
||||
assert monitor.threshold_db == 60
|
||||
|
||||
def test_init_defaults(self):
|
||||
"""Default values are set correctly."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
assert monitor.threshold_db == 50
|
||||
|
||||
def test_init_db_starts_at_negative(self):
|
||||
"""_db starts at negative value."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
assert monitor.db == -99.0
|
||||
|
||||
|
||||
class TestMicMonitorProperties:
|
||||
"""Tests for MicMonitor properties."""
|
||||
|
||||
def test_excess_returns_positive_when_above_threshold(self):
|
||||
"""excess returns positive value when above threshold."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor(threshold_db=50)
|
||||
with patch.object(monitor, "_db", 60.0):
|
||||
assert monitor.excess == 10.0
|
||||
|
||||
def test_excess_returns_zero_when_below_threshold(self):
|
||||
"""excess returns zero when below threshold."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor(threshold_db=50)
|
||||
with patch.object(monitor, "_db", 40.0):
|
||||
assert monitor.excess == 0.0
|
||||
|
||||
|
||||
class TestMicMonitorAvailable:
|
||||
"""Tests for MicMonitor.available property."""
|
||||
|
||||
def test_available_is_bool(self):
|
||||
"""available returns a boolean."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
assert isinstance(monitor.available, bool)
|
||||
|
||||
|
||||
class TestMicMonitorStop:
|
||||
"""Tests for MicMonitor.stop method."""
|
||||
|
||||
def test_stop_does_nothing_when_no_stream(self):
|
||||
"""stop() does nothing if no stream exists."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
monitor.stop()
|
||||
assert monitor._stream is None
|
||||
|
||||
|
||||
class TestMicMonitorEventEmission:
|
||||
"""Tests for MicMonitor event emission."""
|
||||
|
||||
def test_subscribe_adds_callback(self):
|
||||
"""subscribe() adds a callback."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
def callback(e):
|
||||
return None
|
||||
|
||||
monitor.subscribe(callback)
|
||||
|
||||
assert callback in monitor._subscribers
|
||||
|
||||
def test_unsubscribe_removes_callback(self):
|
||||
"""unsubscribe() removes a callback."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
def callback(e):
|
||||
return None
|
||||
monitor.subscribe(callback)
|
||||
|
||||
monitor.unsubscribe(callback)
|
||||
|
||||
assert callback not in monitor._subscribers
|
||||
|
||||
def test_emit_calls_subscribers(self):
|
||||
"""_emit() calls all subscribers."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
received = []
|
||||
|
||||
def callback(event):
|
||||
received.append(event)
|
||||
|
||||
monitor.subscribe(callback)
|
||||
event = MicLevelEvent(
|
||||
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
|
||||
)
|
||||
monitor._emit(event)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].db_level == 60.0
|
||||
|
||||
def test_emit_handles_subscriber_exception(self):
|
||||
"""_emit() handles exceptions in subscribers gracefully."""
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
monitor = MicMonitor()
|
||||
|
||||
def bad_callback(event):
|
||||
raise RuntimeError("test")
|
||||
|
||||
monitor.subscribe(bad_callback)
|
||||
event = MicLevelEvent(
|
||||
db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now()
|
||||
)
|
||||
monitor._emit(event)
|
||||
192
tests/test_performance_regression.py
Normal file
192
tests/test_performance_regression.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Performance regression tests for pipeline stages with realistic data volumes.
|
||||
|
||||
These tests verify that the pipeline maintains performance with large datasets
|
||||
by ensuring ViewportFilterStage prevents FontStage from rendering excessive items.
|
||||
|
||||
Uses pytest-benchmark for statistical benchmarking with automatic regression detection.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.data_sources.sources import SourceItem
|
||||
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
||||
from engine.pipeline.core import PipelineContext
|
||||
|
||||
|
||||
class MockParams:
|
||||
"""Mock parameters object for testing."""
|
||||
|
||||
def __init__(self, viewport_width: int = 80, viewport_height: int = 24):
|
||||
self.viewport_width = viewport_width
|
||||
self.viewport_height = viewport_height
|
||||
|
||||
|
||||
class TestViewportFilterPerformance:
|
||||
"""Test ViewportFilterStage performance with realistic data volumes."""
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_filter_2000_items_to_viewport(self, benchmark):
|
||||
"""Benchmark: Filter 2000 items to viewport size.
|
||||
|
||||
Performance threshold: Must complete in < 1ms per iteration
|
||||
This tests the filtering overhead is negligible.
|
||||
"""
|
||||
# Create 2000 test items (more than real headline sources)
|
||||
test_items = [
|
||||
SourceItem(f"Headline {i}", f"source-{i % 10}", str(i)) for i in range(2000)
|
||||
]
|
||||
|
||||
stage = ViewportFilterStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_height=24)
|
||||
|
||||
result = benchmark(stage.process, test_items, ctx)
|
||||
|
||||
# Verify result is correct
|
||||
assert len(result) <= 5
|
||||
assert len(result) > 0
|
||||
|
||||
@pytest.mark.benchmark
|
||||
def test_font_stage_with_filtered_items(self, benchmark):
|
||||
"""Benchmark: FontStage rendering filtered (5) items.
|
||||
|
||||
Performance threshold: Must complete in < 50ms per iteration
|
||||
This tests that filtering saves significant time by reducing FontStage work.
|
||||
"""
|
||||
# Create filtered items (what ViewportFilterStage outputs)
|
||||
filtered_items = [
|
||||
SourceItem(f"Headline {i}", "source", str(i))
|
||||
for i in range(5) # Filtered count
|
||||
]
|
||||
|
||||
font_stage = FontStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams()
|
||||
|
||||
result = benchmark(font_stage.process, filtered_items, ctx)
|
||||
|
||||
# Should render successfully
|
||||
assert result is not None
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_filter_reduces_work_by_288x(self):
|
||||
"""Verify ViewportFilterStage achieves expected performance improvement.
|
||||
|
||||
With 1438 items and 24-line viewport:
|
||||
- Without filter: FontStage renders all 1438 items
|
||||
- With filter: FontStage renders ~3 items (layout-based)
|
||||
- Expected improvement: 1438 / 3 ≈ 479x
|
||||
"""
|
||||
test_items = [
|
||||
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1438)
|
||||
]
|
||||
|
||||
stage = ViewportFilterStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_height=24)
|
||||
|
||||
filtered = stage.process(test_items, ctx)
|
||||
improvement_factor = len(test_items) / len(filtered)
|
||||
|
||||
# Verify we get expected ~479x improvement (better than old ~288x)
|
||||
assert 400 < improvement_factor < 600
|
||||
# Verify filtered count is reasonable (layout-based is more precise)
|
||||
assert 2 <= len(filtered) <= 5
|
||||
|
||||
|
||||
class TestPipelinePerformanceWithRealData:
|
||||
"""Integration tests for full pipeline performance with large datasets."""
|
||||
|
||||
def test_pipeline_handles_large_item_count(self):
|
||||
"""Test that pipeline doesn't hang with 2000+ items due to filtering."""
|
||||
# Create large dataset
|
||||
large_items = [
|
||||
SourceItem(f"Headline {i}", f"source-{i % 5}", str(i)) for i in range(2000)
|
||||
]
|
||||
|
||||
filter_stage = ViewportFilterStage()
|
||||
font_stage = FontStage()
|
||||
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_height=24)
|
||||
|
||||
# Filter should reduce items quickly
|
||||
filtered = filter_stage.process(large_items, ctx)
|
||||
assert len(filtered) < len(large_items)
|
||||
|
||||
# FontStage should process filtered items quickly
|
||||
rendered = font_stage.process(filtered, ctx)
|
||||
assert rendered is not None
|
||||
|
||||
def test_multiple_viewports_filter_correctly(self):
|
||||
"""Test that filter respects different viewport configurations."""
|
||||
large_items = [
|
||||
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1000)
|
||||
]
|
||||
|
||||
stage = ViewportFilterStage()
|
||||
|
||||
# Test different viewport heights
|
||||
test_cases = [
|
||||
(12, 3), # 12px height -> ~3 items
|
||||
(24, 5), # 24px height -> ~5 items
|
||||
(48, 9), # 48px height -> ~9 items
|
||||
]
|
||||
|
||||
for viewport_height, expected_max_items in test_cases:
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_height=viewport_height)
|
||||
|
||||
filtered = stage.process(large_items, ctx)
|
||||
|
||||
# Verify filtering is proportional to viewport
|
||||
assert len(filtered) <= expected_max_items + 1
|
||||
assert len(filtered) > 0
|
||||
|
||||
|
||||
class TestPerformanceRegressions:
|
||||
"""Tests that catch common performance regressions."""
|
||||
|
||||
def test_filter_doesnt_render_all_items(self):
|
||||
"""Regression test: Ensure filter doesn't accidentally render all items.
|
||||
|
||||
This would indicate that ViewportFilterStage is broken or bypassed.
|
||||
"""
|
||||
large_items = [
|
||||
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1438)
|
||||
]
|
||||
|
||||
stage = ViewportFilterStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams()
|
||||
|
||||
filtered = stage.process(large_items, ctx)
|
||||
|
||||
# Should NOT have all items (regression detection)
|
||||
assert len(filtered) != len(large_items)
|
||||
# Should have drastically fewer items
|
||||
assert len(filtered) < 10
|
||||
|
||||
def test_font_stage_doesnt_hang_with_filter(self):
|
||||
"""Regression test: FontStage shouldn't hang when receiving filtered data.
|
||||
|
||||
Previously, FontStage would render all items, causing 10+ second hangs.
|
||||
Now it should receive only ~5 items and complete quickly.
|
||||
"""
|
||||
# Simulate what happens after ViewportFilterStage
|
||||
filtered_items = [
|
||||
SourceItem(f"Headline {i}", "source", str(i))
|
||||
for i in range(5) # What filter outputs
|
||||
]
|
||||
|
||||
font_stage = FontStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams()
|
||||
|
||||
# Should complete instantly (not hang)
|
||||
result = font_stage.process(filtered_items, ctx)
|
||||
|
||||
# Verify it actually worked
|
||||
assert result is not None
|
||||
assert isinstance(result, list)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user