Compare commits
1 Commits
main
...
integratio
| Author | SHA1 | Date | |
|---|---|---|---|
| ef98add0c5 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,4 +9,7 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.DS_Store
|
coverage.xml
|
||||||
|
*.dot
|
||||||
|
*.png
|
||||||
|
test-reports/
|
||||||
|
|||||||
97
.opencode/skills/mainline-architecture/SKILL.md
Normal file
97
.opencode/skills/mainline-architecture/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
---
|
||||||
|
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) -> set[str]:
|
||||||
|
"""What this stage needs (e.g., {'source'})"""
|
||||||
|
return set()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capability-Based Dependencies
|
||||||
|
|
||||||
|
The Pipeline resolves dependencies using **prefix matching**:
|
||||||
|
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||||
|
- `"camera.state"` matches the camera state capability
|
||||||
|
- This allows flexible composition without hardcoding specific stage names
|
||||||
|
|
||||||
|
### Minimum Capabilities
|
||||||
|
|
||||||
|
The pipeline requires these minimum capabilities to function:
|
||||||
|
- `"source"` - Data source capability
|
||||||
|
- `"render.output"` - Rendered content capability
|
||||||
|
- `"display.output"` - Display output capability
|
||||||
|
- `"camera.state"` - Camera state for viewport filtering
|
||||||
|
|
||||||
|
These are automatically injected if missing (auto-injection).
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- Clock stages update state independently of data flow
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- engine/pipeline/core.py - Stage base class
|
||||||
|
- engine/pipeline/controller.py - Pipeline implementation
|
||||||
|
- engine/pipeline/adapters/ - Stage adapters
|
||||||
|
- docs/PIPELINE.md - Pipeline documentation
|
||||||
163
.opencode/skills/mainline-display/SKILL.md
Normal file
163
.opencode/skills/mainline-display/SKILL.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
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):
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
|
"""Initialize the display"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def show(self, buf: list[str], border: bool = False) -> None:
|
||||||
|
"""Display the buffer"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the display"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Clean up resources"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Return (width, height)"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### DisplayRegistry
|
||||||
|
|
||||||
|
Discovers and manages backends:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.display import DisplayRegistry
|
||||||
|
display = DisplayRegistry.create("terminal") # or "websocket", "null", "multi"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Backends
|
||||||
|
|
||||||
|
| Backend | File | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| terminal | backends/terminal.py | ANSI terminal output |
|
||||||
|
| websocket | backends/websocket.py | Web browser via WebSocket |
|
||||||
|
| null | backends/null.py | Headless for testing |
|
||||||
|
| multi | backends/multi.py | Forwards to multiple displays |
|
||||||
|
| moderngl | backends/moderngl.py | GPU-accelerated OpenGL rendering (optional) |
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
- `init(width: int, height: int, reuse: bool = False)` - Initialize display
|
||||||
|
- `show(buf: list[str], border: bool = False)` - Display buffer
|
||||||
|
- `clear()` - Clear screen
|
||||||
|
- `cleanup()` - Clean up resources
|
||||||
|
- `get_dimensions() -> tuple[int, int]` - Get 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 moderngl # GPU-accelerated (requires moderngl)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Bugs and Patterns
|
||||||
|
|
||||||
|
### BorderMode.OFF Enum Bug
|
||||||
|
|
||||||
|
**Problem**: `BorderMode.OFF` has enum value `1` (not `0`), and Python enums are always truthy.
|
||||||
|
|
||||||
|
**Incorrect Code**:
|
||||||
|
```python
|
||||||
|
if border:
|
||||||
|
buffer = render_border(buffer, width, height, fps, frame_time)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct Code**:
|
||||||
|
```python
|
||||||
|
from engine.display import BorderMode
|
||||||
|
if border and border != BorderMode.OFF:
|
||||||
|
buffer = render_border(buffer, width, height, fps, frame_time)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why**: Checking `if border:` evaluates to `True` even when `border == BorderMode.OFF` because enum members are always truthy in Python.
|
||||||
|
|
||||||
|
### Context Type Mismatch
|
||||||
|
|
||||||
|
**Problem**: `PipelineContext` and `EffectContext` have different APIs for storing data.
|
||||||
|
|
||||||
|
- `PipelineContext`: Uses `set()`/`get()` for services
|
||||||
|
- `EffectContext`: Uses `set_state()`/`get_state()` for state
|
||||||
|
|
||||||
|
**Pattern for Passing Data**:
|
||||||
|
```python
|
||||||
|
# In pipeline setup (uses PipelineContext)
|
||||||
|
ctx.set("pipeline_order", pipeline.execution_order)
|
||||||
|
|
||||||
|
# In EffectPluginStage (must copy to EffectContext)
|
||||||
|
effect_ctx.set_state("pipeline_order", ctx.get("pipeline_order"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal Display ANSI Patterns
|
||||||
|
|
||||||
|
**Screen Clearing**:
|
||||||
|
```python
|
||||||
|
output = "\033[H\033[J" + "".join(buffer)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cursor Positioning** (used by HUD effect):
|
||||||
|
- `\033[row;colH` - Move cursor to row, column
|
||||||
|
- Example: `\033[1;1H` - Move to row 1, column 1
|
||||||
|
|
||||||
|
**Key Insight**: Terminal display joins buffer lines WITHOUT newlines, relying on ANSI cursor positioning codes to move the cursor to the correct location for each line.
|
||||||
|
|
||||||
|
### EffectPluginStage Context Copying
|
||||||
|
|
||||||
|
**Problem**: When effects need access to pipeline services (like `pipeline_order`), they must be copied from `PipelineContext` to `EffectContext`.
|
||||||
|
|
||||||
|
**Pattern**:
|
||||||
|
```python
|
||||||
|
# In EffectPluginStage.process()
|
||||||
|
# Copy pipeline_order from PipelineContext services to EffectContext state
|
||||||
|
pipeline_order = ctx.get("pipeline_order")
|
||||||
|
if pipeline_order:
|
||||||
|
effect_ctx.set_state("pipeline_order", pipeline_order)
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures effects can access `ctx.get_state("pipeline_order")` in their process method.
|
||||||
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
|
||||||
|
- `null` - Headless
|
||||||
|
- `moderngl` - GPU-accelerated (optional)
|
||||||
|
|
||||||
|
## 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
|
||||||
463
AGENTS.md
463
AGENTS.md
@@ -4,88 +4,208 @@
|
|||||||
|
|
||||||
This project uses:
|
This project uses:
|
||||||
- **mise** (mise.jdx.dev) - tool version manager and task runner
|
- **mise** (mise.jdx.dev) - tool version manager and task runner
|
||||||
- **hk** (hk.jdx.dev) - git hook manager
|
|
||||||
- **uv** - fast Python package installer
|
- **uv** - fast Python package installer
|
||||||
- **ruff** - linter and formatter
|
- **ruff** - linter and formatter (line-length 88, target Python 3.10)
|
||||||
- **pytest** - test runner
|
- **pytest** - test runner with strict marker enforcement
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
mise run install # Install dependencies
|
||||||
mise run install
|
# Or: uv sync --all-extras # includes mic, websocket support
|
||||||
|
|
||||||
# Or equivalently:
|
|
||||||
uv sync
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run test # Run tests
|
# Testing
|
||||||
mise run test-v # Run tests verbose
|
mise run test # Run all tests
|
||||||
mise run test-cov # Run tests with coverage report
|
mise run test-cov # Run tests with coverage report
|
||||||
mise run lint # Run ruff linter
|
pytest tests/test_foo.py::TestClass::test_method # Run single test
|
||||||
mise run lint-fix # Run ruff with auto-fix
|
|
||||||
mise run format # Run ruff formatter
|
# Linting & Formatting
|
||||||
mise run ci # Full CI pipeline (sync + test + coverage)
|
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git Hooks
|
### Running a Single Test
|
||||||
|
|
||||||
**At the start of every agent session**, verify hooks are installed:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ls -la .git/hooks/pre-commit
|
# 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"
|
||||||
```
|
```
|
||||||
|
|
||||||
If hooks are not installed, install them with:
|
### Git Hooks
|
||||||
|
|
||||||
|
Install hooks at start of session:
|
||||||
```bash
|
```bash
|
||||||
hk init --mise
|
ls -la .git/hooks/pre-commit # Verify installed
|
||||||
mise run pre-commit
|
hk init --mise # Install if missing
|
||||||
|
mise run pre-commit # Run manually
|
||||||
```
|
```
|
||||||
|
|
||||||
The project uses hk configured in `hk.pkl`:
|
## Code Style Guidelines
|
||||||
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
|
||||||
- **pre-push**: runs ruff check
|
### Imports (three sections, alphabetical within each)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 1. Standard library
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# 2. Third-party
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
# 3. Local project
|
||||||
|
from engine.events import EventType
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Hints
|
||||||
|
|
||||||
|
- Use type hints for all function signatures (parameters and return)
|
||||||
|
- Use `|` for unions (Python 3.10+): `EventType | None`
|
||||||
|
- Use `dict[K, V]`, `list[V]` (generic syntax): `dict[str, list[int]]`
|
||||||
|
- Use `Callable[[ArgType], ReturnType]` for callbacks
|
||||||
|
|
||||||
|
```python
|
||||||
|
def subscribe(self, event_type: EventType, callback: Callable[[Any], None]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_sensor_value(self, sensor_name: str) -> float | None:
|
||||||
|
return self._state.get(f"sensor.{sensor_name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Classes**: `PascalCase` (e.g., `EventBus`, `EffectPlugin`)
|
||||||
|
- **Functions/methods**: `snake_case` (e.g., `get_event_bus`, `process_partial`)
|
||||||
|
- **Constants**: `SCREAMING_SNAKE_CASE` (e.g., `CURSOR_OFF`)
|
||||||
|
- **Private methods**: `_snake_case` prefix (e.g., `_initialize`)
|
||||||
|
- **Type variables**: `PascalCase` (e.g., `T`, `EffectT`)
|
||||||
|
|
||||||
|
### Dataclasses
|
||||||
|
|
||||||
|
Use `@dataclass` for simple data containers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class EffectContext:
|
||||||
|
terminal_width: int
|
||||||
|
terminal_height: int
|
||||||
|
scroll_cam: int
|
||||||
|
ticker_height: int = 0
|
||||||
|
_state: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Abstract Base Classes
|
||||||
|
|
||||||
|
Use ABC for interface enforcement:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EffectPlugin(ABC):
|
||||||
|
name: str
|
||||||
|
config: EffectConfig
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Catch specific exceptions, not bare `Exception`
|
||||||
|
- Use `try/except` with fallbacks for optional features
|
||||||
|
- Silent pass in event callbacks to prevent one handler from breaking others
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good: specific exception
|
||||||
|
try:
|
||||||
|
term_size = os.get_terminal_size()
|
||||||
|
except OSError:
|
||||||
|
term_width = 80
|
||||||
|
|
||||||
|
# Good: silent pass in callbacks
|
||||||
|
for callback in callbacks:
|
||||||
|
try:
|
||||||
|
callback(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Thread Safety
|
||||||
|
|
||||||
|
Use locks for shared state:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EventBus:
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def publish(self, event_type: EventType, event: Any = None) -> None:
|
||||||
|
with self._lock:
|
||||||
|
callbacks = list(self._subscribers.get(event_type, []))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
- **DO NOT ADD comments** unless explicitly required
|
||||||
|
- Let code be self-documenting with good naming
|
||||||
|
- Use docstrings only for public APIs or complex logic
|
||||||
|
|
||||||
|
### Testing Patterns
|
||||||
|
|
||||||
|
Follow pytest conventions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TestEventBusSubscribe:
|
||||||
|
"""Tests for EventBus.subscribe method."""
|
||||||
|
|
||||||
|
def test_subscribe_adds_callback(self):
|
||||||
|
"""subscribe() adds a callback for an event type."""
|
||||||
|
bus = EventBus()
|
||||||
|
def callback(e):
|
||||||
|
return None
|
||||||
|
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||||
|
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use classes to group related tests (`Test<ClassName>`, `Test<method_name>`)
|
||||||
|
- Test docstrings follow `"<method>() <action>"` pattern
|
||||||
|
- Use descriptive assertion messages via pytest behavior
|
||||||
|
|
||||||
## Workflow Rules
|
## Workflow Rules
|
||||||
|
|
||||||
### Before Committing
|
### Before Committing
|
||||||
|
|
||||||
1. **Always run the test suite** - never commit code that fails tests:
|
1. Run tests: `mise run test`
|
||||||
```bash
|
2. Run linter: `mise run lint`
|
||||||
mise run test
|
3. Review changes: `git diff`
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
### On Failing Tests
|
### On Failing Tests
|
||||||
|
|
||||||
When tests fail, **determine whether it's an out-of-date test or a correctly failing test**:
|
- **Out-of-date test**: Update test to match new expected behavior
|
||||||
|
- **Correctly failing test**: Fix implementation, not the 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.
|
|
||||||
|
|
||||||
**Never** modify a test to make it pass without understanding why it failed.
|
**Never** modify a test to make it pass without understanding why it failed.
|
||||||
|
|
||||||
### Code Review
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests live in `tests/` and follow the pattern `test_*.py`.
|
Tests live in `tests/` and follow the pattern `test_*.py`.
|
||||||
@@ -102,9 +222,244 @@ mise run test-cov
|
|||||||
|
|
||||||
The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||||
|
|
||||||
|
### Test Coverage Strategy
|
||||||
|
|
||||||
|
Current coverage: 56% (463 tests)
|
||||||
|
|
||||||
|
Key areas with lower coverage (acceptable for now):
|
||||||
|
- **app.py** (8%): Main entry point - integration heavy, requires terminal
|
||||||
|
- **scroll.py** (10%): Terminal-dependent rendering logic (unused)
|
||||||
|
|
||||||
|
Key areas with good coverage:
|
||||||
|
- **display/backends/null.py** (95%): Easy to test headlessly
|
||||||
|
- **display/backends/terminal.py** (96%): Uses mocking
|
||||||
|
- **display/backends/multi.py** (100%): Simple forwarding logic
|
||||||
|
- **effects/performance.py** (99%): Pure Python logic
|
||||||
|
- **eventbus.py** (96%): Simple event system
|
||||||
|
- **effects/controller.py** (95%): Effects command handling
|
||||||
|
|
||||||
|
Areas needing more tests:
|
||||||
|
- **websocket.py** (48%): Network I/O, hard to test in CI
|
||||||
|
- **ntfy.py** (50%): Network I/O, hard to test in CI
|
||||||
|
- **mic.py** (61%): Audio I/O, hard to test in CI
|
||||||
|
|
||||||
|
Note: Terminal-dependent modules (scroll, layers render) are harder to test in CI.
|
||||||
|
Performance regression tests are in `tests/test_benchmark.py` with `@pytest.mark.benchmark`.
|
||||||
|
|
||||||
## Architecture Notes
|
## 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
|
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||||
- **controller.py** coordinates ntfy/mic monitoring
|
- **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
|
||||||
|
- **PipelineConfig** (`engine/pipeline/controller.py`): Configuration for pipeline instance
|
||||||
|
- **StageRegistry** (`engine/pipeline/registry.py`): Discovers and registers stages
|
||||||
|
- **Stage Adapters** (`engine/pipeline/adapters.py`): Wraps existing components as stages
|
||||||
|
|
||||||
|
#### Pipeline Configuration
|
||||||
|
|
||||||
|
The `PipelineConfig` dataclass configures pipeline behavior:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class PipelineConfig:
|
||||||
|
source: str = "headlines" # Data source identifier
|
||||||
|
display: str = "terminal" # Display backend identifier
|
||||||
|
camera: str = "vertical" # Camera mode identifier
|
||||||
|
effects: list[str] = field(default_factory=list) # List of effect names
|
||||||
|
enable_metrics: bool = True # Enable performance metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available sources**: `headlines`, `poetry`, `empty`, `list`, `image`, `metrics`, `cached`, `transform`, `composite`, `pipeline-inspect`
|
||||||
|
**Available displays**: `terminal`, `null`, `replay`, `websocket`, `pygame`, `moderngl`, `multi`
|
||||||
|
**Available camera modes**: `FEED`, `SCROLL`, `HORIZONTAL`, `OMNI`, `FLOATING`, `BOUNCE`, `RADIAL`
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
- `"camera.state"` matches the camera state capability
|
||||||
|
- This allows flexible composition without hardcoding specific stage names
|
||||||
|
|
||||||
|
#### Minimum Capabilities
|
||||||
|
|
||||||
|
The pipeline requires these minimum capabilities to function:
|
||||||
|
- `"source"` - Data source capability
|
||||||
|
- `"render.output"` - Rendered content capability
|
||||||
|
- `"display.output"` - Display output capability
|
||||||
|
- `"camera.state"` - Camera state for viewport filtering
|
||||||
|
|
||||||
|
These are automatically injected if missing by the `ensure_minimum_capabilities()` method.
|
||||||
|
|
||||||
|
#### Sensor Framework
|
||||||
|
|
||||||
|
- **Sensor** (`engine/sensors/__init__.py`): Base class for real-time input sensors
|
||||||
|
- **SensorRegistry**: Discovers available sensors
|
||||||
|
- **SensorStage**: Pipeline adapter that provides sensor values to effects
|
||||||
|
- **MicSensor** (`engine/sensors/mic.py`): Self-contained microphone input
|
||||||
|
- **OscillatorSensor** (`engine/sensors/oscillator.py`): Test sensor for development
|
||||||
|
- **PipelineMetricsSensor** (`engine/sensors/pipeline_metrics.py`): Exposes pipeline metrics as sensor values
|
||||||
|
|
||||||
|
Sensors support param bindings to drive effect parameters in real-time.
|
||||||
|
|
||||||
|
#### Pipeline Introspection
|
||||||
|
|
||||||
|
- **PipelineIntrospectionSource** (`engine/data_sources/pipeline_introspection.py`): Renders live ASCII visualization of pipeline DAG with metrics
|
||||||
|
- **PipelineIntrospectionDemo** (`engine/pipeline/pipeline_introspection_demo.py`): 3-phase demo controller for effect animation
|
||||||
|
|
||||||
|
Preset: `pipeline-inspect` - Live pipeline introspection with DAG and performance metrics
|
||||||
|
|
||||||
|
#### Partial Update Support
|
||||||
|
|
||||||
|
Effect plugins can opt-in to partial buffer updates for performance optimization:
|
||||||
|
- Set `supports_partial_updates = True` on the effect class
|
||||||
|
- Implement `process_partial(buf, ctx, partial)` method
|
||||||
|
- The `PartialUpdate` dataclass indicates which regions changed
|
||||||
|
|
||||||
|
### Preset System
|
||||||
|
|
||||||
|
Presets use TOML format (no external dependencies):
|
||||||
|
|
||||||
|
- Built-in: `engine/presets.toml`
|
||||||
|
- User config: `~/.config/mainline/presets.toml`
|
||||||
|
- Local override: `./presets.toml`
|
||||||
|
|
||||||
|
- **Preset loader** (`engine/pipeline/preset_loader.py`): Loads and validates presets
|
||||||
|
- **PipelinePreset** (`engine/pipeline/presets.py`): Dataclass for preset configuration
|
||||||
|
|
||||||
|
Functions:
|
||||||
|
- `validate_preset()` - Validate preset structure
|
||||||
|
- `validate_signal_path()` - Detect circular dependencies
|
||||||
|
- `generate_preset_toml()` - Generate skeleton preset
|
||||||
|
|
||||||
|
### Display System
|
||||||
|
|
||||||
|
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
||||||
|
- `display/backends/terminal.py` - ANSI terminal output
|
||||||
|
- `display/backends/websocket.py` - broadcasts to web clients via WebSocket
|
||||||
|
- `display/backends/null.py` - headless display for testing
|
||||||
|
- `display/backends/multi.py` - forwards to multiple displays simultaneously
|
||||||
|
- `display/backends/moderngl.py` - GPU-accelerated OpenGL rendering (optional)
|
||||||
|
- `display/__init__.py` - DisplayRegistry for backend discovery
|
||||||
|
|
||||||
|
- **WebSocket display** (`engine/display/backends/websocket.py`): real-time frame broadcasting to web browsers
|
||||||
|
- WebSocket server on port 8765
|
||||||
|
- HTTP server on port 8766 (serves HTML client)
|
||||||
|
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
||||||
|
|
||||||
|
- **Display modes** (`--display` flag):
|
||||||
|
- `terminal` - Default ANSI terminal output
|
||||||
|
- `websocket` - Web browser display (requires websockets package)
|
||||||
|
- `moderngl` - GPU-accelerated rendering (requires moderngl package)
|
||||||
|
|
||||||
|
### Effect Plugin System
|
||||||
|
|
||||||
|
- **EffectPlugin ABC** (`engine/effects/types.py`): abstract base class for effects
|
||||||
|
- All effects must inherit from EffectPlugin and implement `process()` and `configure()`
|
||||||
|
- Runtime discovery via `effects_plugins/__init__.py` using `issubclass()` checks
|
||||||
|
|
||||||
|
- **EffectRegistry** (`engine/effects/registry.py`): manages registered effects
|
||||||
|
- **EffectChain** (`engine/effects/chain.py`): chains effects in pipeline order
|
||||||
|
|
||||||
|
### Command & Control
|
||||||
|
|
||||||
|
- C&C uses separate ntfy topics for commands and responses
|
||||||
|
- `NTFY_CC_CMD_TOPIC` - commands from cmdline.py
|
||||||
|
- `NTFY_CC_RESP_TOPIC` - responses back to cmdline.py
|
||||||
|
- Effects controller handles `/effects` commands (list, on/off, intensity, reorder, stats)
|
||||||
|
|
||||||
|
### Pipeline Documentation
|
||||||
|
|
||||||
|
The rendering pipeline is documented in `docs/PIPELINE.md` using Mermaid diagrams.
|
||||||
|
|
||||||
|
**IMPORTANT**: When making significant architectural changes to the rendering pipeline (new layers, effects, display backends), update `docs/PIPELINE.md` to reflect the changes:
|
||||||
|
1. Edit `docs/PIPELINE.md` with the new architecture
|
||||||
|
2. If adding new SVG diagrams, render them manually using an external tool (e.g., Mermaid Live Editor)
|
||||||
|
3. Commit both the markdown and any new diagram files
|
||||||
|
|
||||||
|
### Pipeline Mutation API
|
||||||
|
|
||||||
|
The Pipeline class supports dynamic mutation during runtime via the mutation API:
|
||||||
|
|
||||||
|
**Core Methods:**
|
||||||
|
- `add_stage(name, stage, initialize=True)` - Add a stage to the pipeline
|
||||||
|
- `remove_stage(name, cleanup=True)` - Remove a stage and rebuild execution order
|
||||||
|
- `replace_stage(name, new_stage, preserve_state=True)` - Replace a stage with another
|
||||||
|
- `swap_stages(name1, name2)` - Swap two stages
|
||||||
|
- `move_stage(name, after=None, before=None)` - Move a stage in execution order
|
||||||
|
- `enable_stage(name)` - Enable a stage
|
||||||
|
- `disable_stage(name)` - Disable a stage
|
||||||
|
|
||||||
|
**New Methods (Issue #35):**
|
||||||
|
- `cleanup_stage(name)` - Clean up specific stage without removing it
|
||||||
|
- `remove_stage_safe(name, cleanup=True)` - Alias for remove_stage that explicitly rebuilds
|
||||||
|
- `can_hot_swap(name)` - Check if a stage can be safely hot-swapped
|
||||||
|
- Returns False for stages that provide minimum capabilities as sole provider
|
||||||
|
- Returns True for swappable stages
|
||||||
|
|
||||||
|
**WebSocket Commands:**
|
||||||
|
Commands can be sent via WebSocket to mutate the pipeline at runtime:
|
||||||
|
```json
|
||||||
|
{"action": "remove_stage", "stage": "stage_name"}
|
||||||
|
{"action": "swap_stages", "stage1": "name1", "stage2": "name2"}
|
||||||
|
{"action": "enable_stage", "stage": "stage_name"}
|
||||||
|
{"action": "disable_stage", "stage": "stage_name"}
|
||||||
|
{"action": "cleanup_stage", "stage": "stage_name"}
|
||||||
|
{"action": "can_hot_swap", "stage": "stage_name"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation Files:**
|
||||||
|
- `engine/pipeline/controller.py` - Pipeline class with mutation methods
|
||||||
|
- `engine/app/pipeline_runner.py` - `_handle_pipeline_mutation()` function
|
||||||
|
- `engine/pipeline/ui.py` - execute_command() with docstrings
|
||||||
|
- `tests/test_pipeline_mutation_commands.py` - Integration tests
|
||||||
|
|
||||||
|
## 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 `local_skills_list_skills` to see available skills
|
||||||
|
2. Use `local_skills_peek_skill({name: "skill-name"})` to preview relevant skills
|
||||||
|
3. Use `local_skills_skill_slice({name: "skill-name", query: "your question"})` to get relevant sections
|
||||||
|
|
||||||
|
**While working:**
|
||||||
|
- If a skill was wrong or incomplete: `local_skills_update_skill` → `local_skills_record_assessment` → `local_skills_report_outcome({quality: 1})`
|
||||||
|
- If a skill worked correctly: `local_skills_report_outcome({quality: 4})` (normal) or `quality: 5` (perfect)
|
||||||
|
|
||||||
|
**End of session:**
|
||||||
|
- Run `local_skills_reflect_on_session({context_summary: "what you did"})` to identify new skills to capture
|
||||||
|
- Use `local_skills_create_skill` to add new skills
|
||||||
|
- Use `local_skills_record_assessment` to score them
|
||||||
|
|
||||||
|
### Useful Tools
|
||||||
|
- `local_skills_review_stale_skills()` - Skills due for review (negative days_until_due)
|
||||||
|
- `local_skills_skills_report()` - Overview of entire collection
|
||||||
|
- `local_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
|
||||||
|
|||||||
160
README.md
160
README.md
@@ -2,34 +2,7 @@
|
|||||||
|
|
||||||
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
|
||||||
|
|
||||||
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). **Figment mode** overlays flickery, theme-colored SVG glyphs on the running stream at timed intervals — controllable from any input source via an extensible trigger protocol.
|
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with a white-hot → deep green gradient. Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contents
|
|
||||||
|
|
||||||
- [Using](#using)
|
|
||||||
- [Run](#run)
|
|
||||||
- [Config](#config)
|
|
||||||
- [Display Modes](#display-modes)
|
|
||||||
- [Feeds](#feeds)
|
|
||||||
- [Fonts](#fonts)
|
|
||||||
- [ntfy.sh](#ntfysh)
|
|
||||||
- [Figment Mode](#figment-mode)
|
|
||||||
- [Command & Control](#command--control-cc)
|
|
||||||
- [Internals](#internals)
|
|
||||||
- [How it works](#how-it-works)
|
|
||||||
- [Architecture](#architecture)
|
|
||||||
- [Development](#development)
|
|
||||||
- [Setup](#setup)
|
|
||||||
- [Tasks](#tasks)
|
|
||||||
- [Testing](#testing)
|
|
||||||
- [Linting](#linting)
|
|
||||||
- [Roadmap](#roadmap)
|
|
||||||
- [Performance](#performance)
|
|
||||||
- [Graphics](#graphics)
|
|
||||||
- [Cyberpunk Vibes](#cyberpunk-vibes)
|
|
||||||
- [Extensibility](#extensibility)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,11 +15,7 @@ python3 mainline.py # news stream
|
|||||||
python3 mainline.py --poetry # literary consciousness mode
|
python3 mainline.py --poetry # literary consciousness mode
|
||||||
python3 mainline.py -p # same
|
python3 mainline.py -p # same
|
||||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||||
python3 mainline.py --figment # enable periodic SVG glyph overlays
|
|
||||||
python3 mainline.py --figment-interval 30 # figment every 30 seconds (default: 60)
|
|
||||||
python3 mainline.py --display websocket # web browser display only
|
python3 mainline.py --display websocket # web browser display only
|
||||||
python3 mainline.py --display both # terminal + web browser
|
|
||||||
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
|
||||||
python3 mainline.py --no-font-picker # skip interactive font picker
|
python3 mainline.py --no-font-picker # skip interactive font picker
|
||||||
python3 mainline.py --font-file path.otf # use a specific font file
|
python3 mainline.py --font-file path.otf # use a specific font file
|
||||||
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
||||||
@@ -98,7 +67,6 @@ All constants live in `engine/config.py`:
|
|||||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||||
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
||||||
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
|
|
||||||
|
|
||||||
### Display Modes
|
### Display Modes
|
||||||
|
|
||||||
@@ -106,8 +74,7 @@ Mainline supports multiple display backends:
|
|||||||
|
|
||||||
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
||||||
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
||||||
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
|
- **ModernGL** (`--display moderngl`): GPU-accelerated rendering (optional)
|
||||||
- **Both** (`--display both`): Terminal + WebSocket simultaneously
|
|
||||||
|
|
||||||
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
||||||
|
|
||||||
@@ -135,56 +102,20 @@ To push a message:
|
|||||||
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
||||||
```
|
```
|
||||||
|
|
||||||
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
|
|
||||||
|
|
||||||
### Figment Mode
|
|
||||||
|
|
||||||
Figment mode periodically overlays a full-screen SVG glyph on the running ticker — flickering through a reveal → hold (strobe) → dissolve cycle, colored with a randomly selected theme gradient.
|
|
||||||
|
|
||||||
**Enable it** with the `--figment` flag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run mainline.py --figment # glyph every 60 seconds (default)
|
|
||||||
uv run mainline.py --figment --figment-interval 30 # every 30 seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
**Figment assets** live in `figments/` — drop any `.svg` file there and it will be picked up automatically. The bundled set contains Mayan and Aztec glyphs. Figments are selected randomly, avoiding immediate repeats, and rasterized into half-block terminal art at display time.
|
|
||||||
|
|
||||||
**Triggering manually** — any object with a `poll() -> FigmentCommand | None` method satisfies the `FigmentTrigger` protocol and can be passed to the plugin:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.figment_trigger import FigmentAction, FigmentCommand
|
|
||||||
|
|
||||||
class MyTrigger:
|
|
||||||
def poll(self):
|
|
||||||
if some_condition:
|
|
||||||
return FigmentCommand(action=FigmentAction.TRIGGER)
|
|
||||||
return None
|
|
||||||
```
|
|
||||||
|
|
||||||
Built-in commands: `TRIGGER`, `SET_INTENSITY`, `SET_INTERVAL`, `SET_COLOR`, `STOP`.
|
|
||||||
|
|
||||||
**System dependency:** Figment mode requires the Cairo C library (`brew install cairo` on macOS) in addition to the `figment` extras group:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv sync --extra figment # adds cairosvg
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Internals
|
## Internals
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection
|
||||||
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
- Feeds are fetched and filtered on startup; results are cached for fast restarts
|
||||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters
|
||||||
- The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
|
- The ticker uses a sweeping white-hot → deep green gradient
|
||||||
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
- Subject-region detection triggers Google Translate and font swap for non-Latin scripts
|
||||||
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
- The mic stream runs in a background thread, feeding RMS dB into glitch probability
|
||||||
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
- The viewport scrolls through pre-rendered blocks with fade zones
|
||||||
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands
|
||||||
- Figment mode rasterizes SVGs via cairosvg → PIL → greyscale → half-block encode, then overlays them with ANSI cursor-positioning commands between the effect chain and the ntfy message layer
|
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
@@ -205,40 +136,32 @@ engine/
|
|||||||
controller.py handles /effects commands
|
controller.py handles /effects commands
|
||||||
performance.py performance monitoring
|
performance.py performance monitoring
|
||||||
legacy.py legacy functional effects
|
legacy.py legacy functional effects
|
||||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
effects_plugins/ effect plugin implementations
|
||||||
|
noise.py noise effect
|
||||||
|
fade.py fade effect
|
||||||
|
glitch.py glitch effect
|
||||||
|
firehose.py firehose effect
|
||||||
|
fetch.py RSS/Gutenberg fetching + cache
|
||||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||||
mic.py MicMonitor — standalone, graceful fallback
|
mic.py MicMonitor — standalone, graceful fallback
|
||||||
scroll.py stream() frame loop + message rendering
|
scroll.py stream() frame loop + message rendering
|
||||||
viewport.py terminal dimension tracking (tw/th)
|
viewport.py terminal dimension tracking
|
||||||
frame.py scroll step calculation, timing
|
frame.py scroll step calculation, timing
|
||||||
layers.py ticker zone, firehose, message + figment overlay rendering
|
layers.py ticker zone, firehose, message overlay
|
||||||
figment_render.py SVG → cairosvg → PIL → half-block rasterizer with cache
|
eventbus.py thread-safe event publishing
|
||||||
figment_trigger.py FigmentTrigger protocol, FigmentAction enum, FigmentCommand
|
|
||||||
eventbus.py thread-safe event publishing for decoupled communication
|
|
||||||
events.py event types and definitions
|
events.py event types and definitions
|
||||||
controller.py coordinates ntfy/mic monitoring and event publishing
|
controller.py coordinates ntfy/mic monitoring
|
||||||
emitters.py background emitters for ntfy and mic
|
emitters.py background emitters
|
||||||
types.py type definitions and dataclasses
|
types.py type definitions
|
||||||
themes.py THEME_REGISTRY — gradient color definitions
|
|
||||||
display/ Display backend system
|
display/ Display backend system
|
||||||
__init__.py DisplayRegistry, get_monitor
|
__init__.py DisplayRegistry, get_monitor
|
||||||
backends/
|
backends/
|
||||||
terminal.py ANSI terminal display
|
terminal.py ANSI terminal display
|
||||||
websocket.py WebSocket server for browser clients
|
websocket.py WebSocket server for browser clients
|
||||||
sixel.py Sixel graphics (pure Python)
|
null.py headless display for testing
|
||||||
null.py headless display for testing
|
multi.py forwards to multiple displays
|
||||||
multi.py forwards to multiple displays
|
moderngl.py GPU-accelerated OpenGL rendering
|
||||||
benchmark.py performance benchmarking tool
|
benchmark.py performance benchmarking tool
|
||||||
|
|
||||||
effects_plugins/
|
|
||||||
__init__.py plugin discovery (ABC issubclass scan)
|
|
||||||
noise.py NoiseEffect — random character noise
|
|
||||||
glitch.py GlitchEffect — horizontal glitch bars
|
|
||||||
fade.py FadeEffect — edge fade zones
|
|
||||||
firehose.py FirehoseEffect — dense bottom ticker strip
|
|
||||||
figment.py FigmentEffect — periodic SVG glyph overlay (state machine)
|
|
||||||
|
|
||||||
figments/ SVG assets for figment mode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -250,15 +173,11 @@ figments/ SVG assets for figment mode
|
|||||||
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync # minimal (no mic, no figment)
|
uv sync # minimal (no mic)
|
||||||
uv sync --extra mic # with mic support (sounddevice + numpy)
|
uv sync --all-extras # with mic support
|
||||||
uv sync --extra figment # with figment mode (cairosvg + system Cairo)
|
|
||||||
uv sync --all-extras # all optional features
|
|
||||||
uv sync --all-extras --group dev # full dev environment
|
uv sync --all-extras --group dev # full dev environment
|
||||||
```
|
```
|
||||||
|
|
||||||
Figment mode also requires the Cairo C library: `brew install cairo` (macOS).
|
|
||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
With [mise](https://mise.jdx.dev/):
|
With [mise](https://mise.jdx.dev/):
|
||||||
@@ -273,9 +192,7 @@ mise run format # ruff format
|
|||||||
|
|
||||||
mise run run # terminal display
|
mise run run # terminal display
|
||||||
mise run run-websocket # web display only
|
mise run run-websocket # web display only
|
||||||
mise run run-sixel # sixel graphics
|
mise run run-client # terminal + web
|
||||||
mise run run-both # terminal + web
|
|
||||||
mise run run-client # both + open browser
|
|
||||||
|
|
||||||
mise run cmd # C&C command interface
|
mise run cmd # C&C command interface
|
||||||
mise run cmd-stats # watch effects stats
|
mise run cmd-stats # watch effects stats
|
||||||
@@ -288,8 +205,6 @@ mise run topics-init # initialize ntfy topics
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, `terminal`, and the full figment pipeline (`figment_render`, `figment_trigger`, `figment`, `figment_overlay`). Figment tests are automatically skipped if Cairo is not installed.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest
|
uv run pytest
|
||||||
uv run pytest --cov=engine --cov-report=term-missing
|
uv run pytest --cov=engine --cov-report=term-missing
|
||||||
@@ -333,19 +248,12 @@ Pre-commit hooks run lint automatically via `hk`.
|
|||||||
- Parallax secondary column
|
- Parallax secondary column
|
||||||
|
|
||||||
### Cyberpunk Vibes
|
### Cyberpunk Vibes
|
||||||
- **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency
|
- Keyword watch list with strobe effects
|
||||||
- **ntfy figment trigger** — built-in `NtfyFigmentTrigger` that listens on a dedicated topic to fire figments on demand
|
- Breaking interrupt with synthesized audio
|
||||||
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
- Live data overlay (BTC, ISS position)
|
||||||
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
- Theme switcher (amber, ice, red)
|
||||||
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
- Persona modes (surveillance, oracle, underground)
|
||||||
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
|
|
||||||
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
|
|
||||||
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
|
|
||||||
|
|
||||||
### Extensibility
|
|
||||||
- **serve.py** — HTTP server that imports `engine.render` and `engine.fetch` directly to stream 1-bit bitmaps to an ESP32 display
|
|
||||||
- **Rust port** — `ntfy.py` and `render.py` are the natural first targets; clear module boundaries make incremental porting viable
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
||||||
27
TODO.md
Normal file
27
TODO.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
- [x] Remove references to removed display backends (sixel, kitty) from all documentation
|
||||||
|
- [x] Remove references to deprecated "both" display mode
|
||||||
|
- [x] Update AGENTS.md to reflect current architecture and remove merge conflicts
|
||||||
|
- [x] Update Agent Skills (.opencode/skills/) to match current codebase
|
||||||
|
- [x] Update docs/ARCHITECTURE.md to remove SixelDisplay references
|
||||||
|
- [x] Verify ModernGL backend is properly documented and registered
|
||||||
|
- [ ] Update docs/PIPELINE.md to reflect Stage-based architecture (outdated legacy flowchart) [#41](https://git.notsosm.art/david/Mainline/issues/41)
|
||||||
|
|
||||||
|
## Code & Features
|
||||||
|
- [ ] Check if luminance implementation exists for shade/tint effects (see [#26](https://git.notsosm.art/david/Mainline/issues/26) related: need to verify render/blocks.py has luminance calculation)
|
||||||
|
- [x] Add entropy/chaos score metadata to effects for auto-categorization and intensity control [#32](https://git.notsosm.art/david/Mainline/issues/32) (closed - completed)
|
||||||
|
- [ ] Finish ModernGL display backend: integrate window system, implement glyph caching, add event handling, and support border modes [#42](https://git.notsosm.art/david/Mainline/issues/42)
|
||||||
|
- [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 [#43](https://git.notsosm.art/david/Mainline/issues/43)
|
||||||
|
|
||||||
|
## Gitea Issues Tracking
|
||||||
|
- [#37](https://git.notsosm.art/david/Mainline/issues/37): Refactor app.py and adapter.py for better maintainability
|
||||||
|
- [#35](https://git.notsosm.art/david/Mainline/issues/35): Epic: Pipeline Mutation API for Stage Hot-Swapping
|
||||||
|
- [#34](https://git.notsosm.art/david/Mainline/issues/34): Improve benchmarking system and performance tests
|
||||||
|
- [#33](https://git.notsosm.art/david/Mainline/issues/33): Add web-based pipeline editor UI
|
||||||
|
- [#26](https://git.notsosm.art/david/Mainline/issues/26): Add Streaming display backend
|
||||||
313
client/editor.html
Normal file
313
client/editor.html
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mainline Pipeline Editor</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #eee;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
#sidebar {
|
||||||
|
width: 300px;
|
||||||
|
background: #222;
|
||||||
|
padding: 15px;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stage-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.stage-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #333;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.stage-item:hover { background: #444; }
|
||||||
|
.stage-item.selected { background: #0066cc; }
|
||||||
|
.stage-item input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
.stage-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.param-group {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.param-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.param-name {
|
||||||
|
width: 100px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.param-slider {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
.param-value {
|
||||||
|
width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
color: #4f4;
|
||||||
|
}
|
||||||
|
.preset-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.preset-btn {
|
||||||
|
background: #333;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ccc;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.preset-btn:hover { background: #444; }
|
||||||
|
.preset-btn.active { background: #0066cc; border-color: #0077ff; color: #fff; }
|
||||||
|
button.action-btn {
|
||||||
|
background: #0066cc;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
button.action-btn:hover { background: #0077ee; }
|
||||||
|
#status {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
#status.connected { color: #4f4; }
|
||||||
|
#status.disconnected { color: #f44; }
|
||||||
|
#pipeline-view {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.pipeline-node {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 2px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.pipeline-node.enabled { border-left: 3px solid #4f4; }
|
||||||
|
.pipeline-node.disabled { border-left: 3px solid #666; opacity: 0.6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="sidebar">
|
||||||
|
<div class="section">
|
||||||
|
<h2>Preset</h2>
|
||||||
|
<div id="preset-list" class="preset-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Stages</h2>
|
||||||
|
<ul id="stage-list" class="stage-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Parameters</h2>
|
||||||
|
<div id="param-editor" class="param-group"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="main">
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
<div id="pipeline-view"></div>
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<button class="action-btn" onclick="sendCommand({action: 'cycle_preset', direction: 1})">Next Preset</button>
|
||||||
|
<button class="action-btn" onclick="sendCommand({action: 'cycle_preset', direction: -1})">Prev Preset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="status">Disconnected</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ws = new WebSocket(`ws://${location.hostname}:8765`);
|
||||||
|
let state = { stages: {}, preset: '', presets: [], selected_stage: null };
|
||||||
|
|
||||||
|
function updateStatus(connected) {
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
status.textContent = connected ? 'Connected' : 'Disconnected';
|
||||||
|
status.className = connected ? 'connected' : 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws.onopen = () => {
|
||||||
|
updateStatus(true);
|
||||||
|
// Request initial state
|
||||||
|
ws.send(JSON.stringify({ type: 'state_request' }));
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
updateStatus(false);
|
||||||
|
setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
updateStatus(false);
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'state') {
|
||||||
|
state = data.state;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCommand(command) {
|
||||||
|
ws.send(JSON.stringify({ type: 'command', command }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderPresets();
|
||||||
|
renderStageList();
|
||||||
|
renderPipeline();
|
||||||
|
renderParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPresets() {
|
||||||
|
const container = document.getElementById('preset-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
(state.presets || []).forEach(preset => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'preset-btn' + (preset === state.preset ? ' active' : '');
|
||||||
|
btn.textContent = preset;
|
||||||
|
btn.onclick = () => sendCommand({ action: 'change_preset', preset });
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStageList() {
|
||||||
|
const list = document.getElementById('stage-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
Object.entries(state.stages || {}).forEach(([name, info]) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'stage-item' + (name === state.selected_stage ? ' selected' : '');
|
||||||
|
li.innerHTML = `
|
||||||
|
<input type="checkbox" ${info.enabled ? 'checked' : ''}
|
||||||
|
onchange="sendCommand({action: 'toggle_stage', stage: '${name}'})">
|
||||||
|
<span class="stage-name">${name}</span>
|
||||||
|
`;
|
||||||
|
li.onclick = (e) => {
|
||||||
|
if (e.target.type !== 'checkbox') {
|
||||||
|
sendCommand({ action: 'select_stage', stage: name });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPipeline() {
|
||||||
|
const view = document.getElementById('pipeline-view');
|
||||||
|
view.innerHTML = '';
|
||||||
|
const stages = Object.entries(state.stages || {});
|
||||||
|
if (stages.length === 0) {
|
||||||
|
view.textContent = '(No stages)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stages.forEach(([name, info]) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'pipeline-node' + (info.enabled ? ' enabled' : ' disabled');
|
||||||
|
span.textContent = name;
|
||||||
|
view.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderParams() {
|
||||||
|
const container = document.getElementById('param-editor');
|
||||||
|
container.innerHTML = '';
|
||||||
|
const selected = state.selected_stage;
|
||||||
|
if (!selected || !state.stages[selected]) {
|
||||||
|
container.innerHTML = '<div style="color:#666;font-size:11px;">(select a stage)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stage = state.stages[selected];
|
||||||
|
if (!stage.params || Object.keys(stage.params).length === 0) {
|
||||||
|
container.innerHTML = '<div style="color:#666;font-size:11px;">(no params)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.entries(stage.params).forEach(([key, value]) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'param-row';
|
||||||
|
// Infer min/max/step from typical ranges
|
||||||
|
let min = 0, max = 1, step = 0.1;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (value > 1) { max = value * 2; step = 1; }
|
||||||
|
else { max = 1; step = 0.1; }
|
||||||
|
}
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="param-name">${key}</div>
|
||||||
|
<input type="range" class="param-slider" min="${min}" max="${max}" step="${step}"
|
||||||
|
value="${value}"
|
||||||
|
oninput="adjustParam('${key}', this.value)">
|
||||||
|
<div class="param-value">${typeof value === 'number' ? Number(value).toFixed(2) : value}</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustParam(param, newValue) {
|
||||||
|
const selected = state.selected_stage;
|
||||||
|
if (!selected) return;
|
||||||
|
// Update display immediately for responsiveness
|
||||||
|
const num = parseFloat(newValue);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
// Show updated value
|
||||||
|
document.querySelectorAll('.param-value').forEach(el => {
|
||||||
|
if (el.parentElement.querySelector('.param-name').textContent === param) {
|
||||||
|
el.textContent = num.toFixed(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Send command
|
||||||
|
sendCommand({
|
||||||
|
action: 'adjust_param',
|
||||||
|
stage: selected,
|
||||||
|
param: param,
|
||||||
|
delta: num - (state.stages[selected].params[param] || 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
369
client/index.html
Normal file
369
client/index.html
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mainline Terminal</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #0a0a0a;
|
||||||
|
color: #ccc;
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
body.fullscreen {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body.fullscreen #controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
background: #000;
|
||||||
|
border: 1px solid #333;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
}
|
||||||
|
body.fullscreen canvas {
|
||||||
|
border: none;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
}
|
||||||
|
#controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
#controls button {
|
||||||
|
background: #333;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #555;
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#controls button:hover {
|
||||||
|
background: #444;
|
||||||
|
}
|
||||||
|
#controls input {
|
||||||
|
width: 60px;
|
||||||
|
background: #222;
|
||||||
|
color: #ccc;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
#status.connected {
|
||||||
|
color: #4f4;
|
||||||
|
}
|
||||||
|
#status.disconnected {
|
||||||
|
color: #f44;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="container">
|
||||||
|
<canvas id="terminal"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="controls">
|
||||||
|
<label>Cols: <input type="number" id="cols" value="80" min="20" max="200"></label>
|
||||||
|
<label>Rows: <input type="number" id="rows" value="24" min="10" max="60"></label>
|
||||||
|
<button id="apply">Apply</button>
|
||||||
|
<button id="fullscreen">Fullscreen</button>
|
||||||
|
</div>
|
||||||
|
<div id="status" class="disconnected">Connecting...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('terminal');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
const colsInput = document.getElementById('cols');
|
||||||
|
const rowsInput = document.getElementById('rows');
|
||||||
|
const applyBtn = document.getElementById('apply');
|
||||||
|
const fullscreenBtn = document.getElementById('fullscreen');
|
||||||
|
|
||||||
|
const CHAR_WIDTH = 9;
|
||||||
|
const CHAR_HEIGHT = 16;
|
||||||
|
|
||||||
|
const ANSI_COLORS = {
|
||||||
|
0: '#000000', 1: '#cd3131', 2: '#0dbc79', 3: '#e5e510',
|
||||||
|
4: '#2472c8', 5: '#bc3fbc', 6: '#11a8cd', 7: '#e5e5e5',
|
||||||
|
8: '#666666', 9: '#f14c4c', 10: '#23d18b', 11: '#f5f543',
|
||||||
|
12: '#3b8eea', 13: '#d670d6', 14: '#29b8db', 15: '#ffffff',
|
||||||
|
};
|
||||||
|
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
function resizeCanvas() {
|
||||||
|
canvas.width = cols * CHAR_WIDTH;
|
||||||
|
canvas.height = rows * CHAR_HEIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAnsi(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const tokens = [];
|
||||||
|
let currentText = '';
|
||||||
|
let fg = '#cccccc';
|
||||||
|
let bg = '#000000';
|
||||||
|
let bold = false;
|
||||||
|
let i = 0;
|
||||||
|
let inEscape = false;
|
||||||
|
let escapeCode = '';
|
||||||
|
|
||||||
|
while (i < text.length) {
|
||||||
|
const char = text[i];
|
||||||
|
|
||||||
|
if (inEscape) {
|
||||||
|
if (char >= '0' && char <= '9' || char === ';' || char === '[') {
|
||||||
|
escapeCode += char;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === 'm') {
|
||||||
|
const codes = escapeCode.replace('\x1b[', '').split(';');
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
const num = parseInt(code) || 0;
|
||||||
|
|
||||||
|
if (num === 0) {
|
||||||
|
fg = '#cccccc';
|
||||||
|
bg = '#000000';
|
||||||
|
bold = false;
|
||||||
|
} else if (num === 1) {
|
||||||
|
bold = true;
|
||||||
|
} else if (num === 22) {
|
||||||
|
bold = false;
|
||||||
|
} else if (num === 39) {
|
||||||
|
fg = '#cccccc';
|
||||||
|
} else if (num === 49) {
|
||||||
|
bg = '#000000';
|
||||||
|
} else if (num >= 30 && num <= 37) {
|
||||||
|
fg = ANSI_COLORS[num - 30 + (bold ? 8 : 0)] || '#cccccc';
|
||||||
|
} else if (num >= 40 && num <= 47) {
|
||||||
|
bg = ANSI_COLORS[num - 40] || '#000000';
|
||||||
|
} else if (num >= 90 && num <= 97) {
|
||||||
|
fg = ANSI_COLORS[num - 90 + 8] || '#cccccc';
|
||||||
|
} else if (num >= 100 && num <= 107) {
|
||||||
|
bg = ANSI_COLORS[num - 100 + 8] || '#000000';
|
||||||
|
} else if (num >= 1 && num <= 256) {
|
||||||
|
// 256 colors
|
||||||
|
if (num < 16) {
|
||||||
|
fg = ANSI_COLORS[num] || '#cccccc';
|
||||||
|
} else if (num < 232) {
|
||||||
|
const c = num - 16;
|
||||||
|
const r = Math.floor(c / 36) * 51;
|
||||||
|
const g = Math.floor((c % 36) / 6) * 51;
|
||||||
|
const b = (c % 6) * 51;
|
||||||
|
fg = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
||||||
|
} else {
|
||||||
|
const gray = (num - 232) * 10 + 8;
|
||||||
|
fg = `#${gray.toString(16).repeat(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
currentText = '';
|
||||||
|
}
|
||||||
|
inEscape = false;
|
||||||
|
escapeCode = '';
|
||||||
|
}
|
||||||
|
} else if (char === '\x1b' && text[i + 1] === '[') {
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
currentText = '';
|
||||||
|
}
|
||||||
|
inEscape = true;
|
||||||
|
escapeCode = '';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
currentText += char;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentText) {
|
||||||
|
tokens.push({ text: currentText, fg, bg, bold });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLine(text, x, y, lineHeight) {
|
||||||
|
const tokens = parseAnsi(text);
|
||||||
|
let xOffset = x;
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.text) {
|
||||||
|
if (token.bold) {
|
||||||
|
ctx.font = 'bold 16px monospace';
|
||||||
|
} else {
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = ctx.measureText(token.text);
|
||||||
|
|
||||||
|
if (token.bg !== '#000000') {
|
||||||
|
ctx.fillStyle = token.bg;
|
||||||
|
ctx.fillRect(xOffset, y - 2, metrics.width + 1, lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = token.fg;
|
||||||
|
ctx.fillText(token.text, xOffset, y);
|
||||||
|
xOffset += metrics.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.hostname}:8765`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
status.textContent = 'Connected';
|
||||||
|
status.className = 'connected';
|
||||||
|
sendSize();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
status.textContent = 'Disconnected - Reconnecting...';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
setTimeout(connect, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
status.textContent = 'Connection error';
|
||||||
|
status.className = 'disconnected';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type === 'frame') {
|
||||||
|
cols = data.width || 80;
|
||||||
|
rows = data.height || 24;
|
||||||
|
colsInput.value = cols;
|
||||||
|
rowsInput.value = rows;
|
||||||
|
resizeCanvas();
|
||||||
|
render(data.lines || []);
|
||||||
|
} else if (data.type === 'clear') {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
} else if (data.type === 'state') {
|
||||||
|
// Log state updates for debugging (can be extended for UI)
|
||||||
|
console.log('State update:', data.state);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendSize() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
width: parseInt(colsInput.value),
|
||||||
|
height: parseInt(rowsInput.value)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(lines) {
|
||||||
|
ctx.fillStyle = '#000';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
ctx.font = '16px monospace';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
const lineHeight = CHAR_HEIGHT;
|
||||||
|
const maxLines = Math.min(lines.length, rows);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxLines; i++) {
|
||||||
|
const line = lines[i] || '';
|
||||||
|
renderLine(line, 0, i * lineHeight, lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateViewportSize() {
|
||||||
|
const isFullscreen = document.fullscreenElement !== null;
|
||||||
|
const padding = isFullscreen ? 0 : 40;
|
||||||
|
const controlsHeight = isFullscreen ? 0 : 60;
|
||||||
|
const availableWidth = window.innerWidth - padding;
|
||||||
|
const availableHeight = window.innerHeight - controlsHeight;
|
||||||
|
cols = Math.max(20, Math.floor(availableWidth / CHAR_WIDTH));
|
||||||
|
rows = Math.max(10, Math.floor(availableHeight / CHAR_HEIGHT));
|
||||||
|
colsInput.value = cols;
|
||||||
|
rowsInput.value = rows;
|
||||||
|
resizeCanvas();
|
||||||
|
console.log('Fullscreen:', isFullscreen, 'Size:', cols, 'x', rows);
|
||||||
|
sendSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyBtn.addEventListener('click', () => {
|
||||||
|
cols = parseInt(colsInput.value);
|
||||||
|
rows = parseInt(rowsInput.value);
|
||||||
|
resizeCanvas();
|
||||||
|
sendSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
fullscreenBtn.addEventListener('click', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.body.classList.add('fullscreen');
|
||||||
|
document.documentElement.requestFullscreen().then(() => {
|
||||||
|
calculateViewportSize();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen().then(() => {
|
||||||
|
calculateViewportSize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.body.classList.remove('fullscreen');
|
||||||
|
calculateViewportSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
calculateViewportSize();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
resizeCanvas();
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
Command-line utility for interacting with mainline via ntfy.
|
Command-line utility for interacting with mainline via ntfy.
|
||||||
|
|
||||||
@@ -20,6 +21,11 @@ C&C works like a serial port:
|
|||||||
3. Cmdline polls for response
|
3. Cmdline polls for response
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["FORCE_COLOR"] = "1"
|
||||||
|
os.environ["TERM"] = "xterm-256color"
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
153
docs/ARCHITECTURE.md
Normal file
153
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
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
|
||||||
|
└── 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
|
||||||
223
docs/PIPELINE.md
Normal file
223
docs/PIPELINE.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# Mainline Pipeline
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
The Mainline pipeline uses a **Stage-based architecture** with **capability-based dependency resolution**. Stages declare capabilities (what they provide) and dependencies (what they need), and the Pipeline resolves dependencies using prefix matching.
|
||||||
|
|
||||||
|
```
|
||||||
|
Source Stage → Render Stage → Effect Stages → Display Stage
|
||||||
|
↓
|
||||||
|
Camera Stage (provides camera.state capability)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capability-Based Dependency Resolution
|
||||||
|
|
||||||
|
Stages declare capabilities and dependencies:
|
||||||
|
- **Capabilities**: What the stage provides (e.g., `source`, `render.output`, `display.output`, `camera.state`)
|
||||||
|
- **Dependencies**: What the stage needs (e.g., `source`, `render.output`, `camera.state`)
|
||||||
|
|
||||||
|
The Pipeline resolves dependencies using **prefix matching**:
|
||||||
|
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||||
|
- `"camera.state"` matches the camera state capability provided by `CameraClockStage`
|
||||||
|
- This allows flexible composition without hardcoding specific stage names
|
||||||
|
|
||||||
|
### Minimum Capabilities
|
||||||
|
|
||||||
|
The pipeline requires these minimum capabilities to function:
|
||||||
|
- `"source"` - Data source capability (provides raw items)
|
||||||
|
- `"render.output"` - Rendered content capability
|
||||||
|
- `"display.output"` - Display output capability
|
||||||
|
- `"camera.state"` - Camera state for viewport filtering
|
||||||
|
|
||||||
|
These are automatically injected if missing by the `ensure_minimum_capabilities()` method.
|
||||||
|
|
||||||
|
### Stage Registry
|
||||||
|
|
||||||
|
The `StageRegistry` discovers and registers stages automatically:
|
||||||
|
- Scans `engine/stages/` for stage implementations
|
||||||
|
- Registers stages by their declared capabilities
|
||||||
|
- Enables runtime stage discovery and composition
|
||||||
|
|
||||||
|
## Stage-Based Pipeline Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Stages["Stage Pipeline"]
|
||||||
|
subgraph SourceStage["Source Stage (provides: source.*)"]
|
||||||
|
Headlines[HeadlinesSource]
|
||||||
|
Poetry[PoetrySource]
|
||||||
|
Pipeline[PipelineSource]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph RenderStage["Render Stage (provides: render.*)"]
|
||||||
|
Render[RenderStage]
|
||||||
|
Canvas[Canvas]
|
||||||
|
Camera[Camera]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EffectStages["Effect Stages (provides: effect.*)"]
|
||||||
|
Noise[NoiseEffect]
|
||||||
|
Fade[FadeEffect]
|
||||||
|
Glitch[GlitchEffect]
|
||||||
|
Firehose[FirehoseEffect]
|
||||||
|
Hud[HudEffect]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DisplayStage["Display Stage (provides: display.*)"]
|
||||||
|
Terminal[TerminalDisplay]
|
||||||
|
Pygame[PygameDisplay]
|
||||||
|
WebSocket[WebSocketDisplay]
|
||||||
|
Null[NullDisplay]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Capabilities["Capability Map"]
|
||||||
|
SourceCaps["source.headlines<br/>source.poetry<br/>source.pipeline"]
|
||||||
|
RenderCaps["render.output<br/>render.canvas"]
|
||||||
|
EffectCaps["effect.noise<br/>effect.fade<br/>effect.glitch"]
|
||||||
|
DisplayCaps["display.output<br/>display.terminal"]
|
||||||
|
end
|
||||||
|
|
||||||
|
SourceStage --> RenderStage
|
||||||
|
RenderStage --> EffectStages
|
||||||
|
EffectStages --> DisplayStage
|
||||||
|
|
||||||
|
SourceStage --> SourceCaps
|
||||||
|
RenderStage --> RenderCaps
|
||||||
|
EffectStages --> EffectCaps
|
||||||
|
DisplayStage --> DisplayCaps
|
||||||
|
|
||||||
|
style SourceStage fill:#f9f,stroke:#333
|
||||||
|
style RenderStage fill:#bbf,stroke:#333
|
||||||
|
style EffectStages fill:#fbf,stroke:#333
|
||||||
|
style DisplayStage fill:#bfb,stroke:#333
|
||||||
|
```
|
||||||
|
|
||||||
|
## Stage Adapters
|
||||||
|
|
||||||
|
Existing components are wrapped as Stages via adapters:
|
||||||
|
|
||||||
|
### Source Stage Adapter
|
||||||
|
- Wraps `HeadlinesDataSource`, `PoetryDataSource`, etc.
|
||||||
|
- Provides `source.*` capabilities
|
||||||
|
- Fetches data and outputs to pipeline buffer
|
||||||
|
|
||||||
|
### Render Stage Adapter
|
||||||
|
- Wraps `StreamController`, `Camera`, `render_ticker_zone`
|
||||||
|
- Provides `render.output` capability
|
||||||
|
- Processes content and renders to canvas
|
||||||
|
|
||||||
|
### Effect Stage Adapter
|
||||||
|
- Wraps `EffectChain` and individual effect plugins
|
||||||
|
- Provides `effect.*` capabilities
|
||||||
|
- Applies visual effects to rendered content
|
||||||
|
|
||||||
|
### Display Stage Adapter
|
||||||
|
- Wraps `TerminalDisplay`, `PygameDisplay`, etc.
|
||||||
|
- Provides `display.*` capabilities
|
||||||
|
- Outputs final buffer to display backend
|
||||||
|
|
||||||
|
## Pipeline Mutation API
|
||||||
|
|
||||||
|
The Pipeline supports dynamic mutation during runtime:
|
||||||
|
|
||||||
|
### Core Methods
|
||||||
|
- `add_stage(name, stage, initialize=True)` - Add a stage
|
||||||
|
- `remove_stage(name, cleanup=True)` - Remove a stage and rebuild execution order
|
||||||
|
- `replace_stage(name, new_stage, preserve_state=True)` - Replace a stage
|
||||||
|
- `swap_stages(name1, name2)` - Swap two stages
|
||||||
|
- `move_stage(name, after=None, before=None)` - Move a stage in execution order
|
||||||
|
- `enable_stage(name)` / `disable_stage(name)` - Enable/disable stages
|
||||||
|
|
||||||
|
### Safety Checks
|
||||||
|
- `can_hot_swap(name)` - Check if a stage can be safely hot-swapped
|
||||||
|
- `cleanup_stage(name)` - Clean up specific stage without removing it
|
||||||
|
|
||||||
|
### WebSocket Commands
|
||||||
|
The mutation API is accessible via WebSocket for remote control:
|
||||||
|
```json
|
||||||
|
{"action": "remove_stage", "stage": "stage_name"}
|
||||||
|
{"action": "swap_stages", "stage1": "name1", "stage2": "name2"}
|
||||||
|
{"action": "enable_stage", "stage": "stage_name"}
|
||||||
|
{"action": "cleanup_stage", "stage": "stage_name"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Camera Modes
|
||||||
|
|
||||||
|
The Camera supports the following modes:
|
||||||
|
|
||||||
|
- **FEED**: Single item view (static or rapid cycling)
|
||||||
|
- **SCROLL**: Smooth vertical scrolling (movie credits style)
|
||||||
|
- **HORIZONTAL**: Left/right movement
|
||||||
|
- **OMNI**: Combination of vertical and horizontal
|
||||||
|
- **FLOATING**: Sinusoidal/bobbing motion
|
||||||
|
- **BOUNCE**: DVD-style bouncing off edges
|
||||||
|
- **RADIAL**: Polar coordinate scanning (radar sweep)
|
||||||
|
|
||||||
|
Note: Camera state is provided by `CameraClockStage` (capability: `camera.state`) which updates independently of data flow. The `CameraStage` applies viewport transformations (capability: `camera`).
|
||||||
|
|
||||||
|
## Animation & Presets
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Preset["Preset"]
|
||||||
|
PP[PipelineParams]
|
||||||
|
AC[AnimationController]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph AnimationController["AnimationController"]
|
||||||
|
Clock[Clock]
|
||||||
|
Events[Events]
|
||||||
|
Triggers[Triggers]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Triggers["Trigger Types"]
|
||||||
|
TIME[TIME]
|
||||||
|
FRAME[FRAME]
|
||||||
|
CYCLE[CYCLE]
|
||||||
|
COND[CONDITION]
|
||||||
|
MANUAL[MANUAL]
|
||||||
|
end
|
||||||
|
|
||||||
|
PP --> AC
|
||||||
|
Clock --> AC
|
||||||
|
Events --> AC
|
||||||
|
Triggers --> Events
|
||||||
|
```
|
||||||
|
|
||||||
|
## Camera Modes State Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
stateDiagram-v2
|
||||||
|
[*] --> Vertical
|
||||||
|
Vertical --> Horizontal: mode change
|
||||||
|
Horizontal --> Omni: mode change
|
||||||
|
Omni --> Floating: mode change
|
||||||
|
Floating --> Trace: mode change
|
||||||
|
Trace --> Vertical: mode change
|
||||||
|
|
||||||
|
state Vertical {
|
||||||
|
[*] --> ScrollUp
|
||||||
|
ScrollUp --> ScrollUp: +y each frame
|
||||||
|
}
|
||||||
|
|
||||||
|
state Horizontal {
|
||||||
|
[*] --> ScrollLeft
|
||||||
|
ScrollLeft --> ScrollLeft: +x each frame
|
||||||
|
}
|
||||||
|
|
||||||
|
state Omni {
|
||||||
|
[*] --> Diagonal
|
||||||
|
Diagonal --> Diagonal: +x, +y each frame
|
||||||
|
}
|
||||||
|
|
||||||
|
state Floating {
|
||||||
|
[*] --> Bobbing
|
||||||
|
Bobbing --> Bobbing: sin(time) for x,y
|
||||||
|
}
|
||||||
|
|
||||||
|
state Trace {
|
||||||
|
[*] --> FollowPath
|
||||||
|
FollowPath --> FollowPath: node by node
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
# Refactor mainline\.py into modular package
|
#
|
||||||
|
|
||||||
|
Refactor mainline\.py into modular package
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
`mainline.py` is a single 1085\-line file with ~10 interleaved concerns\. This prevents:
|
`mainline.py` is a single 1085\-line file with ~10 interleaved concerns\. This prevents:
|
||||||
|
|
||||||
* Reusing the ntfy doorbell interrupt in other visualizers
|
* Reusing the ntfy doorbell interrupt in other visualizers
|
||||||
* Importing the render pipeline from `serve.py` \(future ESP32 HTTP server\)
|
* Importing the render pipeline from `serve.py` \(future ESP32 HTTP server\)
|
||||||
* Testing any concern in isolation
|
* Testing any concern in isolation
|
||||||
* Porting individual layers to Rust independently
|
* Porting individual layers to Rust independently
|
||||||
|
|
||||||
## Target structure
|
## Target structure
|
||||||
|
|
||||||
```warp-runnable-command
|
```warp-runnable-command
|
||||||
mainline.py # thin entrypoint: venv bootstrap → engine.app.main()
|
mainline.py # thin entrypoint: venv bootstrap → engine.app.main()
|
||||||
engine/
|
engine/
|
||||||
@@ -23,8 +30,11 @@ engine/
|
|||||||
scroll.py # stream() frame loop + message rendering
|
scroll.py # stream() frame loop + message rendering
|
||||||
app.py # main(), TITLE art, boot sequence, signal handler
|
app.py # main(), TITLE art, boot sequence, signal handler
|
||||||
```
|
```
|
||||||
|
|
||||||
The package is named `engine/` to avoid a naming conflict with the `mainline.py` entrypoint\.
|
The package is named `engine/` to avoid a naming conflict with the `mainline.py` entrypoint\.
|
||||||
|
|
||||||
## Module dependency graph
|
## Module dependency graph
|
||||||
|
|
||||||
```warp-runnable-command
|
```warp-runnable-command
|
||||||
config ← (nothing)
|
config ← (nothing)
|
||||||
sources ← (nothing)
|
sources ← (nothing)
|
||||||
@@ -39,64 +49,92 @@ mic ← (nothing — sounddevice only)
|
|||||||
scroll ← config, terminal, render, effects, ntfy, mic
|
scroll ← config, terminal, render, effects, ntfy, mic
|
||||||
app ← everything above
|
app ← everything above
|
||||||
```
|
```
|
||||||
|
|
||||||
Critical property: **ntfy\.py and mic\.py have zero internal dependencies**, making ntfy reusable by any visualizer\.
|
Critical property: **ntfy\.py and mic\.py have zero internal dependencies**, making ntfy reusable by any visualizer\.
|
||||||
|
|
||||||
## Module details
|
## Module details
|
||||||
|
|
||||||
### mainline\.py \(entrypoint — slimmed down\)
|
### 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()`\.
|
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
|
### engine/config\.py
|
||||||
|
|
||||||
From current mainline\.py:
|
From current mainline\.py:
|
||||||
|
|
||||||
* `HEADLINE_LIMIT`, `FEED_TIMEOUT`, `MIC_THRESHOLD_DB` \(lines 55\-57\)
|
* `HEADLINE_LIMIT`, `FEED_TIMEOUT`, `MIC_THRESHOLD_DB` \(lines 55\-57\)
|
||||||
* `MODE`, `FIREHOSE` CLI flag parsing \(lines 58\-59\)
|
* `MODE`, `FIREHOSE` CLI flag parsing \(lines 58\-59\)
|
||||||
* `NTFY_TOPIC`, `NTFY_POLL_INTERVAL`, `MESSAGE_DISPLAY_SECS` \(lines 62\-64\)
|
* `NTFY_TOPIC`, `NTFY_POLL_INTERVAL`, `MESSAGE_DISPLAY_SECS` \(lines 62\-64\)
|
||||||
* `_FONT_PATH`, `_FONT_SZ`, `_RENDER_H` \(lines 147\-150\)
|
* `_FONT_PATH`, `_FONT_SZ`, `_RENDER_H` \(lines 147\-150\)
|
||||||
* `_SCROLL_DUR`, `_FRAME_DT`, `FIREHOSE_H` \(lines 505\-507\)
|
* `_SCROLL_DUR`, `_FRAME_DT`, `FIREHOSE_H` \(lines 505\-507\)
|
||||||
* `GLITCH`, `KATA` glyph tables \(lines 143\-144\)
|
* `GLITCH`, `KATA` glyph tables \(lines 143\-144\)
|
||||||
|
|
||||||
### engine/sources\.py
|
### engine/sources\.py
|
||||||
|
|
||||||
Pure data, no logic:
|
Pure data, no logic:
|
||||||
|
|
||||||
* `FEEDS` dict \(lines 102\-140\)
|
* `FEEDS` dict \(lines 102\-140\)
|
||||||
* `POETRY_SOURCES` dict \(lines 67\-80\)
|
* `POETRY_SOURCES` dict \(lines 67\-80\)
|
||||||
* `SOURCE_LANGS` dict \(lines 258\-266\)
|
* `SOURCE_LANGS` dict \(lines 258\-266\)
|
||||||
* `_LOCATION_LANGS` dict \(lines 269\-289\)
|
* `_LOCATION_LANGS` dict \(lines 269\-289\)
|
||||||
* `_SCRIPT_FONTS` dict \(lines 153\-165\)
|
* `_SCRIPT_FONTS` dict \(lines 153\-165\)
|
||||||
* `_NO_UPPER` set \(line 167\)
|
* `_NO_UPPER` set \(line 167\)
|
||||||
|
|
||||||
### engine/terminal\.py
|
### engine/terminal\.py
|
||||||
|
|
||||||
ANSI primitives and terminal I/O:
|
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\)
|
* 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\)
|
* `tw()`, `th()` \(lines 223\-234\)
|
||||||
* `type_out()`, `slow_print()`, `boot_ln()` \(lines 355\-386\)
|
* `type_out()`, `slow_print()`, `boot_ln()` \(lines 355\-386\)
|
||||||
|
|
||||||
### engine/filter\.py
|
### engine/filter\.py
|
||||||
|
|
||||||
* `_Strip` HTML parser class \(lines 205\-214\)
|
* `_Strip` HTML parser class \(lines 205\-214\)
|
||||||
* `strip_tags()` \(lines 217\-220\)
|
* `strip_tags()` \(lines 217\-220\)
|
||||||
* `_SKIP_RE` compiled regex \(lines 322\-346\)
|
* `_SKIP_RE` compiled regex \(lines 322\-346\)
|
||||||
* `_skip()` predicate \(lines 349\-351\)
|
* `_skip()` predicate \(lines 349\-351\)
|
||||||
|
|
||||||
### engine/translate\.py
|
### engine/translate\.py
|
||||||
|
|
||||||
* `_TRANSLATE_CACHE` \(line 291\)
|
* `_TRANSLATE_CACHE` \(line 291\)
|
||||||
* `_detect_location_language()` \(lines 294\-300\) — imports `_LOCATION_LANGS` from sources
|
* `_detect_location_language()` \(lines 294\-300\) — imports `_LOCATION_LANGS` from sources
|
||||||
* `_translate_headline()` \(lines 303\-319\)
|
* `_translate_headline()` \(lines 303\-319\)
|
||||||
|
|
||||||
### engine/render\.py
|
### engine/render\.py
|
||||||
|
|
||||||
The OTF→terminal pipeline\. This is exactly what `serve.py` will import to produce 1\-bit bitmaps for the ESP32\.
|
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\)
|
* `_GRAD_COLS` gradient table \(lines 169\-182\)
|
||||||
* `_font()`, `_font_for_lang()` with lazy\-load \+ cache \(lines 185\-202\)
|
* `_font()`, `_font_for_lang()` with lazy\-load \+ cache \(lines 185\-202\)
|
||||||
* `_render_line()` — OTF text → half\-block terminal rows \(lines 567\-605\)
|
* `_render_line()` — OTF text → half\-block terminal rows \(lines 567\-605\)
|
||||||
* `_big_wrap()` — word\-wrap \+ render \(lines 608\-636\)
|
* `_big_wrap()` — word\-wrap \+ render \(lines 608\-636\)
|
||||||
* `_lr_gradient()` — apply left→right color gradient \(lines 639\-656\)
|
* `_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\.
|
* `_make_block()` — composite: translate → render → colorize a headline \(lines 718\-756\)\. Imports from translate, sources\.
|
||||||
|
|
||||||
### engine/effects\.py
|
### engine/effects\.py
|
||||||
|
|
||||||
Visual effects applied during the frame loop:
|
Visual effects applied during the frame loop:
|
||||||
|
|
||||||
* `noise()` \(lines 237\-245\)
|
* `noise()` \(lines 237\-245\)
|
||||||
* `glitch_bar()` \(lines 248\-252\)
|
* `glitch_bar()` \(lines 248\-252\)
|
||||||
* `_fade_line()` — probabilistic character dissolve \(lines 659\-680\)
|
* `_fade_line()` — probabilistic character dissolve \(lines 659\-680\)
|
||||||
* `_vis_trunc()` — ANSI\-aware width truncation \(lines 683\-701\)
|
* `_vis_trunc()` — ANSI\-aware width truncation \(lines 683\-701\)
|
||||||
* `_firehose_line()` \(lines 759\-801\) — imports config\.MODE, sources\.FEEDS/POETRY\_SOURCES
|
* `_firehose_line()` \(lines 759\-801\) — imports config\.MODE, sources\.FEEDS/POETRY\_SOURCES
|
||||||
* `_next_headline()` — pool management \(lines 704\-715\)
|
* `_next_headline()` — pool management \(lines 704\-715\)
|
||||||
|
|
||||||
### engine/fetch\.py
|
### engine/fetch\.py
|
||||||
|
|
||||||
* `fetch_feed()` \(lines 390\-396\)
|
* `fetch_feed()` \(lines 390\-396\)
|
||||||
* `fetch_all()` \(lines 399\-426\) — imports filter\.\_skip, filter\.strip\_tags, terminal\.boot\_ln
|
* `fetch_all()` \(lines 399\-426\) — imports filter\.\_skip, filter\.strip\_tags, terminal\.boot\_ln
|
||||||
* `_fetch_gutenberg()` \(lines 429\-456\)
|
* `_fetch_gutenberg()` \(lines 429\-456\)
|
||||||
* `fetch_poetry()` \(lines 459\-472\)
|
* `fetch_poetry()` \(lines 459\-472\)
|
||||||
* `_cache_path()`, `_load_cache()`, `_save_cache()` \(lines 476\-501\)
|
* `_cache_path()`, `_load_cache()`, `_save_cache()` \(lines 476\-501\)
|
||||||
|
|
||||||
### engine/ntfy\.py — standalone, reusable
|
### 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:
|
Refactored from the current globals \+ thread \(lines 531\-564\) and the message rendering section of `stream()` \(lines 845\-909\) into a class:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class NtfyPoller:
|
class NtfyPoller:
|
||||||
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
||||||
@@ -108,8 +146,10 @@ class NtfyPoller:
|
|||||||
def dismiss(self):
|
def dismiss(self):
|
||||||
"""Manually dismiss current message."""
|
"""Manually dismiss current message."""
|
||||||
```
|
```
|
||||||
|
|
||||||
Dependencies: `urllib.request`, `json`, `threading`, `time` — all stdlib\. No internal imports\.
|
Dependencies: `urllib.request`, `json`, `threading`, `time` — all stdlib\. No internal imports\.
|
||||||
Other visualizers use it like:
|
Other visualizers use it like:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from engine.ntfy import NtfyPoller
|
from engine.ntfy import NtfyPoller
|
||||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||||
@@ -120,8 +160,11 @@ if msg:
|
|||||||
title, body, ts = msg
|
title, body, ts = msg
|
||||||
render_my_message(title, body) # visualizer-specific
|
render_my_message(title, body) # visualizer-specific
|
||||||
```
|
```
|
||||||
|
|
||||||
### engine/mic\.py — standalone
|
### engine/mic\.py — standalone
|
||||||
|
|
||||||
Refactored from the current globals \(lines 508\-528\) into a class:
|
Refactored from the current globals \(lines 508\-528\) into a class:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class MicMonitor:
|
class MicMonitor:
|
||||||
def __init__(self, threshold_db=50):
|
def __init__(self, threshold_db=50):
|
||||||
@@ -137,41 +180,75 @@ class MicMonitor:
|
|||||||
def excess(self) -> float:
|
def excess(self) -> float:
|
||||||
"""dB above threshold (clamped to 0)."""
|
"""dB above threshold (clamped to 0)."""
|
||||||
```
|
```
|
||||||
|
|
||||||
Dependencies: `sounddevice`, `numpy` \(both optional — graceful fallback\)\.
|
Dependencies: `sounddevice`, `numpy` \(both optional — graceful fallback\)\.
|
||||||
|
|
||||||
### engine/scroll\.py
|
### engine/scroll\.py
|
||||||
|
|
||||||
The `stream()` function \(lines 804\-990\)\. Receives its dependencies via arguments or imports:
|
The `stream()` function \(lines 804\-990\)\. Receives its dependencies via arguments or imports:
|
||||||
|
|
||||||
* `stream(items, ntfy_poller, mic_monitor, config)` or similar
|
* `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
|
* Message rendering \(lines 855\-909\) stays here since it's terminal\-display\-specific — a different visualizer would render messages differently
|
||||||
|
|
||||||
### engine/app\.py
|
### engine/app\.py
|
||||||
|
|
||||||
The orchestrator:
|
The orchestrator:
|
||||||
|
|
||||||
* `TITLE` ASCII art \(lines 994\-1001\)
|
* `TITLE` ASCII art \(lines 994\-1001\)
|
||||||
* `main()` \(lines 1004\-1084\): CLI handling, signal setup, boot animation, fetch, wire up ntfy/mic/scroll
|
* `main()` \(lines 1004\-1084\): CLI handling, signal setup, boot animation, fetch, wire up ntfy/mic/scroll
|
||||||
|
|
||||||
## Execution order
|
## Execution order
|
||||||
|
|
||||||
### Step 1: Create engine/ package skeleton
|
### Step 1: Create engine/ package skeleton
|
||||||
|
|
||||||
Create `engine/__init__.py` and all empty module files\.
|
Create `engine/__init__.py` and all empty module files\.
|
||||||
|
|
||||||
### Step 2: Extract pure data modules \(zero\-dep\)
|
### Step 2: Extract pure data modules \(zero\-dep\)
|
||||||
|
|
||||||
Move constants and data dicts into `config.py`, `sources.py`\. These have no logic dependencies\.
|
Move constants and data dicts into `config.py`, `sources.py`\. These have no logic dependencies\.
|
||||||
|
|
||||||
### Step 3: Extract terminal\.py
|
### Step 3: Extract terminal\.py
|
||||||
|
|
||||||
Move ANSI codes and terminal I/O helpers\. No internal deps\.
|
Move ANSI codes and terminal I/O helpers\. No internal deps\.
|
||||||
|
|
||||||
### Step 4: Extract filter\.py and translate\.py
|
### Step 4: Extract filter\.py and translate\.py
|
||||||
|
|
||||||
Both are small, self\-contained\. translate imports from sources\.
|
Both are small, self\-contained\. translate imports from sources\.
|
||||||
|
|
||||||
### Step 5: Extract render\.py
|
### 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\.
|
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
|
### Step 6: Extract effects\.py
|
||||||
|
|
||||||
Visual effects\. Imports from config, terminal, sources\.
|
Visual effects\. Imports from config, terminal, sources\.
|
||||||
|
|
||||||
### Step 7: Extract fetch\.py
|
### Step 7: Extract fetch\.py
|
||||||
|
|
||||||
Feed/Gutenberg fetching \+ caching\. Imports from config, sources, filter, terminal\.
|
Feed/Gutenberg fetching \+ caching\. Imports from config, sources, filter, terminal\.
|
||||||
|
|
||||||
### Step 8: Extract ntfy\.py and mic\.py
|
### Step 8: Extract ntfy\.py and mic\.py
|
||||||
|
|
||||||
Refactor globals\+threads into classes\. Zero internal deps\.
|
Refactor globals\+threads into classes\. Zero internal deps\.
|
||||||
|
|
||||||
### Step 9: Extract scroll\.py
|
### Step 9: Extract scroll\.py
|
||||||
|
|
||||||
The frame loop\. Last to extract because it depends on everything above\.
|
The frame loop\. Last to extract because it depends on everything above\.
|
||||||
|
|
||||||
### Step 10: Extract app\.py
|
### Step 10: Extract app\.py
|
||||||
|
|
||||||
The `main()` function, boot sequence, signal handler\. Wire up all modules\.
|
The `main()` function, boot sequence, signal handler\. Wire up all modules\.
|
||||||
|
|
||||||
### Step 11: Slim down mainline\.py
|
### Step 11: Slim down mainline\.py
|
||||||
|
|
||||||
Keep only venv bootstrap \+ `from engine.app import main; main()`\.
|
Keep only venv bootstrap \+ `from engine.app import main; main()`\.
|
||||||
|
|
||||||
### Step 12: Verify
|
### 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\.
|
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
|
## What this enables
|
||||||
|
|
||||||
* **serve\.py** \(future\): `from engine.render import _render_line, _big_wrap` \+ `from engine.fetch import fetch_all` — imports the pipeline directly
|
* **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
|
* **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\)
|
* **Rust port**: Clear boundaries for what to port first \(ntfy client, render pipeline\) vs what stays in Python \(fetching, caching — the server side\)
|
||||||
217
docs/proposals/adr-preset-scripting-language.md
Normal file
217
docs/proposals/adr-preset-scripting-language.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# ADR: Preset Scripting Language for Mainline
|
||||||
|
|
||||||
|
## Status: Draft
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We need to evaluate whether to add a scripting language for authoring presets in Mainline, replacing or augmenting the current TOML-based preset system. The goals are:
|
||||||
|
|
||||||
|
1. **Expressiveness**: More powerful than TOML for describing dynamic, procedural, or dataflow-based presets
|
||||||
|
2. **Live coding**: Support hot-reloading of presets during runtime (like TidalCycles or Sonic Pi)
|
||||||
|
3. **Testing**: Include assertion language to package tests alongside presets
|
||||||
|
4. **Toolchain**: Consider packaging and build processes
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
|
||||||
|
The current preset system uses TOML files (`presets.toml`) with a simple structure:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[presets.demo-base]
|
||||||
|
description = "Demo: Base preset for effect hot-swapping"
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "feed"
|
||||||
|
effects = [] # Demo script will add/remove effects dynamically
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
```
|
||||||
|
|
||||||
|
This is declarative and static. It cannot express:
|
||||||
|
- Conditional logic based on runtime state
|
||||||
|
- Dataflow between pipeline stages
|
||||||
|
- Procedural generation of stage configurations
|
||||||
|
- Assertions or validation of preset behavior
|
||||||
|
|
||||||
|
### Problems with TOML
|
||||||
|
|
||||||
|
- No way to express dependencies between effects or stages
|
||||||
|
- Cannot describe temporal/animated behavior
|
||||||
|
- No support for sensor bindings or parametric animations
|
||||||
|
- Static configuration cannot adapt to runtime conditions
|
||||||
|
- No built-in testing/assertion mechanism
|
||||||
|
|
||||||
|
## Approaches
|
||||||
|
|
||||||
|
### 1. Visual Dataflow Language (PureData-style)
|
||||||
|
|
||||||
|
Inspired by Pure Data (Pd), Max/MSP, and TouchDesigner:
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Intuitive for creative coding and live performance
|
||||||
|
- Strong model for real-time parameter modulation
|
||||||
|
- Matches the "patcher" paradigm already seen in pipeline architecture
|
||||||
|
- Rich ecosystem of visual programming tools
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Complex to implement from scratch
|
||||||
|
- Requires dedicated GUI editor
|
||||||
|
- Harder to version control (binary/graph formats)
|
||||||
|
- Mermaid diagrams alone aren't sufficient for this
|
||||||
|
|
||||||
|
**Tools to explore:**
|
||||||
|
- libpd (Pure Data bindings for other languages)
|
||||||
|
- Node-based frameworks (node-red, various DSP tools)
|
||||||
|
- TouchDesigner-like approaches
|
||||||
|
|
||||||
|
### 2. Textual DSL (TidalCycles-style)
|
||||||
|
|
||||||
|
Domain-specific language focused on pattern transformation:
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Lightweight, fast iteration
|
||||||
|
- Easy to version control (text files)
|
||||||
|
- Can express complex patterns with minimal syntax
|
||||||
|
- Proven in livecoding community
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Learning curve for non-programmers
|
||||||
|
- Less visual than PureData approach
|
||||||
|
|
||||||
|
**Example (hypothetical):**
|
||||||
|
```
|
||||||
|
preset my-show {
|
||||||
|
source: headlines
|
||||||
|
|
||||||
|
every 8s {
|
||||||
|
effect noise: intensity = (0.5 <-> 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
on mic.level > 0.7 {
|
||||||
|
effect glitch: intensity += 0.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Embed Existing Language
|
||||||
|
|
||||||
|
Embed Lua, Python, or JavaScript:
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Full power of general-purpose language
|
||||||
|
- Existing tooling, testing frameworks
|
||||||
|
- Easy to integrate (many embeddable interpreters)
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Security concerns with running user code
|
||||||
|
- May be overkill for simple presets
|
||||||
|
- Testing/assertion system must be built on top
|
||||||
|
|
||||||
|
**Tools:**
|
||||||
|
- Lua (lightweight, fast)
|
||||||
|
- Python (rich ecosystem, but heavier)
|
||||||
|
- QuickJS (small, embeddable JS)
|
||||||
|
|
||||||
|
### 4. Hybrid Approach
|
||||||
|
|
||||||
|
Visual editor generates textual DSL that compiles to Python:
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Best of both worlds
|
||||||
|
- Can start with simple DSL and add editor later
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- More complex initial implementation
|
||||||
|
|
||||||
|
## Requirements Analysis
|
||||||
|
|
||||||
|
### Must Have
|
||||||
|
- [ ] Express pipeline stage configurations (source, effects, camera, display)
|
||||||
|
- [ ] Support parameter bindings to sensors
|
||||||
|
- [ ] Hot-reloading during runtime
|
||||||
|
- [ ] Integration with existing Pipeline architecture
|
||||||
|
|
||||||
|
### Should Have
|
||||||
|
- [ ] Basic assertion language for testing
|
||||||
|
- [ ] Ability to define custom abstractions/modules
|
||||||
|
- [ ] Version control friendly (text-based)
|
||||||
|
|
||||||
|
### Could Have
|
||||||
|
- [ ] Visual node-based editor
|
||||||
|
- [ ] Real-time visualization of dataflow
|
||||||
|
- [ ] MIDI/OSC support for external controllers
|
||||||
|
|
||||||
|
## User Stories (Proposed)
|
||||||
|
|
||||||
|
### Spike Stories (Investigation)
|
||||||
|
|
||||||
|
**Story 1: Evaluate DSL Parsing Tools**
|
||||||
|
> As a developer, I want to understand the available Python DSL parsing libraries (Lark, parsy, pyparsing) so that I can choose the right tool for implementing a preset DSL.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Document pros/cons of 3+ parsing libraries with small proof-of-concept experiments
|
||||||
|
|
||||||
|
**Story 2: Research Livecoding Languages**
|
||||||
|
> As a developer, I want to understand how TidalCycles, Sonic Pi, and PureData handle hot-reloading and pattern generation so that I can apply similar techniques to Mainline.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Document key architectural patterns from 2+ livecoding systems
|
||||||
|
|
||||||
|
**Story 3: Prototype Textual DSL**
|
||||||
|
> As a preset author, I want to write presets in a simple textual DSL that supports basic conditionals and sensor bindings.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Create a prototype DSL that can parse a sample preset and convert to PipelineConfig
|
||||||
|
|
||||||
|
**Story 4: Investigate Assertion/Testing Approaches**
|
||||||
|
> As a quality engineer, I want to include assertions with presets so that preset behavior can be validated automatically.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Survey testing patterns in livecoding and propose assertion syntax
|
||||||
|
|
||||||
|
### Implementation Stories (Future)
|
||||||
|
|
||||||
|
**Story 5: Implement Core DSL Parser**
|
||||||
|
> As a preset author, I want to write presets in a textual DSL that supports sensors, conditionals, and parameter bindings.
|
||||||
|
>
|
||||||
|
> **Acceptance**: DSL parser handles the core syntax, produces valid PipelineConfig
|
||||||
|
|
||||||
|
**Story 6: Hot-Reload System**
|
||||||
|
> As a performer, I want to edit preset files and see changes reflected in real-time without restarting.
|
||||||
|
>
|
||||||
|
> **Acceptance**: File watcher + pipeline mutation API integration works
|
||||||
|
|
||||||
|
**Story 7: Assertion Language**
|
||||||
|
> As a preset author, I want to include assertions that validate sensor values or pipeline state.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Assertions can run as part of preset execution and report pass/fail
|
||||||
|
|
||||||
|
**Story 8: Toolchain/Packaging**
|
||||||
|
> As a preset distributor, I want to package presets with dependencies for easy sharing.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Can create, build, and install a preset package
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Recommend: Start with textual DSL approach (Option 2/4)**
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- Lowest barrier to entry (text files, version control)
|
||||||
|
- Can evolve to hybrid later if visual editor is needed
|
||||||
|
- Strong precedents in livecoding community (TidalCycles, Sonic Pi)
|
||||||
|
- Enables hot-reloading naturally
|
||||||
|
- Assertion language can be part of the DSL syntax
|
||||||
|
|
||||||
|
**Not recommending Mermaid**: Mermaid is excellent for documentation and visualization, but it's a diagramming tool, not a programming language. It cannot express the logic, conditionals, and sensor bindings we need.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Execute Spike Stories 1-4 to reduce uncertainty
|
||||||
|
2. Create minimal viable DSL syntax
|
||||||
|
3. Prototype hot-reloading with existing preset system
|
||||||
|
4. Evaluate whether visual editor adds sufficient value to warrant complexity
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Pure Data: https://puredata.info/
|
||||||
|
- TidalCycles: https://tidalcycles.org/
|
||||||
|
- Sonic Pi: https://sonic-pi.net/
|
||||||
|
- Lark parser: https://lark-parser.readthedocs.io/
|
||||||
|
- Mainline Pipeline Architecture: `engine/pipeline/`
|
||||||
|
- Current Presets: `presets.toml`
|
||||||
@@ -1,894 +0,0 @@
|
|||||||
# Color Scheme Switcher Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Implement interactive color theme picker at startup that lets users choose between green, orange, or purple gradients with complementary message queue colors.
|
|
||||||
|
|
||||||
**Architecture:** New `themes.py` data module defines Theme class and THEME_REGISTRY. Config adds `ACTIVE_THEME` global set by picker. Render functions read from active theme instead of hardcoded constants. App adds picker UI that mirrors font picker pattern.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3.10+, ANSI 256-color codes, existing terminal I/O utilities
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
| File | Purpose | Change Type |
|
|
||||||
|------|---------|------------|
|
|
||||||
| `engine/themes.py` | Theme class, THEME_REGISTRY, color codes | Create |
|
|
||||||
| `engine/config.py` | ACTIVE_THEME global, set_active_theme() | Modify |
|
|
||||||
| `engine/render.py` | Replace GRAD_COLS/MSG_GRAD_COLS with config lookup | Modify |
|
|
||||||
| `engine/scroll.py` | Update message gradient call | Modify |
|
|
||||||
| `engine/app.py` | pick_color_theme(), call in main() | Modify |
|
|
||||||
| `tests/test_themes.py` | Theme class and registry unit tests | Create |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 1: Theme Data Module
|
|
||||||
|
|
||||||
### Task 1: Create themes.py with Theme class and registry
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `engine/themes.py`
|
|
||||||
- Test: `tests/test_themes.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test for Theme class**
|
|
||||||
|
|
||||||
Create `tests/test_themes.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Test color themes and registry."""
|
|
||||||
from engine.themes import Theme, THEME_REGISTRY, get_theme
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_construction():
|
|
||||||
"""Theme stores name and gradient lists."""
|
|
||||||
main = ["\033[1;38;5;231m"] * 12
|
|
||||||
msg = ["\033[1;38;5;225m"] * 12
|
|
||||||
theme = Theme(name="Test Green", main_gradient=main, message_gradient=msg)
|
|
||||||
|
|
||||||
assert theme.name == "Test Green"
|
|
||||||
assert theme.main_gradient == main
|
|
||||||
assert theme.message_gradient == msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_gradient_length():
|
|
||||||
"""Each gradient must have exactly 12 ANSI codes."""
|
|
||||||
for theme_id, theme in THEME_REGISTRY.items():
|
|
||||||
assert len(theme.main_gradient) == 12, f"{theme_id} main gradient wrong length"
|
|
||||||
assert len(theme.message_gradient) == 12, f"{theme_id} message gradient wrong length"
|
|
||||||
|
|
||||||
|
|
||||||
def test_theme_registry_has_three_themes():
|
|
||||||
"""Registry contains green, orange, purple."""
|
|
||||||
assert len(THEME_REGISTRY) == 3
|
|
||||||
assert "green" in THEME_REGISTRY
|
|
||||||
assert "orange" in THEME_REGISTRY
|
|
||||||
assert "purple" in THEME_REGISTRY
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_theme_valid():
|
|
||||||
"""get_theme returns Theme object for valid ID."""
|
|
||||||
theme = get_theme("green")
|
|
||||||
assert isinstance(theme, Theme)
|
|
||||||
assert theme.name == "Verdant Green"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_theme_invalid():
|
|
||||||
"""get_theme raises KeyError for invalid ID."""
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
get_theme("invalid_theme")
|
|
||||||
|
|
||||||
|
|
||||||
def test_green_theme_unchanged():
|
|
||||||
"""Green theme uses original green → magenta colors."""
|
|
||||||
green_theme = get_theme("green")
|
|
||||||
# First color should be white (bold)
|
|
||||||
assert green_theme.main_gradient[0] == "\033[1;38;5;231m"
|
|
||||||
# Last deep green
|
|
||||||
assert green_theme.main_gradient[9] == "\033[38;5;22m"
|
|
||||||
# Message gradient is magenta
|
|
||||||
assert green_theme.message_gradient[9] == "\033[38;5;89m"
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `pytest tests/test_themes.py -v`
|
|
||||||
Expected: FAIL (module doesn't exist)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create themes.py with Theme class and finalized gradients**
|
|
||||||
|
|
||||||
Create `engine/themes.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
"""Color theme definitions and registry."""
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class Theme:
|
|
||||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
|
||||||
"""Initialize theme with display name and gradient lists.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Display name (e.g., "Verdant Green")
|
|
||||||
main_gradient: List of 12 ANSI 256-color codes (white → primary color)
|
|
||||||
message_gradient: List of 12 ANSI codes (white → complementary color)
|
|
||||||
"""
|
|
||||||
self.name = name
|
|
||||||
self.main_gradient = main_gradient
|
|
||||||
self.message_gradient = message_gradient
|
|
||||||
|
|
||||||
|
|
||||||
# ─── FINALIZED GRADIENTS ──────────────────────────────────────────────────
|
|
||||||
# Each gradient: white → primary/complementary, 12 steps total
|
|
||||||
# Format: "\033[<brightness>;<color>m" where color is 38;5;<colorcode>
|
|
||||||
|
|
||||||
_GREEN_MAIN = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale white-tint
|
|
||||||
"\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
|
|
||||||
]
|
|
||||||
|
|
||||||
_GREEN_MESSAGE = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\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
|
|
||||||
]
|
|
||||||
|
|
||||||
_ORANGE_MAIN = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;215m", # pale orange-white
|
|
||||||
"\033[38;5;209m", # bright orange
|
|
||||||
"\033[38;5;208m", # vibrant orange
|
|
||||||
"\033[38;5;202m", # orange
|
|
||||||
"\033[38;5;166m", # dark orange
|
|
||||||
"\033[38;5;130m", # burnt orange
|
|
||||||
"\033[38;5;94m", # rust
|
|
||||||
"\033[38;5;58m", # dark rust
|
|
||||||
"\033[38;5;94m", # rust (hold)
|
|
||||||
"\033[2;38;5;94m", # dim rust
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_ORANGE_MESSAGE = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
|
||||||
"\033[38;5;33m", # bright blue
|
|
||||||
"\033[38;5;27m", # blue
|
|
||||||
"\033[38;5;21m", # deep blue
|
|
||||||
"\033[38;5;21m", # deep blue (hold)
|
|
||||||
"\033[38;5;21m", # deep blue (hold)
|
|
||||||
"\033[38;5;18m", # navy
|
|
||||||
"\033[38;5;18m", # navy (hold)
|
|
||||||
"\033[38;5;18m", # navy (hold)
|
|
||||||
"\033[2;38;5;18m", # dim navy
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_PURPLE_MAIN = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;225m", # pale purple-white
|
|
||||||
"\033[38;5;177m", # bright purple
|
|
||||||
"\033[38;5;171m", # vibrant purple
|
|
||||||
"\033[38;5;165m", # purple
|
|
||||||
"\033[38;5;135m", # medium purple
|
|
||||||
"\033[38;5;129m", # purple
|
|
||||||
"\033[38;5;93m", # dark purple
|
|
||||||
"\033[38;5;57m", # deep purple
|
|
||||||
"\033[38;5;57m", # deep purple (hold)
|
|
||||||
"\033[2;38;5;57m", # dim deep purple
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
_PURPLE_MESSAGE = [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;226m", # pale yellow-white
|
|
||||||
"\033[38;5;226m", # bright yellow
|
|
||||||
"\033[38;5;220m", # yellow
|
|
||||||
"\033[38;5;220m", # yellow (hold)
|
|
||||||
"\033[38;5;184m", # dark yellow
|
|
||||||
"\033[38;5;184m", # dark yellow (hold)
|
|
||||||
"\033[38;5;178m", # olive-yellow
|
|
||||||
"\033[38;5;178m", # olive-yellow (hold)
|
|
||||||
"\033[38;5;172m", # golden
|
|
||||||
"\033[2;38;5;172m", # dim golden
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
THEME_REGISTRY = {
|
|
||||||
"green": Theme(
|
|
||||||
name="Verdant Green",
|
|
||||||
main_gradient=_GREEN_MAIN,
|
|
||||||
message_gradient=_GREEN_MESSAGE,
|
|
||||||
),
|
|
||||||
"orange": Theme(
|
|
||||||
name="Molten Orange",
|
|
||||||
main_gradient=_ORANGE_MAIN,
|
|
||||||
message_gradient=_ORANGE_MESSAGE,
|
|
||||||
),
|
|
||||||
"purple": Theme(
|
|
||||||
name="Violet Purple",
|
|
||||||
main_gradient=_PURPLE_MAIN,
|
|
||||||
message_gradient=_PURPLE_MESSAGE,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_theme(theme_id: str) -> Theme:
|
|
||||||
"""Retrieve a theme by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: One of "green", "orange", "purple"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Theme object
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id not found in registry
|
|
||||||
"""
|
|
||||||
if theme_id not in THEME_REGISTRY:
|
|
||||||
raise KeyError(f"Unknown theme: {theme_id}. Available: {list(THEME_REGISTRY.keys())}")
|
|
||||||
return THEME_REGISTRY[theme_id]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_themes.py -v`
|
|
||||||
Expected: PASS (all 6 tests)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/themes.py tests/test_themes.py
|
|
||||||
git commit -m "feat: create Theme class and registry with finalized color gradients
|
|
||||||
|
|
||||||
- Define Theme class to encapsulate name and main/message gradients
|
|
||||||
- Create THEME_REGISTRY with green, orange, purple themes
|
|
||||||
- Each gradient has 12 ANSI 256-color codes finalized
|
|
||||||
- Complementary color pairs: green/magenta, orange/blue, purple/yellow
|
|
||||||
- Add get_theme() lookup with error handling
|
|
||||||
- Add comprehensive unit tests"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 2: Config Integration
|
|
||||||
|
|
||||||
### Task 2: Add ACTIVE_THEME global and set_active_theme() to config.py
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/config.py:1-30`
|
|
||||||
- Test: `tests/test_config.py` (expand existing)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests for config changes**
|
|
||||||
|
|
||||||
Add to `tests/test_config.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_active_theme_initially_none():
|
|
||||||
"""ACTIVE_THEME is None before initialization."""
|
|
||||||
# This test may fail if config is already initialized
|
|
||||||
# We'll set it to None first for testing
|
|
||||||
import engine.config
|
|
||||||
engine.config.ACTIVE_THEME = None
|
|
||||||
assert engine.config.ACTIVE_THEME is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_theme_green():
|
|
||||||
"""set_active_theme('green') sets ACTIVE_THEME to green theme."""
|
|
||||||
from engine.config import set_active_theme
|
|
||||||
from engine.themes import get_theme
|
|
||||||
|
|
||||||
set_active_theme("green")
|
|
||||||
|
|
||||||
assert config.ACTIVE_THEME is not None
|
|
||||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
|
||||||
assert config.ACTIVE_THEME == get_theme("green")
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_theme_default():
|
|
||||||
"""set_active_theme() with no args defaults to green."""
|
|
||||||
from engine.config import set_active_theme
|
|
||||||
|
|
||||||
set_active_theme()
|
|
||||||
|
|
||||||
assert config.ACTIVE_THEME.name == "Verdant Green"
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_active_theme_invalid():
|
|
||||||
"""set_active_theme() with invalid ID raises KeyError."""
|
|
||||||
from engine.config import set_active_theme
|
|
||||||
|
|
||||||
with pytest.raises(KeyError):
|
|
||||||
set_active_theme("invalid")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `pytest tests/test_config.py -v`
|
|
||||||
Expected: FAIL (functions don't exist yet)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add ACTIVE_THEME global and set_active_theme() to config.py**
|
|
||||||
|
|
||||||
Edit `engine/config.py`, add after line 30 (after `_resolve_font_path` function):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# ─── COLOR THEME ──────────────────────────────────────────────────────────
|
|
||||||
ACTIVE_THEME = None # set by set_active_theme() after picker
|
|
||||||
|
|
||||||
|
|
||||||
def set_active_theme(theme_id: str = "green"):
|
|
||||||
"""Set the active color theme. Defaults to 'green' if not specified.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: One of "green", "orange", "purple"
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id is invalid
|
|
||||||
"""
|
|
||||||
global ACTIVE_THEME
|
|
||||||
from engine import themes
|
|
||||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Remove hardcoded GRAD_COLS and MSG_GRAD_COLS from render.py**
|
|
||||||
|
|
||||||
Edit `engine/render.py`, find and delete lines 20-49 (the hardcoded gradient arrays):
|
|
||||||
|
|
||||||
```python
|
|
||||||
# DELETED:
|
|
||||||
# GRAD_COLS = [...]
|
|
||||||
# MSG_GRAD_COLS = [...]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_config.py::test_active_theme_initially_none -v`
|
|
||||||
Run: `pytest tests/test_config.py::test_set_active_theme_green -v`
|
|
||||||
Run: `pytest tests/test_config.py::test_set_active_theme_default -v`
|
|
||||||
Run: `pytest tests/test_config.py::test_set_active_theme_invalid -v`
|
|
||||||
|
|
||||||
Expected: PASS (all 4 new tests)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Verify existing config tests still pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_config.py -v`
|
|
||||||
|
|
||||||
Expected: PASS (all existing + new tests)
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/config.py tests/test_config.py
|
|
||||||
git commit -m "feat: add ACTIVE_THEME global and set_active_theme() to config
|
|
||||||
|
|
||||||
- Add ACTIVE_THEME global (initialized to None)
|
|
||||||
- Add set_active_theme(theme_id) function with green default
|
|
||||||
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS (move to themes.py)
|
|
||||||
- Add comprehensive tests for theme setting"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 3: Render Pipeline Integration
|
|
||||||
|
|
||||||
### Task 3: Update render.py to use config.ACTIVE_THEME
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/render.py:15-220`
|
|
||||||
- Test: `tests/test_render.py` (expand existing)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing test for lr_gradient with theme**
|
|
||||||
|
|
||||||
Add to `tests/test_render.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_lr_gradient_uses_active_theme(monkeypatch):
|
|
||||||
"""lr_gradient uses config.ACTIVE_THEME when cols=None."""
|
|
||||||
from engine import config, render
|
|
||||||
from engine.themes import get_theme
|
|
||||||
|
|
||||||
# Set orange theme
|
|
||||||
config.set_active_theme("orange")
|
|
||||||
|
|
||||||
# Create simple rows
|
|
||||||
rows = ["test row"]
|
|
||||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
|
||||||
|
|
||||||
# Result should start with first color from orange main gradient
|
|
||||||
assert result[0].startswith("\033[1;38;5;231m") # white (same for all)
|
|
||||||
|
|
||||||
|
|
||||||
def test_lr_gradient_fallback_when_no_theme(monkeypatch):
|
|
||||||
"""lr_gradient uses fallback when ACTIVE_THEME is None."""
|
|
||||||
from engine import config, render
|
|
||||||
|
|
||||||
# Clear active theme
|
|
||||||
config.ACTIVE_THEME = None
|
|
||||||
|
|
||||||
rows = ["test row"]
|
|
||||||
result = render.lr_gradient(rows, offset=0, cols=None)
|
|
||||||
|
|
||||||
# Should not crash and should return something
|
|
||||||
assert result is not None
|
|
||||||
assert len(result) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_green_gradient_length():
|
|
||||||
"""_default_green_gradient returns 12 colors."""
|
|
||||||
from engine import render
|
|
||||||
|
|
||||||
colors = render._default_green_gradient()
|
|
||||||
assert len(colors) == 12
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
|
||||||
Expected: FAIL (function signature doesn't match)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update lr_gradient() to use config.ACTIVE_THEME**
|
|
||||||
|
|
||||||
Edit `engine/render.py`, find the `lr_gradient()` function (around line 194) and update it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def lr_gradient(rows, offset, cols=None):
|
|
||||||
"""
|
|
||||||
Render rows through a left-to-right color sweep.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rows: List of text rows to colorize
|
|
||||||
offset: Gradient position offset (for animation)
|
|
||||||
cols: Optional list of color codes. If None, uses active theme.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of colorized rows
|
|
||||||
"""
|
|
||||||
if cols is None:
|
|
||||||
from engine import config
|
|
||||||
cols = (
|
|
||||||
config.ACTIVE_THEME.main_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_green_gradient()
|
|
||||||
)
|
|
||||||
|
|
||||||
# ... rest of function unchanged ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add _default_green_gradient() fallback function**
|
|
||||||
|
|
||||||
Add to `engine/render.py` before `lr_gradient()`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _default_green_gradient():
|
|
||||||
"""Fallback green gradient (original colors) for initialization."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale white-tint
|
|
||||||
"\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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _default_magenta_gradient():
|
|
||||||
"""Fallback magenta gradient (original message colors) for initialization."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\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
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_render.py::test_lr_gradient_uses_active_theme -v`
|
|
||||||
Run: `pytest tests/test_render.py::test_lr_gradient_fallback_when_no_theme -v`
|
|
||||||
Run: `pytest tests/test_render.py::test_default_green_gradient_length -v`
|
|
||||||
|
|
||||||
Expected: PASS (all 3 new tests)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run full render test suite**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_render.py -v`
|
|
||||||
|
|
||||||
Expected: PASS (existing tests may need adjustment for mocking)
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/render.py tests/test_render.py
|
|
||||||
git commit -m "feat: update lr_gradient to use config.ACTIVE_THEME
|
|
||||||
|
|
||||||
- Update lr_gradient(cols=None) to check config.ACTIVE_THEME
|
|
||||||
- Add _default_green_gradient() and _default_magenta_gradient() fallbacks
|
|
||||||
- Fallback used when ACTIVE_THEME is None (non-interactive init)
|
|
||||||
- Add tests for theme-aware and fallback gradient rendering"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 4: Message Gradient Integration
|
|
||||||
|
|
||||||
### Task 4: Update scroll.py to use message gradient from config
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/scroll.py:85-95`
|
|
||||||
- Test: existing `tests/test_scroll.py`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Locate message gradient calls in scroll.py**
|
|
||||||
|
|
||||||
Run: `grep -n "MSG_GRAD_COLS\|lr_gradient_opposite" /Users/genejohnson/Dev/mainline/engine/scroll.py`
|
|
||||||
|
|
||||||
Expected: Should find line(s) where `MSG_GRAD_COLS` or similar is used
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update scroll.py to use theme message gradient**
|
|
||||||
|
|
||||||
Edit `engine/scroll.py`, find the line that uses message gradients (around line 89 based on spec) and update:
|
|
||||||
|
|
||||||
Old code:
|
|
||||||
```python
|
|
||||||
# Some variation of:
|
|
||||||
rows = lr_gradient(rows, offset, MSG_GRAD_COLS)
|
|
||||||
```
|
|
||||||
|
|
||||||
New code:
|
|
||||||
```python
|
|
||||||
from engine import config
|
|
||||||
msg_cols = (
|
|
||||||
config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else render._default_magenta_gradient()
|
|
||||||
)
|
|
||||||
rows = lr_gradient(rows, offset, msg_cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use the helper approach (create `msg_gradient()` in render.py):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def msg_gradient(rows, offset):
|
|
||||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
|
||||||
from engine import config
|
|
||||||
cols = (
|
|
||||||
config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient()
|
|
||||||
)
|
|
||||||
return lr_gradient(rows, offset, cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
Then in scroll.py:
|
|
||||||
```python
|
|
||||||
rows = render.msg_gradient(rows, offset)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run existing scroll tests**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_scroll.py -v`
|
|
||||||
|
|
||||||
Expected: PASS (existing functionality unchanged)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/scroll.py engine/render.py
|
|
||||||
git commit -m "feat: update scroll.py to use theme message gradient
|
|
||||||
|
|
||||||
- Replace MSG_GRAD_COLS reference with config.ACTIVE_THEME.message_gradient
|
|
||||||
- Use fallback magenta gradient when theme not initialized
|
|
||||||
- Ensure ntfy messages render in complementary color from selected theme"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 5: Color Picker UI
|
|
||||||
|
|
||||||
### Task 5: Create pick_color_theme() function in app.py
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `engine/app.py:1-300`
|
|
||||||
- Test: manual/integration (interactive)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write helper functions for color picker UI**
|
|
||||||
|
|
||||||
Edit `engine/app.py`, add before `pick_font_face()` function:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _draw_color_picker(themes_list, selected):
|
|
||||||
"""Draw the color theme picker menu."""
|
|
||||||
import sys
|
|
||||||
from engine.terminal import CLR, W_GHOST, G_HI, G_DIM, tw
|
|
||||||
|
|
||||||
print(CLR, end="")
|
|
||||||
print()
|
|
||||||
print(f" {G_HI}▼ COLOR THEME{W_GHOST} ─ ↑/↓ or j/k to move, Enter/q to select{G_DIM}")
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}\n")
|
|
||||||
|
|
||||||
for i, (theme_id, theme) in enumerate(themes_list):
|
|
||||||
prefix = " ▶ " if i == selected else " "
|
|
||||||
color = G_HI if i == selected else ""
|
|
||||||
reset = "" if i == selected else W_GHOST
|
|
||||||
print(f"{prefix}{color}{theme.name}{reset}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create pick_color_theme() function**
|
|
||||||
|
|
||||||
Edit `engine/app.py`, add after helper function:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def pick_color_theme():
|
|
||||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
|
||||||
import sys
|
|
||||||
import termios
|
|
||||||
import tty
|
|
||||||
from engine import config, themes
|
|
||||||
|
|
||||||
# Non-interactive fallback: use green
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
config.set_active_theme("green")
|
|
||||||
return
|
|
||||||
|
|
||||||
themes_list = list(themes.THEME_REGISTRY.items())
|
|
||||||
selected = 0
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setcbreak(fd)
|
|
||||||
while True:
|
|
||||||
_draw_color_picker(themes_list, selected)
|
|
||||||
key = _read_picker_key()
|
|
||||||
if key == "up":
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
elif key == "down":
|
|
||||||
selected = min(len(themes_list) - 1, selected + 1)
|
|
||||||
elif key == "enter":
|
|
||||||
break
|
|
||||||
elif key == "interrupt":
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
selected_theme_id = themes_list[selected][0]
|
|
||||||
config.set_active_theme(selected_theme_id)
|
|
||||||
|
|
||||||
theme_name = themes_list[selected][1].name
|
|
||||||
print(f" {G_DIM}> using {theme_name}{RST}")
|
|
||||||
time.sleep(0.8)
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update main() to call pick_color_theme() before pick_font_face()**
|
|
||||||
|
|
||||||
Edit `engine/app.py`, find the `main()` function and locate where `pick_font_face()` is called (around line 265). Add before it:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def main():
|
|
||||||
# ... existing signal handler setup ...
|
|
||||||
|
|
||||||
pick_color_theme() # NEW LINE - before font picker
|
|
||||||
pick_font_face()
|
|
||||||
|
|
||||||
# ... rest of main unchanged ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual test - run in interactive terminal**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- See color theme picker menu before font picker
|
|
||||||
- Can navigate with ↑/↓ or j/k
|
|
||||||
- Can select with Enter or q
|
|
||||||
- Selected theme applies to scrolling headlines
|
|
||||||
- Can select different themes and see colors change
|
|
||||||
|
|
||||||
- [ ] **Step 5: Manual test - run in non-interactive environment**
|
|
||||||
|
|
||||||
Run: `echo "" | python3 mainline.py`
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- No color picker menu shown
|
|
||||||
- Defaults to green theme
|
|
||||||
- App runs without error
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add engine/app.py
|
|
||||||
git commit -m "feat: add pick_color_theme() UI and integration
|
|
||||||
|
|
||||||
- Create _draw_color_picker() to render menu
|
|
||||||
- Create pick_color_theme() function mirroring font picker pattern
|
|
||||||
- Integrate into main() before font picker
|
|
||||||
- Fallback to green theme in non-interactive environments
|
|
||||||
- Support arrow keys and j/k navigation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Chunk 6: Integration & Validation
|
|
||||||
|
|
||||||
### Task 6: End-to-end testing and cleanup
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: All modified files
|
|
||||||
- Verify: App functionality
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run full test suite**
|
|
||||||
|
|
||||||
Run: `pytest tests/ -v`
|
|
||||||
|
|
||||||
Expected: PASS (all tests, including new ones)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run linter**
|
|
||||||
|
|
||||||
Run: `ruff check engine/ mainline.py`
|
|
||||||
|
|
||||||
Expected: No errors (fix any style issues)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Manual integration test - green theme**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Then select "Verdant Green" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Headlines render in green → deep green
|
|
||||||
- ntfy messages render in magenta gradient
|
|
||||||
- Both work correctly during streaming
|
|
||||||
|
|
||||||
- [ ] **Step 4: Manual integration test - orange theme**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Then select "Molten Orange" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Headlines render in orange → deep orange
|
|
||||||
- ntfy messages render in blue gradient
|
|
||||||
- Colors are visually distinct from green
|
|
||||||
|
|
||||||
- [ ] **Step 5: Manual integration test - purple theme**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py`
|
|
||||||
|
|
||||||
Then select "Violet Purple" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Headlines render in purple → deep purple
|
|
||||||
- ntfy messages render in yellow gradient
|
|
||||||
- Colors are visually distinct from green and orange
|
|
||||||
|
|
||||||
- [ ] **Step 6: Test poetry mode with color picker**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py --poetry`
|
|
||||||
|
|
||||||
Then select "orange" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Poetry mode works with color picker
|
|
||||||
- Colors apply to poetry rendering
|
|
||||||
|
|
||||||
- [ ] **Step 7: Test code mode with color picker**
|
|
||||||
|
|
||||||
Run: `python3 mainline.py --code`
|
|
||||||
|
|
||||||
Then select "purple" from picker.
|
|
||||||
|
|
||||||
Expected:
|
|
||||||
- Code mode works with color picker
|
|
||||||
- Colors apply to code rendering
|
|
||||||
|
|
||||||
- [ ] **Step 8: Verify acceptance criteria**
|
|
||||||
|
|
||||||
✓ Color picker displays 3 theme options at startup
|
|
||||||
✓ Selection applies to all headline and message gradients
|
|
||||||
✓ Boot UI (title, status) uses hardcoded green (not theme)
|
|
||||||
✓ Scrolling headlines and ntfy messages use theme gradients
|
|
||||||
✓ No persistence between runs (each run picks fresh)
|
|
||||||
✓ Non-TTY environments default to green without error
|
|
||||||
✓ Architecture supports future random/animation modes
|
|
||||||
✓ All gradient color codes finalized with no TBD values
|
|
||||||
|
|
||||||
- [ ] **Step 9: Final commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "feat: color scheme switcher implementation complete
|
|
||||||
|
|
||||||
Closes color-pick feature with:
|
|
||||||
- Three selectable color themes (green, orange, purple)
|
|
||||||
- Interactive menu at startup (mirrors font picker UI)
|
|
||||||
- Complementary colors for ntfy message queue
|
|
||||||
- Fallback to green in non-interactive environments
|
|
||||||
- All tests passing, manual validation complete"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 10: Create feature branch PR summary**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Color Scheme Switcher
|
|
||||||
|
|
||||||
Implements interactive color theme selection for Mainline news ticker.
|
|
||||||
|
|
||||||
### What's New
|
|
||||||
- 3 color themes: Verdant Green, Molten Orange, Violet Purple
|
|
||||||
- Interactive picker at startup (↑/↓ or j/k, Enter to select)
|
|
||||||
- Complementary gradients for ntfy messages (magenta, blue, yellow)
|
|
||||||
- Fresh theme selection each run (no persistence)
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- `engine/themes.py` (new)
|
|
||||||
- `engine/config.py` (ACTIVE_THEME, set_active_theme)
|
|
||||||
- `engine/render.py` (theme-aware gradients)
|
|
||||||
- `engine/scroll.py` (message gradient integration)
|
|
||||||
- `engine/app.py` (pick_color_theme UI)
|
|
||||||
- `tests/test_themes.py` (new theme tests)
|
|
||||||
- `README.md` (documentation)
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
All met. App fully tested and ready for merge.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Unit tests: `pytest tests/test_themes.py -v`
|
|
||||||
- [ ] Unit tests: `pytest tests/test_config.py -v`
|
|
||||||
- [ ] Unit tests: `pytest tests/test_render.py -v`
|
|
||||||
- [ ] Full suite: `pytest tests/ -v`
|
|
||||||
- [ ] Linting: `ruff check engine/ mainline.py`
|
|
||||||
- [ ] Manual: Green theme selection
|
|
||||||
- [ ] Manual: Orange theme selection
|
|
||||||
- [ ] Manual: Purple theme selection
|
|
||||||
- [ ] Manual: Poetry mode with colors
|
|
||||||
- [ ] Manual: Code mode with colors
|
|
||||||
- [ ] Manual: Non-TTY fallback
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `themes.py` is data-only; never import config or render to prevent cycles
|
|
||||||
- `ACTIVE_THEME` initialized to None; guaranteed non-None before stream() via pick_color_theme()
|
|
||||||
- Font picker UI remains hardcoded green; title/subtitle use G_HI/G_DIM constants (not theme)
|
|
||||||
- Message gradients use complementary colors; lookup in scroll.py
|
|
||||||
- Each gradient has 12 colors; verify length in tests
|
|
||||||
- No persistence; fresh picker each run
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,154 +0,0 @@
|
|||||||
# Code Scroll Mode — Design Spec
|
|
||||||
|
|
||||||
**Date:** 2026-03-16
|
|
||||||
**Branch:** feat/code-scroll
|
|
||||||
**Status:** Approved
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Add a `--code` CLI flag that puts MAINLINE into "source consciousness" mode. Instead of RSS headlines or poetry stanzas, the program's own source code scrolls upward as large OTF half-block characters with the standard white-hot → deep green gradient. Each scroll item is one non-blank, non-comment line from `engine/*.py`, attributed to its enclosing function/class scope and dotted module path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Mirror the existing `--poetry` mode pattern as closely as possible
|
|
||||||
- Zero new runtime dependencies (stdlib `ast` and `pathlib` only)
|
|
||||||
- No changes to `scroll.py` or the render pipeline
|
|
||||||
- The item tuple shape `(text, src, ts)` is unchanged
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Files
|
|
||||||
|
|
||||||
### `engine/fetch_code.py`
|
|
||||||
|
|
||||||
Single public function `fetch_code()` that returns `(items, line_count, 0)`.
|
|
||||||
|
|
||||||
**Algorithm:**
|
|
||||||
|
|
||||||
1. Glob `engine/*.py` in sorted order
|
|
||||||
2. For each file:
|
|
||||||
a. Read source text
|
|
||||||
b. `ast.parse(source)` → build a `{line_number: scope_label}` map by walking all `FunctionDef`, `AsyncFunctionDef`, and `ClassDef` nodes. Each node covers its full line range. Inner scopes override outer ones.
|
|
||||||
c. Iterate source lines (1-indexed). Skip if:
|
|
||||||
- The stripped line is empty
|
|
||||||
- The stripped line starts with `#`
|
|
||||||
d. For each kept line emit:
|
|
||||||
- `text` = `line.rstrip()` (preserve indentation for readability in the big render)
|
|
||||||
- `src` = scope label from the AST map, e.g. `stream()` for functions, `MicMonitor` for classes, `<module>` for top-level lines
|
|
||||||
- `ts` = dotted module path derived from filename, e.g. `engine/scroll.py` → `engine.scroll`
|
|
||||||
3. Return `(items, len(items), 0)`
|
|
||||||
|
|
||||||
**Scope label rules:**
|
|
||||||
- `FunctionDef` / `AsyncFunctionDef` → `name()`
|
|
||||||
- `ClassDef` → `name` (no parens)
|
|
||||||
- No enclosing node → `<module>`
|
|
||||||
|
|
||||||
**Dependencies:** `ast`, `pathlib` — stdlib only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modified Files
|
|
||||||
|
|
||||||
### `engine/config.py`
|
|
||||||
|
|
||||||
Extend `MODE` detection to recognise `--code`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
MODE = (
|
|
||||||
"poetry" if "--poetry" in sys.argv or "-p" in sys.argv
|
|
||||||
else "code" if "--code" in sys.argv
|
|
||||||
else "news"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `engine/app.py`
|
|
||||||
|
|
||||||
**Subtitle line** — extend the subtitle dict:
|
|
||||||
|
|
||||||
```python
|
|
||||||
_subtitle = {
|
|
||||||
"poetry": "literary consciousness stream",
|
|
||||||
"code": "source consciousness stream",
|
|
||||||
}.get(config.MODE, "digital consciousness stream")
|
|
||||||
```
|
|
||||||
|
|
||||||
**Boot sequence** — add `elif config.MODE == "code":` branch after the poetry branch:
|
|
||||||
|
|
||||||
```python
|
|
||||||
elif config.MODE == "code":
|
|
||||||
from engine.fetch_code import fetch_code
|
|
||||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
print()
|
|
||||||
items, line_count, _ = fetch_code()
|
|
||||||
print()
|
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
|
||||||
```
|
|
||||||
|
|
||||||
No cache save/load — local source files are read instantly and change only on disk writes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
engine/*.py (sorted)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
fetch_code()
|
|
||||||
│ ast.parse → scope map
|
|
||||||
│ filter blank + comment lines
|
|
||||||
│ emit (line, scope(), engine.module)
|
|
||||||
▼
|
|
||||||
items: List[Tuple[str, str, str]]
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
stream(items, ntfy, mic) ← unchanged
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
next_headline() shuffles + recycles automatically
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- If a file fails to `ast.parse` (malformed source), fall back to `<module>` scope for all lines in that file — do not crash.
|
|
||||||
- If `engine/` contains no `.py` files (shouldn't happen in practice), `fetch_code()` returns an empty list; `app.py`'s existing `if not items:` guard handles this.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
New file: `tests/test_fetch_code.py`
|
|
||||||
|
|
||||||
| Test | Assertion |
|
|
||||||
|------|-----------|
|
|
||||||
| `test_items_are_tuples` | Every item from `fetch_code()` is a 3-tuple of strings |
|
|
||||||
| `test_blank_and_comment_lines_excluded` | No item text is empty; no item text (stripped) starts with `#` |
|
|
||||||
| `test_module_path_format` | Every `ts` field matches pattern `engine\.\w+` |
|
|
||||||
|
|
||||||
No mocking — tests read the real engine source files, keeping them honest against actual content.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CLI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 mainline.py --code # source consciousness mode
|
|
||||||
uv run mainline.py --code
|
|
||||||
```
|
|
||||||
|
|
||||||
Compatible with all existing flags (`--no-font-picker`, `--font-file`, `--firehose`, etc.).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Syntax highlighting / token-aware coloring (can be added later)
|
|
||||||
- `--code-dir` flag for pointing at arbitrary directories (YAGNI)
|
|
||||||
- Caching code items to disk
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
# Color Scheme Switcher Design
|
|
||||||
|
|
||||||
**Date:** 2026-03-16
|
|
||||||
**Status:** Revised after review
|
|
||||||
**Scope:** Interactive color theme selection for Mainline news ticker
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Mainline currently renders news headlines with a fixed white-hot → deep green gradient. This feature adds an interactive theme picker at startup that lets users choose between three precise color schemes (green, orange, purple), each with complementary message queue colors.
|
|
||||||
|
|
||||||
The implementation uses a dedicated `Theme` class to encapsulate gradients and metadata, enabling future extensions like random rotation, animation, or additional themes without architectural changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
**Functional:**
|
|
||||||
1. User selects a color theme from an interactive menu at startup (green, orange, or purple)
|
|
||||||
2. Main headline gradient uses the selected primary color (white → color)
|
|
||||||
3. Message queue (ntfy) gradient uses the precise complementary color (white → opposite)
|
|
||||||
4. Selection is fresh each run (no persistence)
|
|
||||||
5. Design supports future "random rotation" mode without refactoring
|
|
||||||
|
|
||||||
**Complementary colors (precise opposites):**
|
|
||||||
- Green (38;5;22) → Magenta (38;5;89) *(current, unchanged)*
|
|
||||||
- Orange (38;5;208) → Blue (38;5;21)
|
|
||||||
- Purple (38;5;129) → Yellow (38;5;226)
|
|
||||||
|
|
||||||
**Non-functional:**
|
|
||||||
- Reuse the existing font picker pattern for UI consistency
|
|
||||||
- Zero runtime overhead during streaming (theme lookup happens once at startup)
|
|
||||||
- **Boot UI (title, subtitle, status lines) use hardcoded green color constants (G_HI, G_DIM, G_MID); only scrolling headlines and ntfy messages use theme gradients**
|
|
||||||
- Font picker UI remains hardcoded green for visual continuity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### New Module: `engine/themes.py`
|
|
||||||
|
|
||||||
**Data-only module:** Contains Theme class, THEME_REGISTRY, and get_theme() function. **Imports only typing; does NOT import config or render** to prevent circular dependencies.
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Theme:
|
|
||||||
"""Encapsulates a color scheme: name, main gradient, message gradient."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, main_gradient: list[str], message_gradient: list[str]):
|
|
||||||
self.name = name
|
|
||||||
self.main_gradient = main_gradient # white → primary color
|
|
||||||
self.message_gradient = message_gradient # white → complementary
|
|
||||||
```
|
|
||||||
|
|
||||||
**Theme Registry:**
|
|
||||||
Three instances registered by ID: `"green"`, `"orange"`, `"purple"` (IDs match menu labels for clarity).
|
|
||||||
|
|
||||||
Each gradient is a list of 12 ANSI 256-color codes matching the current green gradient:
|
|
||||||
```
|
|
||||||
[
|
|
||||||
"\033[1;38;5;231m", # white (bold)
|
|
||||||
"\033[1;38;5;195m", # pale white-tint
|
|
||||||
"\033[38;5;123m", # bright cyan
|
|
||||||
"\033[38;5;118m", # bright lime
|
|
||||||
"\033[38;5;82m", # lime
|
|
||||||
"\033[38;5;46m", # bright color
|
|
||||||
"\033[38;5;40m", # color
|
|
||||||
"\033[38;5;34m", # medium color
|
|
||||||
"\033[38;5;28m", # dark color
|
|
||||||
"\033[38;5;22m", # deep color
|
|
||||||
"\033[2;38;5;22m", # dim deep color
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Finalized color codes:**
|
|
||||||
|
|
||||||
**Green (primary: 22, complementary: 89)** — unchanged from current
|
|
||||||
- Main: `[231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22(dim), 235]`
|
|
||||||
- Messages: `[231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89(dim), 235]`
|
|
||||||
|
|
||||||
**Orange (primary: 208, complementary: 21)**
|
|
||||||
- Main: `[231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94(dim), 235]`
|
|
||||||
- Messages: `[231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18(dim), 235]`
|
|
||||||
|
|
||||||
**Purple (primary: 129, complementary: 226)**
|
|
||||||
- Main: `[231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57(dim), 235]`
|
|
||||||
- Messages: `[231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172(dim), 235]`
|
|
||||||
|
|
||||||
**Public API:**
|
|
||||||
- `get_theme(theme_id: str) -> Theme` — lookup by ID, raises KeyError if not found
|
|
||||||
- `THEME_REGISTRY` — dict of all available themes (for picker)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modified: `engine/config.py`
|
|
||||||
|
|
||||||
**New globals:**
|
|
||||||
```python
|
|
||||||
ACTIVE_THEME = None # set by set_active_theme() after picker; guaranteed non-None during stream()
|
|
||||||
```
|
|
||||||
|
|
||||||
**New function:**
|
|
||||||
```python
|
|
||||||
def set_active_theme(theme_id: str = "green"):
|
|
||||||
"""Set the active theme. Defaults to 'green' if not specified."""
|
|
||||||
global ACTIVE_THEME
|
|
||||||
from engine import themes
|
|
||||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Behavior:**
|
|
||||||
- Called by `app.pick_color_theme()` with user selection
|
|
||||||
- Has default fallback to "green" for non-interactive environments (CI, testing, piped stdin)
|
|
||||||
- Guarantees `ACTIVE_THEME` is set before any render functions are called
|
|
||||||
|
|
||||||
**Removal:**
|
|
||||||
- Delete hardcoded `GRAD_COLS` and `MSG_GRAD_COLS` constants
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modified: `engine/render.py`
|
|
||||||
|
|
||||||
**Updated gradient access in existing functions:**
|
|
||||||
|
|
||||||
Current pattern (will be removed):
|
|
||||||
```python
|
|
||||||
GRAD_COLS = [...] # hardcoded green
|
|
||||||
MSG_GRAD_COLS = [...] # hardcoded magenta
|
|
||||||
```
|
|
||||||
|
|
||||||
New pattern — update `lr_gradient()` function:
|
|
||||||
```python
|
|
||||||
def lr_gradient(rows, offset, cols=None):
|
|
||||||
if cols is None:
|
|
||||||
from engine import config
|
|
||||||
cols = (config.ACTIVE_THEME.main_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_green_gradient())
|
|
||||||
# ... rest of function unchanged
|
|
||||||
```
|
|
||||||
|
|
||||||
**Define fallback:**
|
|
||||||
```python
|
|
||||||
def _default_green_gradient():
|
|
||||||
"""Fallback green gradient (current colors)."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", "\033[1;38;5;195m", "\033[38;5;123m",
|
|
||||||
"\033[38;5;118m", "\033[38;5;82m", "\033[38;5;46m",
|
|
||||||
"\033[38;5;40m", "\033[38;5;34m", "\033[38;5;28m",
|
|
||||||
"\033[38;5;22m", "\033[2;38;5;22m", "\033[2;38;5;235m",
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Message gradient handling:**
|
|
||||||
|
|
||||||
The existing code (scroll.py line 89) calls `lr_gradient()` with `MSG_GRAD_COLS`. Change this call to:
|
|
||||||
```python
|
|
||||||
# Instead of: lr_gradient(rows, offset, MSG_GRAD_COLS)
|
|
||||||
# Use:
|
|
||||||
from engine import config
|
|
||||||
cols = (config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient())
|
|
||||||
lr_gradient(rows, offset, cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
or define a helper:
|
|
||||||
```python
|
|
||||||
def msg_gradient(rows, offset):
|
|
||||||
"""Apply message (ntfy) gradient using theme complementary colors."""
|
|
||||||
from engine import config
|
|
||||||
cols = (config.ACTIVE_THEME.message_gradient
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient())
|
|
||||||
return lr_gradient(rows, offset, cols)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Modified: `engine/app.py`
|
|
||||||
|
|
||||||
**New function: `pick_color_theme()`**
|
|
||||||
|
|
||||||
Mirrors `pick_font_face()` pattern:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def pick_color_theme():
|
|
||||||
"""Interactive color theme picker. Defaults to 'green' if not TTY."""
|
|
||||||
import sys
|
|
||||||
from engine import config, themes
|
|
||||||
|
|
||||||
# Non-interactive fallback: use default
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
config.set_active_theme("green")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Interactive picker (similar to font picker)
|
|
||||||
themes_list = list(themes.THEME_REGISTRY.items())
|
|
||||||
selected = 0
|
|
||||||
|
|
||||||
# ... render menu, handle arrow keys j/k, ↑/↓ ...
|
|
||||||
# ... on Enter, call config.set_active_theme(themes_list[selected][0]) ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Placement in `main()`:**
|
|
||||||
```python
|
|
||||||
def main():
|
|
||||||
# ... signal handler setup ...
|
|
||||||
pick_color_theme() # NEW — before title/subtitle
|
|
||||||
pick_font_face()
|
|
||||||
# ... rest of boot sequence, title/subtitle use hardcoded G_HI/G_DIM ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** The title and subtitle render with hardcoded `G_HI`/`G_DIM` constants, not theme gradients. This is intentional for visual consistency with the font picker menu.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
User starts: mainline.py
|
|
||||||
↓
|
|
||||||
main() called
|
|
||||||
↓
|
|
||||||
pick_color_theme()
|
|
||||||
→ If TTY: display menu, read input, call config.set_active_theme(user_choice)
|
|
||||||
→ If not TTY: silently call config.set_active_theme("green")
|
|
||||||
↓
|
|
||||||
pick_font_face() — renders in hardcoded green UI colors
|
|
||||||
↓
|
|
||||||
Boot messages (title, status) — all use hardcoded G_HI/G_DIM (not theme gradients)
|
|
||||||
↓
|
|
||||||
stream() — headlines + ntfy messages use config.ACTIVE_THEME gradients
|
|
||||||
↓
|
|
||||||
On exit: no persistence
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
### Initialization Guarantee
|
|
||||||
`config.ACTIVE_THEME` is guaranteed to be non-None before `stream()` is called because:
|
|
||||||
1. `pick_color_theme()` always sets it (either interactively or via fallback)
|
|
||||||
2. It's called before any rendering happens
|
|
||||||
3. Default fallback ensures non-TTY environments don't crash
|
|
||||||
|
|
||||||
### Module Independence
|
|
||||||
`themes.py` is a pure data module with no imports of `config` or `render`. This prevents circular dependencies and allows it to be imported by multiple consumers without side effects.
|
|
||||||
|
|
||||||
### Color Code Finalization
|
|
||||||
All three gradient sequences (green, orange, purple main + complementary) are now finalized with specific ANSI codes. No TBD placeholders remain.
|
|
||||||
|
|
||||||
### Theme ID Naming
|
|
||||||
IDs are `"green"`, `"orange"`, `"purple"` — matching the menu labels exactly for clarity.
|
|
||||||
|
|
||||||
### Terminal Resize Handling
|
|
||||||
The `pick_color_theme()` function mirrors `pick_font_face()`, which does not handle terminal resizing during the picker display. If the terminal is resized while the picker menu is shown, the menu redraw may be incomplete; pressing any key (arrow, j/k, q) continues normally. This is acceptable because:
|
|
||||||
1. The picker completes quickly (< 5 seconds typical interaction)
|
|
||||||
2. Once a theme is selected, the menu closes and rendering begins
|
|
||||||
3. The streaming phase (`stream()`) is resilient to terminal resizing and auto-reflows to new dimensions
|
|
||||||
|
|
||||||
No special resize handling is needed for the color picker beyond what exists for the font picker.
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
1. **Unit tests** (`tests/test_themes.py`):
|
|
||||||
- Verify Theme class construction
|
|
||||||
- Test THEME_REGISTRY lookup (valid and invalid IDs)
|
|
||||||
- Confirm gradient lists have correct length (12)
|
|
||||||
|
|
||||||
2. **Integration tests** (`tests/test_render.py`):
|
|
||||||
- Mock `config.ACTIVE_THEME` to each theme
|
|
||||||
- Verify `lr_gradient()` uses correct colors
|
|
||||||
- Verify fallback works when `ACTIVE_THEME` is None
|
|
||||||
|
|
||||||
3. **Existing tests:**
|
|
||||||
- Render tests that check gradient output will need to mock `config.ACTIVE_THEME`
|
|
||||||
- Use pytest fixtures to set theme per test case
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
- `engine/themes.py` (new)
|
|
||||||
- `engine/config.py` (add `ACTIVE_THEME`, `set_active_theme()`)
|
|
||||||
- `engine/render.py` (replace GRAD_COLS/MSG_GRAD_COLS references with config lookups)
|
|
||||||
- `engine/app.py` (add `pick_color_theme()`, call in main)
|
|
||||||
- `tests/test_themes.py` (new unit tests)
|
|
||||||
- `tests/test_render.py` (update mocking strategy)
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
1. ✓ Color picker displays 3 theme options at startup
|
|
||||||
2. ✓ Selection applies to all headline and message gradients
|
|
||||||
3. ✓ Boot UI (title, status) uses hardcoded green (not theme)
|
|
||||||
4. ✓ Scrolling headlines and ntfy messages use theme gradients
|
|
||||||
5. ✓ No persistence between runs
|
|
||||||
6. ✓ Non-TTY environments default to green without error
|
|
||||||
7. ✓ Architecture supports future random/animation modes
|
|
||||||
8. ✓ All gradient color codes finalized with no TBD values
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
# Figment Mode Design Spec
|
|
||||||
|
|
||||||
> Periodic full-screen SVG glyph overlay with flickery animation, theme-aware coloring, and extensible physical device control.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Figment mode displays a randomly selected SVG from the `figments/` directory as a flickery, glitchy half-block terminal overlay on top of the running ticker. It appears once per minute (configurable), holds for ~4.5 seconds with a three-phase animation (progressive reveal, strobing hold, dissolve), then fades back to the ticker. Colors are randomly chosen from the existing theme gradients.
|
|
||||||
|
|
||||||
The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Display SVG figments as half-block terminal art overlaid on the running ticker
|
|
||||||
- Three-phase animation: progressive reveal, strobing hold, dissolve
|
|
||||||
- Random color from existing theme gradients (green, orange, purple)
|
|
||||||
- Configurable interval and duration via C&C
|
|
||||||
- Extensible input abstraction for physical device control (MQTT, serial, etc.)
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- Multi-figment simultaneous display (one at a time)
|
|
||||||
- SVG animation support (static SVGs only; animation comes from the overlay phases)
|
|
||||||
- Custom color palettes beyond existing themes
|
|
||||||
- MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters)
|
|
||||||
|
|
||||||
## Architecture: Hybrid Plugin + Overlay
|
|
||||||
|
|
||||||
The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management.
|
|
||||||
|
|
||||||
**Important**: The plugin class is named `FigmentEffect` (not `FigmentPlugin`) to match the `*Effect` naming convention required by `discover_plugins()` in `effects_plugins/__init__.py`. The plugin is **not** added to the `EffectChain` order list — its `process()` is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in `scroll.py`, outside the chain.
|
|
||||||
|
|
||||||
### Component Diagram
|
|
||||||
|
|
||||||
```
|
|
||||||
+-------------------+
|
|
||||||
| FigmentTrigger | (Protocol)
|
|
||||||
| - NtfyTrigger | (v1)
|
|
||||||
| - MqttTrigger | (future)
|
|
||||||
| - SerialTrigger | (future)
|
|
||||||
+--------+----------+
|
|
||||||
|
|
|
||||||
| FigmentCommand
|
|
||||||
v
|
|
||||||
+------------------+ +-----------------+ +----------------------+
|
|
||||||
| figment_render |<---| FigmentEffect |--->| render_figment_ |
|
|
||||||
| .py | | (EffectPlugin) | | overlay() in |
|
|
||||||
| | | | | layers.py |
|
|
||||||
| SVG -> PIL -> | | Timer, state | | |
|
|
||||||
| half-block cache | | machine, SVG | | ANSI cursor-position |
|
|
||||||
| | | selection | | commands for overlay |
|
|
||||||
+------------------+ +-----------------+ +----------------------+
|
|
||||||
|
|
|
||||||
| get_figment_state()
|
|
||||||
v
|
|
||||||
+-------------------+
|
|
||||||
| scroll.py |
|
|
||||||
+-------------------+
|
|
||||||
```
|
|
||||||
|
|
||||||
## Section 1: SVG Rasterization
|
|
||||||
|
|
||||||
**File: `engine/figment_render.py`**
|
|
||||||
|
|
||||||
Reuses the same PIL-based half-block encoding that `engine/render.py` uses for OTF fonts.
|
|
||||||
|
|
||||||
### Pipeline
|
|
||||||
|
|
||||||
1. **Load**: `cairosvg.svg2png()` converts SVG to PNG bytes in memory (no temp files)
|
|
||||||
2. **Resize**: PIL scales to fit terminal — width = `tw()`, height = `th() * 2` pixels (each terminal row encodes 2 pixel rows via half-blocks)
|
|
||||||
3. **Threshold**: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible
|
|
||||||
4. **Half-block encode**: Walk pixel pairs top-to-bottom. For each 2-row pair, emit `█` (both lit), `▀` (top only), `▄` (bottom only), or space (neither)
|
|
||||||
5. **Cache**: Results cached per `(svg_path, terminal_width, terminal_height)` — invalidated on terminal resize
|
|
||||||
|
|
||||||
### Dependency
|
|
||||||
|
|
||||||
`cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). If `cairosvg` is not installed, the `FigmentEffect` class will fail to import, and `discover_plugins()` will silently skip it (the existing `except Exception: pass` in discovery handles this). The plugin simply won't appear in the registry.
|
|
||||||
|
|
||||||
### Key Function
|
|
||||||
|
|
||||||
```python
|
|
||||||
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
|
||||||
"""Convert SVG file to list of half-block terminal rows (uncolored)."""
|
|
||||||
```
|
|
||||||
|
|
||||||
## Section 2: Figment Overlay Rendering
|
|
||||||
|
|
||||||
**Integration point: `engine/layers.py`**
|
|
||||||
|
|
||||||
New function following the `render_message_overlay()` pattern.
|
|
||||||
|
|
||||||
### FigmentState Dataclass
|
|
||||||
|
|
||||||
Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@dataclass
|
|
||||||
class FigmentState:
|
|
||||||
phase: FigmentPhase # enum: REVEAL, HOLD, DISSOLVE
|
|
||||||
progress: float # 0.0 to 1.0 within current phase
|
|
||||||
rows: list[str] # rasterized half-block rows (uncolored)
|
|
||||||
gradient: list[int] # 12-color ANSI 256 gradient from chosen theme
|
|
||||||
center_row: int # top row for centering in viewport
|
|
||||||
center_col: int # left column for centering in viewport
|
|
||||||
```
|
|
||||||
|
|
||||||
### Function Signature
|
|
||||||
|
|
||||||
```python
|
|
||||||
def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
|
|
||||||
"""Return ANSI cursor-positioning commands for the current figment frame."""
|
|
||||||
```
|
|
||||||
|
|
||||||
### Animation Phases (~4.5 seconds total)
|
|
||||||
|
|
||||||
Progress advances each frame as: `progress += config.FRAME_DT / phase_duration`. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete.
|
|
||||||
|
|
||||||
| Phase | Duration | Behavior |
|
|
||||||
|-------|----------|----------|
|
|
||||||
| **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed. |
|
|
||||||
| **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency. |
|
|
||||||
| **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed. |
|
|
||||||
|
|
||||||
### Color
|
|
||||||
|
|
||||||
A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Applied via `lr_gradient()` — the same function that colors headlines and messages.
|
|
||||||
|
|
||||||
### Positioning
|
|
||||||
|
|
||||||
Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works.
|
|
||||||
|
|
||||||
## Section 3: FigmentEffect (Effect Plugin)
|
|
||||||
|
|
||||||
**File: `effects_plugins/figment.py`**
|
|
||||||
|
|
||||||
An `EffectPlugin(ABC)` subclass named `FigmentEffect` to match the `*Effect` discovery convention.
|
|
||||||
|
|
||||||
### Chain Exclusion
|
|
||||||
|
|
||||||
`FigmentEffect` is registered in the `EffectRegistry` (for C&C access and config management) but is **not** added to the `EffectChain` order list. Its `process()` returns the buffer unchanged. The `enabled` flag is checked directly by `scroll.py` when deciding whether to call `get_figment_state()`, not by the chain.
|
|
||||||
|
|
||||||
### Responsibilities
|
|
||||||
|
|
||||||
- **Timer**: Tracks elapsed time via `config.FRAME_DT` accumulation. At the configured interval (default 60s), triggers a new figment.
|
|
||||||
- **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown.
|
|
||||||
- **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0).
|
|
||||||
- **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time.
|
|
||||||
- **Rasterization**: Calls `rasterize_svg()` on trigger, caches result for the display duration.
|
|
||||||
|
|
||||||
### State Machine
|
|
||||||
|
|
||||||
```
|
|
||||||
idle ──(timer fires or trigger received)──> reveal
|
|
||||||
reveal ──(progress >= 1.0)──> hold
|
|
||||||
hold ──(progress >= 1.0)──> dissolve
|
|
||||||
dissolve ──(progress >= 1.0)──> idle
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interface
|
|
||||||
|
|
||||||
The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_figment_state(self, frame_number: int) -> FigmentState | None:
|
|
||||||
"""Tick the state machine and return current state, or None if idle."""
|
|
||||||
```
|
|
||||||
|
|
||||||
This mirrors the `ntfy_poller.get_active_message()` pattern.
|
|
||||||
|
|
||||||
### Scroll Loop Access
|
|
||||||
|
|
||||||
`scroll.py` imports `FigmentEffect` directly and uses `isinstance()` to safely downcast from the registry:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from effects_plugins.figment import FigmentEffect
|
|
||||||
|
|
||||||
plugin = registry.get("figment")
|
|
||||||
figment = plugin if isinstance(plugin, FigmentEffect) else None
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a one-time setup check, not per-frame. If `cairosvg` is missing, the import is wrapped in a try/except and `figment` stays `None`.
|
|
||||||
|
|
||||||
### EffectConfig
|
|
||||||
|
|
||||||
- `enabled`: bool (default `False` — opt-in)
|
|
||||||
- `intensity`: float — scales strobe frequency and reveal/dissolve speed
|
|
||||||
- `params`:
|
|
||||||
- `interval_secs`: 60 (time between figments)
|
|
||||||
- `display_secs`: 4.5 (total animation duration)
|
|
||||||
- `figment_dir`: "figments" (SVG source directory)
|
|
||||||
|
|
||||||
Controllable via C&C: `/effects figment on`, `/effects figment intensity 0.7`.
|
|
||||||
|
|
||||||
## Section 4: Input Abstraction (FigmentTrigger)
|
|
||||||
|
|
||||||
**File: `engine/figment_trigger.py`**
|
|
||||||
|
|
||||||
### Protocol
|
|
||||||
|
|
||||||
```python
|
|
||||||
class FigmentTrigger(Protocol):
|
|
||||||
def poll(self) -> FigmentCommand | None: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### FigmentCommand
|
|
||||||
|
|
||||||
```python
|
|
||||||
class FigmentAction(Enum):
|
|
||||||
TRIGGER = "trigger"
|
|
||||||
SET_INTENSITY = "set_intensity"
|
|
||||||
SET_INTERVAL = "set_interval"
|
|
||||||
SET_COLOR = "set_color"
|
|
||||||
STOP = "stop"
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FigmentCommand:
|
|
||||||
action: FigmentAction
|
|
||||||
value: float | str | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
Uses an enum for consistency with `EventType` in `engine/events.py`.
|
|
||||||
|
|
||||||
### Adapters
|
|
||||||
|
|
||||||
| Adapter | Transport | Dependency | Status |
|
|
||||||
|---------|-----------|------------|--------|
|
|
||||||
| `NtfyTrigger` | Existing C&C ntfy topic | None (reuses ntfy) | v1 |
|
|
||||||
| `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future |
|
|
||||||
| `SerialTrigger` | USB serial | `pyserial` (optional) | Future |
|
|
||||||
|
|
||||||
**NtfyTrigger v1**: Subscribes as a callback on the existing `NtfyPoller`. Parses messages with a `/figment` prefix (e.g., `/figment trigger`, `/figment intensity 0.8`). This is separate from the `/effects figment on` C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API.
|
|
||||||
|
|
||||||
### Integration
|
|
||||||
|
|
||||||
The `FigmentEffect` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone.
|
|
||||||
|
|
||||||
### EventBus Bridge
|
|
||||||
|
|
||||||
A new `FIGMENT_TRIGGER` variant is added to the `EventType` enum in `engine/events.py`, with a corresponding `FigmentTriggerEvent` dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync).
|
|
||||||
|
|
||||||
## Section 5: Scroll Loop Integration
|
|
||||||
|
|
||||||
Minimal change to `engine/scroll.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# In stream() setup (with safe import):
|
|
||||||
try:
|
|
||||||
from effects_plugins.figment import FigmentEffect
|
|
||||||
_plugin = registry.get("figment")
|
|
||||||
figment = _plugin if isinstance(_plugin, FigmentEffect) else None
|
|
||||||
except ImportError:
|
|
||||||
figment = None
|
|
||||||
|
|
||||||
# In frame loop, after effects processing, before ntfy message overlay:
|
|
||||||
if figment and figment.config.enabled:
|
|
||||||
figment_state = figment.get_figment_state(frame_number)
|
|
||||||
if figment_state is not None:
|
|
||||||
figment_overlay = render_figment_overlay(figment_state, w, h)
|
|
||||||
buf.extend(figment_overlay)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Overlay Priority
|
|
||||||
|
|
||||||
Figment overlay appends **after** effects processing but **before** the ntfy message overlay. This means:
|
|
||||||
- Ntfy messages always appear on top of figments (higher priority)
|
|
||||||
- Existing glitch/noise effects run over the ticker underneath the figment
|
|
||||||
|
|
||||||
Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering.
|
|
||||||
|
|
||||||
## Section 6: Error Handling
|
|
||||||
|
|
||||||
| Scenario | Behavior |
|
|
||||||
|----------|----------|
|
|
||||||
| `cairosvg` not installed | `FigmentEffect` fails to import; `discover_plugins()` silently skips it; `scroll.py` import guard sets `figment = None` |
|
|
||||||
| `figments/` directory missing | Plugin logs warning at startup, stays in permanent `idle` state |
|
|
||||||
| `figments/` contains zero `.svg` files | Same as above: warning, permanent `idle` |
|
|
||||||
| Malformed SVG | `cairosvg` raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent `idle` with warning |
|
|
||||||
| Terminal resize during animation | Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update |
|
|
||||||
|
|
||||||
## Section 7: File Summary
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `effects_plugins/figment.py` | FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase |
|
|
||||||
| `engine/figment_render.py` | SVG to half-block rasterization pipeline |
|
|
||||||
| `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter |
|
|
||||||
| `figments/` | SVG source directory (ships with sample SVGs) |
|
|
||||||
| `tests/test_figment.py` | FigmentEffect lifecycle, state machine transitions, timer |
|
|
||||||
| `tests/test_figment_render.py` | SVG rasterization, caching, edge cases |
|
|
||||||
| `tests/test_figment_trigger.py` | FigmentCommand parsing, NtfyTrigger adapter |
|
|
||||||
| `tests/fixtures/test.svg` | Minimal SVG for deterministic rasterization tests |
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `engine/scroll.py` | Figment overlay integration (setup + per-frame block) |
|
|
||||||
| `engine/layers.py` | Add `render_figment_overlay()` function |
|
|
||||||
| `engine/events.py` | Add `FIGMENT_TRIGGER` to `EventType` enum, add `FigmentTriggerEvent` dataclass |
|
|
||||||
| `pyproject.toml` | Add `cairosvg` as optional dependency |
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
- **Unit**: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage
|
|
||||||
- **Integration**: Plugin discovery (verify `FigmentEffect` is found by `discover_plugins()`), overlay rendering with mock terminal dimensions, C&C command handling via `/effects figment on`
|
|
||||||
- **Edge cases**: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation
|
|
||||||
- **Fixture**: Minimal `test.svg` (simple rectangle) for deterministic rasterization tests
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
"""
|
|
||||||
Figment effect plugin — periodic SVG glyph overlay.
|
|
||||||
|
|
||||||
Owns the figment lifecycle: timer, SVG selection, state machine.
|
|
||||||
Delegates rendering to render_figment_overlay() in engine/layers.py.
|
|
||||||
|
|
||||||
Named FigmentEffect (not FigmentPlugin) to match the *Effect discovery
|
|
||||||
convention in effects_plugins/__init__.py.
|
|
||||||
|
|
||||||
NOT added to the EffectChain order — process() is a no-op. The overlay
|
|
||||||
rendering is handled by scroll.py calling get_figment_state().
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum, auto
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from engine import config
|
|
||||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|
||||||
from engine.figment_render import rasterize_svg
|
|
||||||
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
|
|
||||||
from engine.themes import THEME_REGISTRY
|
|
||||||
|
|
||||||
|
|
||||||
class FigmentPhase(Enum):
|
|
||||||
REVEAL = auto()
|
|
||||||
HOLD = auto()
|
|
||||||
DISSOLVE = auto()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FigmentState:
|
|
||||||
phase: FigmentPhase
|
|
||||||
progress: float
|
|
||||||
rows: list[str]
|
|
||||||
gradient: list[int]
|
|
||||||
center_row: int
|
|
||||||
center_col: int
|
|
||||||
|
|
||||||
|
|
||||||
class FigmentEffect(EffectPlugin):
|
|
||||||
name = "figment"
|
|
||||||
config = EffectConfig(
|
|
||||||
enabled=False,
|
|
||||||
intensity=1.0,
|
|
||||||
params={
|
|
||||||
"interval_secs": 60,
|
|
||||||
"display_secs": 4.5,
|
|
||||||
"figment_dir": "figments",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
figment_dir: str | None = None,
|
|
||||||
triggers: list[FigmentTrigger] | None = None,
|
|
||||||
):
|
|
||||||
self.config = EffectConfig(
|
|
||||||
enabled=False,
|
|
||||||
intensity=1.0,
|
|
||||||
params={
|
|
||||||
"interval_secs": 60,
|
|
||||||
"display_secs": 4.5,
|
|
||||||
"figment_dir": figment_dir or "figments",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
self._triggers = triggers or []
|
|
||||||
self._phase: FigmentPhase | None = None
|
|
||||||
self._progress: float = 0.0
|
|
||||||
self._rows: list[str] = []
|
|
||||||
self._gradient: list[int] = []
|
|
||||||
self._center_row: int = 0
|
|
||||||
self._center_col: int = 0
|
|
||||||
self._timer: float = 0.0
|
|
||||||
self._last_svg: str | None = None
|
|
||||||
self._svg_files: list[str] = []
|
|
||||||
self._scan_svgs()
|
|
||||||
|
|
||||||
def _scan_svgs(self) -> None:
|
|
||||||
figment_dir = Path(self.config.params["figment_dir"])
|
|
||||||
if figment_dir.is_dir():
|
|
||||||
self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))
|
|
||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
|
||||||
return buf
|
|
||||||
|
|
||||||
def configure(self, cfg: EffectConfig) -> None:
|
|
||||||
# Preserve figment_dir if the new config doesn't supply one
|
|
||||||
figment_dir = cfg.params.get(
|
|
||||||
"figment_dir", self.config.params.get("figment_dir", "figments")
|
|
||||||
)
|
|
||||||
self.config = cfg
|
|
||||||
if "figment_dir" not in self.config.params:
|
|
||||||
self.config.params["figment_dir"] = figment_dir
|
|
||||||
self._scan_svgs()
|
|
||||||
|
|
||||||
def trigger(self, w: int, h: int) -> None:
|
|
||||||
"""Manually trigger a figment display."""
|
|
||||||
if not self._svg_files:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Pick a random SVG, avoid repeating
|
|
||||||
candidates = [s for s in self._svg_files if s != self._last_svg]
|
|
||||||
if not candidates:
|
|
||||||
candidates = self._svg_files
|
|
||||||
svg_path = random.choice(candidates)
|
|
||||||
self._last_svg = svg_path
|
|
||||||
|
|
||||||
# Rasterize
|
|
||||||
try:
|
|
||||||
self._rows = rasterize_svg(svg_path, w, h)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Pick random theme gradient
|
|
||||||
theme_key = random.choice(list(THEME_REGISTRY.keys()))
|
|
||||||
self._gradient = THEME_REGISTRY[theme_key].main_gradient
|
|
||||||
|
|
||||||
# Center in viewport
|
|
||||||
figment_h = len(self._rows)
|
|
||||||
figment_w = max((len(r) for r in self._rows), default=0)
|
|
||||||
self._center_row = max(0, (h - figment_h) // 2)
|
|
||||||
self._center_col = max(0, (w - figment_w) // 2)
|
|
||||||
|
|
||||||
# Start reveal phase
|
|
||||||
self._phase = FigmentPhase.REVEAL
|
|
||||||
self._progress = 0.0
|
|
||||||
|
|
||||||
def get_figment_state(
|
|
||||||
self, frame_number: int, w: int, h: int
|
|
||||||
) -> FigmentState | None:
|
|
||||||
"""Tick the state machine and return current state, or None if idle."""
|
|
||||||
if not self.config.enabled:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Poll triggers
|
|
||||||
for trig in self._triggers:
|
|
||||||
cmd = trig.poll()
|
|
||||||
if cmd is not None:
|
|
||||||
self._handle_command(cmd, w, h)
|
|
||||||
|
|
||||||
# Tick timer when idle
|
|
||||||
if self._phase is None:
|
|
||||||
self._timer += config.FRAME_DT
|
|
||||||
interval = self.config.params.get("interval_secs", 60)
|
|
||||||
if self._timer >= interval:
|
|
||||||
self._timer = 0.0
|
|
||||||
self.trigger(w, h)
|
|
||||||
|
|
||||||
# Tick animation — snapshot current phase/progress, then advance
|
|
||||||
if self._phase is not None:
|
|
||||||
# Capture the state at the start of this frame
|
|
||||||
current_phase = self._phase
|
|
||||||
current_progress = self._progress
|
|
||||||
|
|
||||||
# Advance for next frame
|
|
||||||
display_secs = self.config.params.get("display_secs", 4.5)
|
|
||||||
phase_duration = display_secs / 3.0
|
|
||||||
self._progress += config.FRAME_DT / phase_duration
|
|
||||||
|
|
||||||
if self._progress >= 1.0:
|
|
||||||
self._progress = 0.0
|
|
||||||
if self._phase == FigmentPhase.REVEAL:
|
|
||||||
self._phase = FigmentPhase.HOLD
|
|
||||||
elif self._phase == FigmentPhase.HOLD:
|
|
||||||
self._phase = FigmentPhase.DISSOLVE
|
|
||||||
elif self._phase == FigmentPhase.DISSOLVE:
|
|
||||||
self._phase = None
|
|
||||||
|
|
||||||
return FigmentState(
|
|
||||||
phase=current_phase,
|
|
||||||
progress=current_progress,
|
|
||||||
rows=self._rows,
|
|
||||||
gradient=self._gradient,
|
|
||||||
center_row=self._center_row,
|
|
||||||
center_col=self._center_col,
|
|
||||||
)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
|
|
||||||
if cmd.action == FigmentAction.TRIGGER:
|
|
||||||
self.trigger(w, h)
|
|
||||||
elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(
|
|
||||||
cmd.value, (int, float)
|
|
||||||
):
|
|
||||||
self.config.intensity = float(cmd.value)
|
|
||||||
elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(
|
|
||||||
cmd.value, (int, float)
|
|
||||||
):
|
|
||||||
self.config.params["interval_secs"] = float(cmd.value)
|
|
||||||
elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
|
|
||||||
if cmd.value in THEME_REGISTRY:
|
|
||||||
self._gradient = THEME_REGISTRY[cmd.value].main_gradient
|
|
||||||
elif cmd.action == FigmentAction.STOP:
|
|
||||||
self._phase = None
|
|
||||||
self._progress = 0.0
|
|
||||||
@@ -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, cfg: EffectConfig) -> None:
|
|
||||||
self.config = cfg
|
|
||||||
@@ -1 +1,10 @@
|
|||||||
# engine — modular internals for mainline
|
# engine — modular internals for mainline
|
||||||
|
|
||||||
|
# Import submodules to make them accessible via engine.<name>
|
||||||
|
# This is required for unittest.mock.patch to work with "engine.<module>.<function>"
|
||||||
|
# strings and for direct attribute access on the engine package.
|
||||||
|
import engine.config # noqa: F401
|
||||||
|
import engine.fetch # noqa: F401
|
||||||
|
import engine.filter # noqa: F401
|
||||||
|
import engine.sources # noqa: F401
|
||||||
|
import engine.terminal # noqa: F401
|
||||||
|
|||||||
441
engine/app.py
441
engine/app.py
@@ -1,437 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
Application orchestrator — pipeline mode entry point.
|
||||||
|
|
||||||
|
This module provides the main entry point for the application.
|
||||||
|
The implementation has been refactored into the engine.app package.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import atexit
|
# Re-export from the new package structure
|
||||||
import os
|
from engine.app import main, run_pipeline_mode, run_pipeline_mode_direct
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import termios
|
|
||||||
import time
|
|
||||||
import tty
|
|
||||||
|
|
||||||
from engine import config, render, themes
|
__all__ = ["main", "run_pipeline_mode", "run_pipeline_mode_direct"]
|
||||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
|
||||||
from engine.mic import MicMonitor
|
|
||||||
from engine.ntfy import NtfyPoller
|
|
||||||
from engine.scroll import stream
|
|
||||||
from engine.terminal import (
|
|
||||||
CLR,
|
|
||||||
CURSOR_OFF,
|
|
||||||
CURSOR_ON,
|
|
||||||
G_DIM,
|
|
||||||
G_HI,
|
|
||||||
G_MID,
|
|
||||||
RST,
|
|
||||||
W_DIM,
|
|
||||||
W_GHOST,
|
|
||||||
boot_ln,
|
|
||||||
slow_print,
|
|
||||||
tw,
|
|
||||||
)
|
|
||||||
|
|
||||||
TITLE = [
|
if __name__ == "__main__":
|
||||||
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
|
main()
|
||||||
" ████╗ ████║██╔══██╗██║████╗ ██║██║ ██║████╗ ██║██╔════╝",
|
|
||||||
" ██╔████╔██║███████║██║██╔██╗ ██║██║ ██║██╔██╗ ██║█████╗ ",
|
|
||||||
" ██║╚██╔╝██║██╔══██║██║██║╚██╗██║██║ ██║██║╚██╗██║██╔══╝ ",
|
|
||||||
" ██║ ╚═╝ ██║██║ ██║██║██║ ╚████║███████╗██║██║ ╚████║███████╗",
|
|
||||||
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _read_picker_key():
|
|
||||||
ch = sys.stdin.read(1)
|
|
||||||
if ch == "\x03":
|
|
||||||
return "interrupt"
|
|
||||||
if ch in ("\r", "\n"):
|
|
||||||
return "enter"
|
|
||||||
if ch == "\x1b":
|
|
||||||
c1 = sys.stdin.read(1)
|
|
||||||
if c1 != "[":
|
|
||||||
return None
|
|
||||||
c2 = sys.stdin.read(1)
|
|
||||||
if c2 == "A":
|
|
||||||
return "up"
|
|
||||||
if c2 == "B":
|
|
||||||
return "down"
|
|
||||||
return None
|
|
||||||
if ch in ("k", "K"):
|
|
||||||
return "up"
|
|
||||||
if ch in ("j", "J"):
|
|
||||||
return "down"
|
|
||||||
if ch in ("q", "Q"):
|
|
||||||
return "enter"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_color_picker(themes_list, selected):
|
|
||||||
"""Draw the color theme picker menu.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
themes_list: List of (theme_id, Theme) tuples from THEME_REGISTRY.items()
|
|
||||||
selected: Index of currently selected theme (0-2)
|
|
||||||
"""
|
|
||||||
print(CLR, end="")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print(
|
|
||||||
f" {G_HI}▼ COLOR THEME{RST} {W_GHOST}─ ↑/↓ or j/k to move, Enter/q to select{RST}"
|
|
||||||
)
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}\n")
|
|
||||||
|
|
||||||
for i, (theme_id, theme) in enumerate(themes_list):
|
|
||||||
prefix = " ▶ " if i == selected else " "
|
|
||||||
color = G_HI if i == selected else ""
|
|
||||||
reset = "" if i == selected else W_GHOST
|
|
||||||
print(f"{prefix}{color}{theme.name}{reset}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_preview_rows(rows):
|
|
||||||
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
|
||||||
non_empty = [r for r in rows if r.strip()]
|
|
||||||
if not non_empty:
|
|
||||||
return [""]
|
|
||||||
left_pad = min(len(r) - len(r.lstrip(" ")) for r in non_empty)
|
|
||||||
out = []
|
|
||||||
for row in rows:
|
|
||||||
if left_pad < len(row):
|
|
||||||
out.append(row[left_pad:].rstrip())
|
|
||||||
else:
|
|
||||||
out.append(row.rstrip())
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _draw_font_picker(faces, selected):
|
|
||||||
w = tw()
|
|
||||||
h = 24
|
|
||||||
try:
|
|
||||||
h = os.get_terminal_size().lines
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
max_preview_w = max(24, w - 8)
|
|
||||||
header_h = 6
|
|
||||||
footer_h = 3
|
|
||||||
preview_h = max(4, min(config.RENDER_H + 2, max(4, h // 2)))
|
|
||||||
visible = max(1, h - header_h - preview_h - footer_h)
|
|
||||||
top = max(0, selected - (visible // 2))
|
|
||||||
bottom = min(len(faces), top + visible)
|
|
||||||
top = max(0, bottom - visible)
|
|
||||||
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
print(f" {G_HI}FONT PICKER{RST}")
|
|
||||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
|
||||||
print(f" {W_DIM}{config.FONT_DIR[:max_preview_w]}{RST}")
|
|
||||||
print(f" {W_GHOST}↑/↓ move · Enter select · q accept current{RST}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
for pos in range(top, bottom):
|
|
||||||
face = faces[pos]
|
|
||||||
active = pos == selected
|
|
||||||
pointer = "▶" if active else " "
|
|
||||||
color = G_HI if active else W_DIM
|
|
||||||
print(
|
|
||||||
f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if top > 0:
|
|
||||||
print(f" {W_GHOST}… {top} above{RST}")
|
|
||||||
if bottom < len(faces):
|
|
||||||
print(f" {W_GHOST}… {len(faces) - bottom} below{RST}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
|
||||||
print(
|
|
||||||
f" {W_DIM}Preview: {faces[selected]['name']} · {faces[selected]['file_name']}{RST}"
|
|
||||||
)
|
|
||||||
preview_rows = faces[selected]["preview_rows"][:preview_h]
|
|
||||||
for row in preview_rows:
|
|
||||||
shown = row[:max_preview_w]
|
|
||||||
print(f" {shown}")
|
|
||||||
|
|
||||||
|
|
||||||
def pick_color_theme():
|
|
||||||
"""Interactive color theme picker. Defaults to 'green' if not TTY.
|
|
||||||
|
|
||||||
Displays a menu of available themes and lets user select with arrow keys.
|
|
||||||
Non-interactive environments (piped stdin, CI) silently default to green.
|
|
||||||
"""
|
|
||||||
# Non-interactive fallback
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
config.set_active_theme("green")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Interactive picker
|
|
||||||
themes_list = list(themes.THEME_REGISTRY.items())
|
|
||||||
selected = 0
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setcbreak(fd)
|
|
||||||
while True:
|
|
||||||
_draw_color_picker(themes_list, selected)
|
|
||||||
key = _read_picker_key()
|
|
||||||
if key == "up":
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
elif key == "down":
|
|
||||||
selected = min(len(themes_list) - 1, selected + 1)
|
|
||||||
elif key == "enter":
|
|
||||||
break
|
|
||||||
elif key == "interrupt":
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
selected_theme_id = themes_list[selected][0]
|
|
||||||
config.set_active_theme(selected_theme_id)
|
|
||||||
|
|
||||||
theme_name = themes_list[selected][1].name
|
|
||||||
print(f" {G_DIM}> using {theme_name}{RST}")
|
|
||||||
time.sleep(0.8)
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def pick_font_face():
|
|
||||||
"""Interactive startup picker for selecting a face from repo OTF files."""
|
|
||||||
if not config.FONT_PICKER:
|
|
||||||
return
|
|
||||||
|
|
||||||
font_files = config.list_repo_font_files()
|
|
||||||
if not font_files:
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
print(f" {G_HI}FONT PICKER{RST}")
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
|
||||||
print(f" {G_DIM}> no .otf/.ttf/.ttc files found in: {config.FONT_DIR}{RST}")
|
|
||||||
print(f" {W_GHOST}> add font files to the fonts folder, then rerun{RST}")
|
|
||||||
time.sleep(1.8)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
prepared = []
|
|
||||||
for font_path in font_files:
|
|
||||||
try:
|
|
||||||
faces = render.list_font_faces(font_path, max_faces=64)
|
|
||||||
except Exception:
|
|
||||||
fallback = os.path.splitext(os.path.basename(font_path))[0]
|
|
||||||
faces = [{"index": 0, "name": fallback}]
|
|
||||||
for face in faces:
|
|
||||||
idx = face["index"]
|
|
||||||
name = face["name"]
|
|
||||||
file_name = os.path.basename(font_path)
|
|
||||||
try:
|
|
||||||
fnt = render.load_font_face(font_path, idx)
|
|
||||||
rows = _normalize_preview_rows(render.render_line(name, fnt))
|
|
||||||
except Exception:
|
|
||||||
rows = ["(preview unavailable)"]
|
|
||||||
prepared.append(
|
|
||||||
{
|
|
||||||
"font_path": font_path,
|
|
||||||
"font_index": idx,
|
|
||||||
"name": name,
|
|
||||||
"file_name": file_name,
|
|
||||||
"preview_rows": rows,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not prepared:
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
print(f" {G_HI}FONT PICKER{RST}")
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
|
||||||
print(f" {G_DIM}> no readable font faces found in: {config.FONT_DIR}{RST}")
|
|
||||||
time.sleep(1.8)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def _same_path(a, b):
|
|
||||||
try:
|
|
||||||
return os.path.samefile(a, b)
|
|
||||||
except Exception:
|
|
||||||
return os.path.abspath(a) == os.path.abspath(b)
|
|
||||||
|
|
||||||
selected = next(
|
|
||||||
(
|
|
||||||
i
|
|
||||||
for i, f in enumerate(prepared)
|
|
||||||
if _same_path(f["font_path"], config.FONT_PATH)
|
|
||||||
and f["font_index"] == config.FONT_INDEX
|
|
||||||
),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
selected_font = prepared[selected]
|
|
||||||
config.set_font_selection(
|
|
||||||
font_path=selected_font["font_path"],
|
|
||||||
font_index=selected_font["font_index"],
|
|
||||||
)
|
|
||||||
render.clear_font_cache()
|
|
||||||
print(
|
|
||||||
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
|
||||||
)
|
|
||||||
time.sleep(0.8)
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
return
|
|
||||||
|
|
||||||
fd = sys.stdin.fileno()
|
|
||||||
old_settings = termios.tcgetattr(fd)
|
|
||||||
try:
|
|
||||||
tty.setcbreak(fd)
|
|
||||||
while True:
|
|
||||||
_draw_font_picker(prepared, selected)
|
|
||||||
key = _read_picker_key()
|
|
||||||
if key == "up":
|
|
||||||
selected = max(0, selected - 1)
|
|
||||||
elif key == "down":
|
|
||||||
selected = min(len(prepared) - 1, selected + 1)
|
|
||||||
elif key == "enter":
|
|
||||||
break
|
|
||||||
elif key == "interrupt":
|
|
||||||
raise KeyboardInterrupt
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
||||||
|
|
||||||
selected_font = prepared[selected]
|
|
||||||
config.set_font_selection(
|
|
||||||
font_path=selected_font["font_path"],
|
|
||||||
font_index=selected_font["font_index"],
|
|
||||||
)
|
|
||||||
render.clear_font_cache()
|
|
||||||
print(
|
|
||||||
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
|
||||||
)
|
|
||||||
time.sleep(0.8)
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
|
||||||
|
|
||||||
def handle_sigint(*_):
|
|
||||||
print(f"\n\n {G_DIM}> SIGNAL LOST{RST}")
|
|
||||||
print(f" {W_GHOST}> connection terminated{RST}\n")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, handle_sigint)
|
|
||||||
|
|
||||||
w = tw()
|
|
||||||
print(CLR, end="")
|
|
||||||
print(CURSOR_OFF, end="")
|
|
||||||
pick_color_theme()
|
|
||||||
pick_font_face()
|
|
||||||
w = tw()
|
|
||||||
print()
|
|
||||||
time.sleep(0.4)
|
|
||||||
|
|
||||||
for ln in TITLE:
|
|
||||||
print(f"{G_HI}{ln}{RST}")
|
|
||||||
time.sleep(0.07)
|
|
||||||
|
|
||||||
print()
|
|
||||||
_subtitle = {
|
|
||||||
"poetry": "literary consciousness stream",
|
|
||||||
"code": "source consciousness stream",
|
|
||||||
}.get(config.MODE, "digital consciousness stream")
|
|
||||||
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
|
||||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
|
||||||
print()
|
|
||||||
time.sleep(0.4)
|
|
||||||
|
|
||||||
cached = load_cache() if "--refresh" not in sys.argv else None
|
|
||||||
if cached:
|
|
||||||
items = cached
|
|
||||||
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
|
|
||||||
elif config.MODE == "poetry":
|
|
||||||
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
print()
|
|
||||||
items, linked, failed = fetch_poetry()
|
|
||||||
print()
|
|
||||||
print(
|
|
||||||
f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}"
|
|
||||||
)
|
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
|
||||||
save_cache(items)
|
|
||||||
elif config.MODE == "code":
|
|
||||||
from engine.fetch_code import fetch_code
|
|
||||||
|
|
||||||
slow_print(" > INITIALIZING SOURCE ARRAY...\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
print()
|
|
||||||
items, line_count, _ = fetch_code()
|
|
||||||
print()
|
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{line_count} LINES ACQUIRED{RST}")
|
|
||||||
else:
|
|
||||||
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
print()
|
|
||||||
items, linked, failed = fetch_all()
|
|
||||||
print()
|
|
||||||
print(
|
|
||||||
f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}"
|
|
||||||
)
|
|
||||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
|
|
||||||
save_cache(items)
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
print(f"\n {W_DIM}> NO SIGNAL — check network{RST}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print()
|
|
||||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
|
||||||
mic_ok = mic.start()
|
|
||||||
if mic.available:
|
|
||||||
boot_ln(
|
|
||||||
"Microphone",
|
|
||||||
"ACTIVE"
|
|
||||||
if mic_ok
|
|
||||||
else "OFFLINE · check System Settings → Privacy → Microphone",
|
|
||||||
bool(mic_ok),
|
|
||||||
)
|
|
||||||
|
|
||||||
ntfy = NtfyPoller(
|
|
||||||
config.NTFY_TOPIC,
|
|
||||||
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
|
||||||
display_secs=config.MESSAGE_DISPLAY_SECS,
|
|
||||||
)
|
|
||||||
ntfy_ok = ntfy.start()
|
|
||||||
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
|
||||||
|
|
||||||
if config.FIREHOSE:
|
|
||||||
boot_ln("Firehose", "ENGAGED", True)
|
|
||||||
|
|
||||||
if config.FIGMENT:
|
|
||||||
try:
|
|
||||||
from effects_plugins.figment import FigmentEffect # noqa: F401
|
|
||||||
|
|
||||||
boot_ln("Figment", f"ARMED [{config.FIGMENT_INTERVAL}s interval]", True)
|
|
||||||
except (ImportError, OSError):
|
|
||||||
boot_ln("Figment", "UNAVAILABLE — run: brew install cairo", False)
|
|
||||||
|
|
||||||
time.sleep(0.4)
|
|
||||||
slow_print(" > STREAMING...\n")
|
|
||||||
time.sleep(0.2)
|
|
||||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
|
||||||
print()
|
|
||||||
time.sleep(0.4)
|
|
||||||
|
|
||||||
stream(items, ntfy, mic)
|
|
||||||
|
|
||||||
print()
|
|
||||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
|
||||||
print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
|
||||||
print(f" {W_GHOST}> end of stream{RST}")
|
|
||||||
print()
|
|
||||||
|
|||||||
34
engine/app/__init__.py
Normal file
34
engine/app/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Application orchestrator — pipeline mode entry point.
|
||||||
|
|
||||||
|
This package contains the main application logic for the pipeline mode,
|
||||||
|
including pipeline construction, UI controller setup, and the main render loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Re-export from engine for backward compatibility with tests
|
||||||
|
# Re-export effects plugins for backward compatibility with tests
|
||||||
|
import engine.effects.plugins as effects_plugins
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
# Re-export display registry for backward compatibility with tests
|
||||||
|
from engine.display import DisplayRegistry
|
||||||
|
|
||||||
|
# Re-export fetch functions for backward compatibility with tests
|
||||||
|
from engine.fetch import fetch_all, fetch_poetry, load_cache
|
||||||
|
from engine.pipeline import list_presets
|
||||||
|
|
||||||
|
from .main import main, run_pipeline_mode_direct
|
||||||
|
from .pipeline_runner import run_pipeline_mode
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"config",
|
||||||
|
"list_presets",
|
||||||
|
"main",
|
||||||
|
"run_pipeline_mode",
|
||||||
|
"run_pipeline_mode_direct",
|
||||||
|
"fetch_all",
|
||||||
|
"fetch_poetry",
|
||||||
|
"load_cache",
|
||||||
|
"DisplayRegistry",
|
||||||
|
"effects_plugins",
|
||||||
|
]
|
||||||
457
engine/app/main.py
Normal file
457
engine/app/main.py
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
"""
|
||||||
|
Main entry point and CLI argument parsing for the application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.display import BorderMode, DisplayRegistry
|
||||||
|
from engine.effects import get_registry
|
||||||
|
from engine.fetch import fetch_all, fetch_all_fast, fetch_poetry, load_cache, save_cache
|
||||||
|
from engine.pipeline import (
|
||||||
|
Pipeline,
|
||||||
|
PipelineConfig,
|
||||||
|
PipelineContext,
|
||||||
|
list_presets,
|
||||||
|
)
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
|
CameraStage,
|
||||||
|
DataSourceStage,
|
||||||
|
EffectPluginStage,
|
||||||
|
create_stage_from_display,
|
||||||
|
create_stage_from_effect,
|
||||||
|
)
|
||||||
|
from engine.pipeline.params import PipelineParams
|
||||||
|
from engine.pipeline.ui import UIConfig, UIPanel
|
||||||
|
from engine.pipeline.validation import validate_pipeline_config
|
||||||
|
|
||||||
|
try:
|
||||||
|
from engine.display.backends.websocket import WebSocketDisplay
|
||||||
|
except ImportError:
|
||||||
|
WebSocketDisplay = None
|
||||||
|
|
||||||
|
from .pipeline_runner import run_pipeline_mode
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point - all modes now use presets or CLI construction."""
|
||||||
|
if config.PIPELINE_DIAGRAM:
|
||||||
|
try:
|
||||||
|
from engine.pipeline import generate_pipeline_diagram
|
||||||
|
except ImportError:
|
||||||
|
print("Error: pipeline diagram not available")
|
||||||
|
return
|
||||||
|
print(generate_pipeline_diagram())
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for direct pipeline construction flags
|
||||||
|
if "--pipeline-source" in sys.argv:
|
||||||
|
# Construct pipeline directly from CLI args
|
||||||
|
run_pipeline_mode_direct()
|
||||||
|
return
|
||||||
|
|
||||||
|
preset_name = None
|
||||||
|
|
||||||
|
if config.PRESET:
|
||||||
|
preset_name = config.PRESET
|
||||||
|
elif config.PIPELINE_MODE:
|
||||||
|
preset_name = config.PIPELINE_PRESET
|
||||||
|
else:
|
||||||
|
preset_name = "demo"
|
||||||
|
|
||||||
|
available = list_presets()
|
||||||
|
if preset_name not in available:
|
||||||
|
print(f"Error: Unknown preset '{preset_name}'")
|
||||||
|
print(f"Available presets: {', '.join(available)}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
run_pipeline_mode(preset_name)
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline_mode_direct():
|
||||||
|
"""Construct and run a pipeline directly from CLI arguments.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m engine.app --pipeline-source headlines --pipeline-effects noise,fade --display null
|
||||||
|
python -m engine.app --pipeline-source fixture --pipeline-effects glitch --pipeline-ui --display null
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--pipeline-source <source>: Headlines, fixture, poetry, empty, pipeline-inspect
|
||||||
|
--pipeline-effects <effects>: Comma-separated list (noise, fade, glitch, firehose, hud, tint, border, crop)
|
||||||
|
--pipeline-camera <type>: scroll, feed, horizontal, omni, floating, bounce
|
||||||
|
--pipeline-display <display>: terminal, pygame, websocket, null, multi:term,pygame
|
||||||
|
--pipeline-ui: Enable UI panel (BorderMode.UI)
|
||||||
|
--pipeline-border <mode>: off, simple, ui
|
||||||
|
"""
|
||||||
|
import engine.effects.plugins as effects_plugins
|
||||||
|
from engine.camera import Camera
|
||||||
|
from engine.data_sources.pipeline_introspection import PipelineIntrospectionSource
|
||||||
|
from engine.data_sources.sources import EmptyDataSource, ListDataSource
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
|
FontStage,
|
||||||
|
ViewportFilterStage,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discover and register all effect plugins
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
# Parse CLI arguments
|
||||||
|
source_name = None
|
||||||
|
effect_names = []
|
||||||
|
camera_type = None
|
||||||
|
display_name = None
|
||||||
|
ui_enabled = False
|
||||||
|
border_mode = BorderMode.OFF
|
||||||
|
source_items = None
|
||||||
|
allow_unsafe = False
|
||||||
|
viewport_width = None
|
||||||
|
viewport_height = None
|
||||||
|
|
||||||
|
i = 1
|
||||||
|
argv = sys.argv
|
||||||
|
while i < len(argv):
|
||||||
|
arg = argv[i]
|
||||||
|
if arg == "--pipeline-source" and i + 1 < len(argv):
|
||||||
|
source_name = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--pipeline-effects" and i + 1 < len(argv):
|
||||||
|
effect_names = [e.strip() for e in argv[i + 1].split(",") if e.strip()]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--pipeline-camera" and i + 1 < len(argv):
|
||||||
|
camera_type = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--viewport" and i + 1 < len(argv):
|
||||||
|
vp = argv[i + 1]
|
||||||
|
try:
|
||||||
|
viewport_width, viewport_height = map(int, vp.split("x"))
|
||||||
|
except ValueError:
|
||||||
|
print("Error: Invalid viewport format. Use WxH (e.g., 40x15)")
|
||||||
|
sys.exit(1)
|
||||||
|
i += 2
|
||||||
|
elif arg == "--pipeline-display" and i + 1 < len(argv):
|
||||||
|
display_name = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--pipeline-ui":
|
||||||
|
ui_enabled = True
|
||||||
|
i += 1
|
||||||
|
elif arg == "--pipeline-border" and i + 1 < len(argv):
|
||||||
|
mode = argv[i + 1]
|
||||||
|
if mode == "simple":
|
||||||
|
border_mode = True
|
||||||
|
elif mode == "ui":
|
||||||
|
border_mode = BorderMode.UI
|
||||||
|
else:
|
||||||
|
border_mode = False
|
||||||
|
i += 2
|
||||||
|
elif arg == "--allow-unsafe":
|
||||||
|
allow_unsafe = True
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not source_name:
|
||||||
|
print("Error: --pipeline-source is required")
|
||||||
|
print(
|
||||||
|
"Usage: python -m engine.app --pipeline-source <source> [--pipeline-effects <effects>] ..."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(" \033[38;5;245mDirect pipeline construction\033[0m")
|
||||||
|
print(f" Source: {source_name}")
|
||||||
|
print(f" Effects: {effect_names}")
|
||||||
|
print(f" Camera: {camera_type}")
|
||||||
|
print(f" Display: {display_name}")
|
||||||
|
print(f" UI Enabled: {ui_enabled}")
|
||||||
|
|
||||||
|
# Create initial config and params
|
||||||
|
params = PipelineParams()
|
||||||
|
params.source = source_name
|
||||||
|
params.camera_mode = camera_type if camera_type is not None else ""
|
||||||
|
params.effect_order = effect_names
|
||||||
|
params.border = border_mode
|
||||||
|
|
||||||
|
# Create minimal config for validation
|
||||||
|
config_obj = PipelineConfig(
|
||||||
|
source=source_name,
|
||||||
|
display=display_name or "", # Will be filled by validation
|
||||||
|
camera=camera_type if camera_type is not None else "",
|
||||||
|
effects=effect_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run MVP validation
|
||||||
|
result = validate_pipeline_config(config_obj, params, allow_unsafe=allow_unsafe)
|
||||||
|
|
||||||
|
if result.warnings and not allow_unsafe:
|
||||||
|
print(" \033[38;5;226mWarning: MVP validation found issues:\033[0m")
|
||||||
|
for warning in result.warnings:
|
||||||
|
print(f" - {warning}")
|
||||||
|
|
||||||
|
if result.changes:
|
||||||
|
print(" \033[38;5;226mApplied MVP defaults:\033[0m")
|
||||||
|
for change in result.changes:
|
||||||
|
print(f" {change}")
|
||||||
|
|
||||||
|
if not result.valid:
|
||||||
|
print(
|
||||||
|
" \033[38;5;196mPipeline configuration invalid and could not be fixed\033[0m"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Show MVP summary
|
||||||
|
print(" \033[38;5;245mMVP Configuration:\033[0m")
|
||||||
|
print(f" Source: {result.config.source}")
|
||||||
|
print(f" Display: {result.config.display}")
|
||||||
|
print(f" Camera: {result.config.camera or 'static (none)'}")
|
||||||
|
print(f" Effects: {result.config.effects if result.config.effects else 'none'}")
|
||||||
|
print(f" Border: {result.params.border}")
|
||||||
|
|
||||||
|
# Load source items
|
||||||
|
if source_name == "headlines":
|
||||||
|
cached = load_cache()
|
||||||
|
if cached:
|
||||||
|
source_items = cached
|
||||||
|
else:
|
||||||
|
source_items = fetch_all_fast()
|
||||||
|
if source_items:
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def background_fetch():
|
||||||
|
full_items, _, _ = fetch_all()
|
||||||
|
save_cache(full_items)
|
||||||
|
|
||||||
|
background_thread = threading.Thread(
|
||||||
|
target=background_fetch, daemon=True
|
||||||
|
)
|
||||||
|
background_thread.start()
|
||||||
|
elif source_name == "fixture":
|
||||||
|
source_items = load_cache()
|
||||||
|
if not source_items:
|
||||||
|
print(" \033[38;5;196mNo fixture cache available\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
elif source_name == "poetry":
|
||||||
|
source_items, _, _ = fetch_poetry()
|
||||||
|
elif source_name == "empty" or source_name == "pipeline-inspect":
|
||||||
|
source_items = []
|
||||||
|
else:
|
||||||
|
print(f" \033[38;5;196mUnknown source: {source_name}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if source_items is not None:
|
||||||
|
print(f" \033[38;5;82mLoaded {len(source_items)} items\033[0m")
|
||||||
|
|
||||||
|
# Set border mode
|
||||||
|
if ui_enabled:
|
||||||
|
border_mode = BorderMode.UI
|
||||||
|
|
||||||
|
# Build pipeline using validated config and params
|
||||||
|
params = result.params
|
||||||
|
params.viewport_width = viewport_width if viewport_width is not None else 80
|
||||||
|
params.viewport_height = viewport_height if viewport_height is not None else 24
|
||||||
|
|
||||||
|
ctx = PipelineContext()
|
||||||
|
ctx.params = params
|
||||||
|
|
||||||
|
# Create display using validated display name
|
||||||
|
display_name = result.config.display or "terminal" # Default to terminal if empty
|
||||||
|
display = DisplayRegistry.create(display_name)
|
||||||
|
if not display:
|
||||||
|
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
display.init(0, 0)
|
||||||
|
|
||||||
|
# Create pipeline using validated config
|
||||||
|
pipeline = Pipeline(config=result.config, context=ctx)
|
||||||
|
|
||||||
|
# Add stages
|
||||||
|
# Source stage
|
||||||
|
if source_name == "pipeline-inspect":
|
||||||
|
introspection_source = PipelineIntrospectionSource(
|
||||||
|
pipeline=None,
|
||||||
|
viewport_width=params.viewport_width,
|
||||||
|
viewport_height=params.viewport_height,
|
||||||
|
)
|
||||||
|
pipeline.add_stage(
|
||||||
|
"source", DataSourceStage(introspection_source, name="pipeline-inspect")
|
||||||
|
)
|
||||||
|
elif source_name == "empty":
|
||||||
|
empty_source = EmptyDataSource(
|
||||||
|
width=params.viewport_width, height=params.viewport_height
|
||||||
|
)
|
||||||
|
pipeline.add_stage("source", DataSourceStage(empty_source, name="empty"))
|
||||||
|
else:
|
||||||
|
list_source = ListDataSource(source_items, name=source_name)
|
||||||
|
pipeline.add_stage("source", DataSourceStage(list_source, name=source_name))
|
||||||
|
|
||||||
|
# Add viewport filter and font for headline sources
|
||||||
|
if source_name in ["headlines", "poetry", "fixture"]:
|
||||||
|
pipeline.add_stage(
|
||||||
|
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
||||||
|
)
|
||||||
|
pipeline.add_stage("font", FontStage(name="font"))
|
||||||
|
else:
|
||||||
|
# Fallback to simple conversion for other sources
|
||||||
|
from engine.pipeline.adapters import SourceItemsToBufferStage
|
||||||
|
|
||||||
|
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
||||||
|
|
||||||
|
# Add camera
|
||||||
|
speed = getattr(params, "camera_speed", 1.0)
|
||||||
|
camera = None
|
||||||
|
if camera_type == "feed":
|
||||||
|
camera = Camera.feed(speed=speed)
|
||||||
|
elif camera_type == "scroll":
|
||||||
|
camera = Camera.scroll(speed=speed)
|
||||||
|
elif camera_type == "horizontal":
|
||||||
|
camera = Camera.horizontal(speed=speed)
|
||||||
|
elif camera_type == "omni":
|
||||||
|
camera = Camera.omni(speed=speed)
|
||||||
|
elif camera_type == "floating":
|
||||||
|
camera = Camera.floating(speed=speed)
|
||||||
|
elif camera_type == "bounce":
|
||||||
|
camera = Camera.bounce(speed=speed)
|
||||||
|
|
||||||
|
if camera:
|
||||||
|
pipeline.add_stage("camera", CameraStage(camera, name=camera_type))
|
||||||
|
|
||||||
|
# Add effects
|
||||||
|
effect_registry = get_registry()
|
||||||
|
for effect_name in effect_names:
|
||||||
|
effect = effect_registry.get(effect_name)
|
||||||
|
if effect:
|
||||||
|
pipeline.add_stage(
|
||||||
|
f"effect_{effect_name}", create_stage_from_effect(effect, effect_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add display
|
||||||
|
pipeline.add_stage("display", create_stage_from_display(display, display_name))
|
||||||
|
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create UI panel if border mode is UI
|
||||||
|
ui_panel = None
|
||||||
|
if params.border == BorderMode.UI:
|
||||||
|
ui_panel = UIPanel(UIConfig(panel_width=24, start_with_preset_picker=True))
|
||||||
|
# Enable raw mode for terminal input if supported
|
||||||
|
if hasattr(display, "set_raw_mode"):
|
||||||
|
display.set_raw_mode(True)
|
||||||
|
for stage in pipeline.stages.values():
|
||||||
|
if isinstance(stage, EffectPluginStage):
|
||||||
|
effect = stage._effect
|
||||||
|
enabled = effect.config.enabled if hasattr(effect, "config") else True
|
||||||
|
stage_control = ui_panel.register_stage(stage, enabled=enabled)
|
||||||
|
stage_control.effect = effect # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
if ui_panel.stages:
|
||||||
|
first_stage = next(iter(ui_panel.stages))
|
||||||
|
ui_panel.select_stage(first_stage)
|
||||||
|
ctrl = ui_panel.stages[first_stage]
|
||||||
|
if hasattr(ctrl, "effect"):
|
||||||
|
effect = ctrl.effect
|
||||||
|
if hasattr(effect, "config"):
|
||||||
|
config = effect.config
|
||||||
|
try:
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
if dataclasses.is_dataclass(config):
|
||||||
|
for field_name, field_obj in dataclasses.fields(config):
|
||||||
|
if field_name == "enabled":
|
||||||
|
continue
|
||||||
|
value = getattr(config, field_name, None)
|
||||||
|
if value is not None:
|
||||||
|
ctrl.params[field_name] = value
|
||||||
|
ctrl.param_schema[field_name] = {
|
||||||
|
"type": type(value).__name__,
|
||||||
|
"min": 0
|
||||||
|
if isinstance(value, (int, float))
|
||||||
|
else None,
|
||||||
|
"max": 1 if isinstance(value, float) else None,
|
||||||
|
"step": 0.1 if isinstance(value, float) else 1,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Run pipeline loop
|
||||||
|
from engine.display import render_ui_panel
|
||||||
|
|
||||||
|
ctx.set("display", display)
|
||||||
|
ctx.set("items", source_items)
|
||||||
|
ctx.set("pipeline", pipeline)
|
||||||
|
ctx.set("pipeline_order", pipeline.execution_order)
|
||||||
|
|
||||||
|
current_width = params.viewport_width
|
||||||
|
current_height = params.viewport_height
|
||||||
|
|
||||||
|
# Only get dimensions from display if viewport wasn't explicitly set
|
||||||
|
if "--viewport" not in sys.argv and hasattr(display, "get_dimensions"):
|
||||||
|
current_width, current_height = display.get_dimensions()
|
||||||
|
params.viewport_width = current_width
|
||||||
|
params.viewport_height = current_height
|
||||||
|
|
||||||
|
print(" \033[38;5;82mStarting pipeline...\033[0m")
|
||||||
|
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = 0
|
||||||
|
while True:
|
||||||
|
params.frame_number = frame
|
||||||
|
ctx.params = params
|
||||||
|
|
||||||
|
result = pipeline.execute(source_items)
|
||||||
|
if not result.success:
|
||||||
|
error_msg = f" ({result.error})" if result.error else ""
|
||||||
|
print(f" \033[38;5;196mPipeline execution failed{error_msg}\033[0m")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Render with UI panel
|
||||||
|
if ui_panel is not None:
|
||||||
|
buf = render_ui_panel(
|
||||||
|
result.data, current_width, current_height, ui_panel
|
||||||
|
)
|
||||||
|
display.show(buf, border=False)
|
||||||
|
else:
|
||||||
|
display.show(result.data, border=border_mode)
|
||||||
|
|
||||||
|
# Handle keyboard events if UI is enabled
|
||||||
|
if ui_panel is not None:
|
||||||
|
# Try pygame first
|
||||||
|
if hasattr(display, "_pygame"):
|
||||||
|
try:
|
||||||
|
import pygame
|
||||||
|
|
||||||
|
for event in pygame.event.get():
|
||||||
|
if event.type == pygame.KEYDOWN:
|
||||||
|
ui_panel.process_key_event(event.key, event.mod)
|
||||||
|
except (ImportError, Exception):
|
||||||
|
pass
|
||||||
|
# Try terminal input
|
||||||
|
elif hasattr(display, "get_input_keys"):
|
||||||
|
try:
|
||||||
|
keys = display.get_input_keys()
|
||||||
|
for key in keys:
|
||||||
|
ui_panel.process_key_event(key, 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for quit request
|
||||||
|
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
|
||||||
|
if hasattr(display, "clear_quit_request"):
|
||||||
|
display.clear_quit_request()
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
|
||||||
|
time.sleep(1 / 60)
|
||||||
|
frame += 1
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pipeline.cleanup()
|
||||||
|
display.cleanup()
|
||||||
|
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
||||||
|
return
|
||||||
|
|
||||||
|
pipeline.cleanup()
|
||||||
|
display.cleanup()
|
||||||
|
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
||||||
852
engine/app/pipeline_runner.py
Normal file
852
engine/app/pipeline_runner.py
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
"""
|
||||||
|
Pipeline runner - handles preset-based pipeline construction and execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.display import BorderMode, DisplayRegistry
|
||||||
|
from engine.effects import get_registry
|
||||||
|
from engine.fetch import fetch_all, fetch_all_fast, fetch_poetry, load_cache, save_cache
|
||||||
|
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext, get_preset
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
|
EffectPluginStage,
|
||||||
|
SourceItemsToBufferStage,
|
||||||
|
create_stage_from_display,
|
||||||
|
create_stage_from_effect,
|
||||||
|
)
|
||||||
|
from engine.pipeline.ui import UIConfig, UIPanel
|
||||||
|
|
||||||
|
try:
|
||||||
|
from engine.display.backends.websocket import WebSocketDisplay
|
||||||
|
except ImportError:
|
||||||
|
WebSocketDisplay = None
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_pipeline_mutation(pipeline: Pipeline, command: dict) -> bool:
|
||||||
|
"""Handle pipeline mutation commands from WebSocket or other external control.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pipeline: The pipeline to mutate
|
||||||
|
command: Command dictionary with 'action' and other parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if command was successfully handled, False otherwise
|
||||||
|
"""
|
||||||
|
action = command.get("action")
|
||||||
|
|
||||||
|
if action == "add_stage":
|
||||||
|
# For now, this just returns True to acknowledge the command
|
||||||
|
# In a full implementation, we'd need to create the appropriate stage
|
||||||
|
print(f" [Pipeline] add_stage command received: {command}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "remove_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
result = pipeline.remove_stage(stage_name)
|
||||||
|
print(f" [Pipeline] Removed stage '{stage_name}': {result is not None}")
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
elif action == "replace_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
# For now, this just returns True to acknowledge the command
|
||||||
|
print(f" [Pipeline] replace_stage command received: {command}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "swap_stages":
|
||||||
|
stage1 = command.get("stage1")
|
||||||
|
stage2 = command.get("stage2")
|
||||||
|
if stage1 and stage2:
|
||||||
|
result = pipeline.swap_stages(stage1, stage2)
|
||||||
|
print(f" [Pipeline] Swapped stages '{stage1}' and '{stage2}': {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif action == "move_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
after = command.get("after")
|
||||||
|
before = command.get("before")
|
||||||
|
if stage_name:
|
||||||
|
result = pipeline.move_stage(stage_name, after, before)
|
||||||
|
print(f" [Pipeline] Moved stage '{stage_name}': {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif action == "enable_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
result = pipeline.enable_stage(stage_name)
|
||||||
|
print(f" [Pipeline] Enabled stage '{stage_name}': {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif action == "disable_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
result = pipeline.disable_stage(stage_name)
|
||||||
|
print(f" [Pipeline] Disabled stage '{stage_name}': {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif action == "cleanup_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
pipeline.cleanup_stage(stage_name)
|
||||||
|
print(f" [Pipeline] Cleaned up stage '{stage_name}'")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "can_hot_swap":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
can_swap = pipeline.can_hot_swap(stage_name)
|
||||||
|
print(f" [Pipeline] Can hot-swap '{stage_name}': {can_swap}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline_mode(preset_name: str = "demo"):
|
||||||
|
"""Run using the new unified pipeline architecture."""
|
||||||
|
import engine.effects.plugins as effects_plugins
|
||||||
|
from engine.effects import PerformanceMonitor, set_monitor
|
||||||
|
|
||||||
|
print(" \033[1;38;5;46mPIPELINE MODE\033[0m")
|
||||||
|
print(" \033[38;5;245mUsing unified pipeline architecture\033[0m")
|
||||||
|
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
monitor = PerformanceMonitor()
|
||||||
|
set_monitor(monitor)
|
||||||
|
|
||||||
|
preset = get_preset(preset_name)
|
||||||
|
if not preset:
|
||||||
|
print(f" \033[38;5;196mUnknown preset: {preset_name}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f" \033[38;5;245mPreset: {preset.name} - {preset.description}\033[0m")
|
||||||
|
|
||||||
|
params = preset.to_params()
|
||||||
|
# Use preset viewport if available, else default to 80x24
|
||||||
|
params.viewport_width = getattr(preset, "viewport_width", 80)
|
||||||
|
params.viewport_height = getattr(preset, "viewport_height", 24)
|
||||||
|
|
||||||
|
if "--viewport" in sys.argv:
|
||||||
|
idx = sys.argv.index("--viewport")
|
||||||
|
if idx + 1 < len(sys.argv):
|
||||||
|
vp = sys.argv[idx + 1]
|
||||||
|
try:
|
||||||
|
params.viewport_width, params.viewport_height = map(int, vp.split("x"))
|
||||||
|
except ValueError:
|
||||||
|
print("Error: Invalid viewport format. Use WxH (e.g., 40x15)")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
pipeline = Pipeline(config=preset.to_config())
|
||||||
|
|
||||||
|
print(" \033[38;5;245mFetching content...\033[0m")
|
||||||
|
|
||||||
|
# Handle special sources that don't need traditional fetching
|
||||||
|
introspection_source = None
|
||||||
|
if preset.source == "pipeline-inspect":
|
||||||
|
items = []
|
||||||
|
print(" \033[38;5;245mUsing pipeline introspection source\033[0m")
|
||||||
|
elif preset.source == "empty":
|
||||||
|
items = []
|
||||||
|
print(" \033[38;5;245mUsing empty source (no content)\033[0m")
|
||||||
|
elif preset.source == "fixture":
|
||||||
|
items = load_cache()
|
||||||
|
if not items:
|
||||||
|
print(" \033[38;5;196mNo fixture cache available\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f" \033[38;5;82mLoaded {len(items)} items from fixture\033[0m")
|
||||||
|
else:
|
||||||
|
cached = load_cache()
|
||||||
|
if cached:
|
||||||
|
items = cached
|
||||||
|
print(f" \033[38;5;82mLoaded {len(items)} items from cache\033[0m")
|
||||||
|
elif preset.source == "poetry":
|
||||||
|
items, _, _ = fetch_poetry()
|
||||||
|
else:
|
||||||
|
items = fetch_all_fast()
|
||||||
|
if items:
|
||||||
|
print(
|
||||||
|
f" \033[38;5;82mFast start: {len(items)} items from first 5 sources\033[0m"
|
||||||
|
)
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def background_fetch():
|
||||||
|
full_items, _, _ = fetch_all()
|
||||||
|
save_cache(full_items)
|
||||||
|
|
||||||
|
background_thread = threading.Thread(target=background_fetch, daemon=True)
|
||||||
|
background_thread.start()
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
print(" \033[38;5;196mNo content available\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f" \033[38;5;82mLoaded {len(items)} items\033[0m")
|
||||||
|
|
||||||
|
# CLI --display flag takes priority over preset
|
||||||
|
# Check if --display was explicitly provided
|
||||||
|
display_name = preset.display
|
||||||
|
if "--display" in sys.argv:
|
||||||
|
idx = sys.argv.index("--display")
|
||||||
|
if idx + 1 < len(sys.argv):
|
||||||
|
display_name = sys.argv[idx + 1]
|
||||||
|
|
||||||
|
display = DisplayRegistry.create(display_name)
|
||||||
|
if not display and not display_name.startswith("multi"):
|
||||||
|
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Handle multi display (format: "multi:terminal,pygame")
|
||||||
|
if not display and display_name.startswith("multi"):
|
||||||
|
parts = display_name[6:].split(
|
||||||
|
","
|
||||||
|
) # "multi:terminal,pygame" -> ["terminal", "pygame"]
|
||||||
|
display = DisplayRegistry.create_multi(parts)
|
||||||
|
if not display:
|
||||||
|
print(f" \033[38;5;196mFailed to create multi display: {parts}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not display:
|
||||||
|
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
display.init(0, 0)
|
||||||
|
|
||||||
|
# Determine if we need UI controller for WebSocket or border=UI
|
||||||
|
need_ui_controller = False
|
||||||
|
web_control_active = False
|
||||||
|
if WebSocketDisplay and isinstance(display, WebSocketDisplay):
|
||||||
|
need_ui_controller = True
|
||||||
|
web_control_active = True
|
||||||
|
elif isinstance(params.border, BorderMode) and params.border == BorderMode.UI:
|
||||||
|
need_ui_controller = True
|
||||||
|
|
||||||
|
effect_registry = get_registry()
|
||||||
|
|
||||||
|
# Create source stage based on preset source type
|
||||||
|
if preset.source == "pipeline-inspect":
|
||||||
|
from engine.data_sources.pipeline_introspection import (
|
||||||
|
PipelineIntrospectionSource,
|
||||||
|
)
|
||||||
|
from engine.pipeline.adapters import DataSourceStage
|
||||||
|
|
||||||
|
introspection_source = PipelineIntrospectionSource(
|
||||||
|
pipeline=None, # Will be set after pipeline.build()
|
||||||
|
viewport_width=80,
|
||||||
|
viewport_height=24,
|
||||||
|
)
|
||||||
|
pipeline.add_stage(
|
||||||
|
"source", DataSourceStage(introspection_source, name="pipeline-inspect")
|
||||||
|
)
|
||||||
|
elif preset.source == "empty":
|
||||||
|
from engine.data_sources.sources import EmptyDataSource
|
||||||
|
from engine.pipeline.adapters import DataSourceStage
|
||||||
|
|
||||||
|
empty_source = EmptyDataSource(width=80, height=24)
|
||||||
|
pipeline.add_stage("source", DataSourceStage(empty_source, name="empty"))
|
||||||
|
else:
|
||||||
|
from engine.data_sources.sources import ListDataSource
|
||||||
|
from engine.pipeline.adapters import DataSourceStage
|
||||||
|
|
||||||
|
list_source = ListDataSource(items, name=preset.source)
|
||||||
|
pipeline.add_stage("source", DataSourceStage(list_source, name=preset.source))
|
||||||
|
|
||||||
|
# Add camera state update stage if specified in preset (must run before viewport filter)
|
||||||
|
camera = None
|
||||||
|
if preset.camera:
|
||||||
|
from engine.camera import Camera
|
||||||
|
from engine.pipeline.adapters import CameraClockStage, CameraStage
|
||||||
|
|
||||||
|
speed = getattr(preset, "camera_speed", 1.0)
|
||||||
|
if preset.camera == "feed":
|
||||||
|
camera = Camera.feed(speed=speed)
|
||||||
|
elif preset.camera == "scroll":
|
||||||
|
camera = Camera.scroll(speed=speed)
|
||||||
|
elif preset.camera == "vertical":
|
||||||
|
camera = Camera.scroll(speed=speed) # Backwards compat
|
||||||
|
elif preset.camera == "horizontal":
|
||||||
|
camera = Camera.horizontal(speed=speed)
|
||||||
|
elif preset.camera == "omni":
|
||||||
|
camera = Camera.omni(speed=speed)
|
||||||
|
elif preset.camera == "floating":
|
||||||
|
camera = Camera.floating(speed=speed)
|
||||||
|
elif preset.camera == "bounce":
|
||||||
|
camera = Camera.bounce(speed=speed)
|
||||||
|
elif preset.camera == "radial":
|
||||||
|
camera = Camera.radial(speed=speed)
|
||||||
|
elif preset.camera == "static" or preset.camera == "":
|
||||||
|
# Static camera: no movement, but provides camera_y=0 for viewport filter
|
||||||
|
camera = Camera.scroll(speed=0.0) # Speed 0 = no movement
|
||||||
|
camera.set_canvas_size(200, 200)
|
||||||
|
|
||||||
|
if camera:
|
||||||
|
# Add camera update stage to ensure camera_y is available for viewport filter
|
||||||
|
pipeline.add_stage(
|
||||||
|
"camera_update", CameraClockStage(camera, name="camera-clock")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add FontStage for headlines/poetry (default for demo)
|
||||||
|
if preset.source in ["headlines", "poetry"]:
|
||||||
|
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
||||||
|
|
||||||
|
# Add viewport filter to prevent rendering all items
|
||||||
|
pipeline.add_stage(
|
||||||
|
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
||||||
|
)
|
||||||
|
pipeline.add_stage("font", FontStage(name="font"))
|
||||||
|
else:
|
||||||
|
# Fallback to simple conversion for other sources
|
||||||
|
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
||||||
|
|
||||||
|
# Add camera stage if specified in preset (after font/render stage)
|
||||||
|
if camera:
|
||||||
|
pipeline.add_stage("camera", CameraStage(camera, name=preset.camera))
|
||||||
|
|
||||||
|
for effect_name in preset.effects:
|
||||||
|
effect = effect_registry.get(effect_name)
|
||||||
|
if effect:
|
||||||
|
pipeline.add_stage(
|
||||||
|
f"effect_{effect_name}", create_stage_from_effect(effect, effect_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline.add_stage("display", create_stage_from_display(display, display_name))
|
||||||
|
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
# For pipeline-inspect, set the pipeline after build to avoid circular dependency
|
||||||
|
if introspection_source is not None:
|
||||||
|
introspection_source.set_pipeline(pipeline)
|
||||||
|
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Initialize UI panel if needed (border mode or WebSocket control)
|
||||||
|
ui_panel = None
|
||||||
|
render_ui_panel_in_terminal = False
|
||||||
|
|
||||||
|
if need_ui_controller:
|
||||||
|
from engine.display import render_ui_panel
|
||||||
|
|
||||||
|
ui_panel = UIPanel(UIConfig(panel_width=24, start_with_preset_picker=True))
|
||||||
|
|
||||||
|
# Determine if we should render UI panel in terminal
|
||||||
|
# Only render if border mode is UI (not for WebSocket-only mode)
|
||||||
|
render_ui_panel_in_terminal = (
|
||||||
|
isinstance(params.border, BorderMode) and params.border == BorderMode.UI
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enable raw mode for terminal input if supported
|
||||||
|
if hasattr(display, "set_raw_mode"):
|
||||||
|
display.set_raw_mode(True)
|
||||||
|
|
||||||
|
# Register effect plugin stages from pipeline for UI control
|
||||||
|
for stage in pipeline.stages.values():
|
||||||
|
if isinstance(stage, EffectPluginStage):
|
||||||
|
effect = stage._effect
|
||||||
|
enabled = effect.config.enabled if hasattr(effect, "config") else True
|
||||||
|
stage_control = ui_panel.register_stage(stage, enabled=enabled)
|
||||||
|
# Store reference to effect for easier access
|
||||||
|
stage_control.effect = effect # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# Select first stage by default
|
||||||
|
if ui_panel.stages:
|
||||||
|
first_stage = next(iter(ui_panel.stages))
|
||||||
|
ui_panel.select_stage(first_stage)
|
||||||
|
# Populate param schema from EffectConfig if it's a dataclass
|
||||||
|
ctrl = ui_panel.stages[first_stage]
|
||||||
|
if hasattr(ctrl, "effect"):
|
||||||
|
effect = ctrl.effect
|
||||||
|
if hasattr(effect, "config"):
|
||||||
|
config = effect.config
|
||||||
|
# Try to get fields via dataclasses if available
|
||||||
|
try:
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
if dataclasses.is_dataclass(config):
|
||||||
|
for field_name, field_obj in dataclasses.fields(config):
|
||||||
|
if field_name == "enabled":
|
||||||
|
continue
|
||||||
|
value = getattr(config, field_name, None)
|
||||||
|
if value is not None:
|
||||||
|
ctrl.params[field_name] = value
|
||||||
|
ctrl.param_schema[field_name] = {
|
||||||
|
"type": type(value).__name__,
|
||||||
|
"min": 0
|
||||||
|
if isinstance(value, (int, float))
|
||||||
|
else None,
|
||||||
|
"max": 1 if isinstance(value, float) else None,
|
||||||
|
"step": 0.1 if isinstance(value, float) else 1,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass # No dataclass fields, skip param UI
|
||||||
|
|
||||||
|
# Set up callback for stage toggles
|
||||||
|
def on_stage_toggled(stage_name: str, enabled: bool):
|
||||||
|
"""Update the actual stage's enabled state when UI toggles."""
|
||||||
|
stage = pipeline.get_stage(stage_name)
|
||||||
|
if stage:
|
||||||
|
# Set stage enabled flag for pipeline execution
|
||||||
|
stage._enabled = enabled
|
||||||
|
# Also update effect config if it's an EffectPluginStage
|
||||||
|
if isinstance(stage, EffectPluginStage):
|
||||||
|
stage._effect.config.enabled = enabled
|
||||||
|
|
||||||
|
# Broadcast state update if WebSocket is active
|
||||||
|
if web_control_active and isinstance(display, WebSocketDisplay):
|
||||||
|
state = display._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
display.broadcast_state(state)
|
||||||
|
|
||||||
|
ui_panel.set_event_callback("stage_toggled", on_stage_toggled)
|
||||||
|
|
||||||
|
# Set up callback for parameter changes
|
||||||
|
def on_param_changed(stage_name: str, param_name: str, value: Any):
|
||||||
|
"""Update the effect config when UI adjusts a parameter."""
|
||||||
|
stage = pipeline.get_stage(stage_name)
|
||||||
|
if stage and isinstance(stage, EffectPluginStage):
|
||||||
|
effect = stage._effect
|
||||||
|
if hasattr(effect, "config"):
|
||||||
|
setattr(effect.config, param_name, value)
|
||||||
|
# Mark effect as needing reconfiguration if it has a configure method
|
||||||
|
if hasattr(effect, "configure"):
|
||||||
|
try:
|
||||||
|
effect.configure(effect.config)
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore reconfiguration errors
|
||||||
|
|
||||||
|
# Broadcast state update if WebSocket is active
|
||||||
|
if web_control_active and isinstance(display, WebSocketDisplay):
|
||||||
|
state = display._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
display.broadcast_state(state)
|
||||||
|
|
||||||
|
ui_panel.set_event_callback("param_changed", on_param_changed)
|
||||||
|
|
||||||
|
# Set up preset list and handle preset changes
|
||||||
|
from engine.pipeline import list_presets
|
||||||
|
|
||||||
|
ui_panel.set_presets(list_presets(), preset_name)
|
||||||
|
|
||||||
|
# Connect WebSocket to UI panel for remote control
|
||||||
|
if web_control_active and isinstance(display, WebSocketDisplay):
|
||||||
|
display.set_controller(ui_panel)
|
||||||
|
|
||||||
|
def handle_websocket_command(command: dict) -> None:
|
||||||
|
"""Handle commands from WebSocket clients."""
|
||||||
|
action = command.get("action")
|
||||||
|
|
||||||
|
# Handle pipeline mutation commands directly
|
||||||
|
if action in (
|
||||||
|
"add_stage",
|
||||||
|
"remove_stage",
|
||||||
|
"replace_stage",
|
||||||
|
"swap_stages",
|
||||||
|
"move_stage",
|
||||||
|
"enable_stage",
|
||||||
|
"disable_stage",
|
||||||
|
"cleanup_stage",
|
||||||
|
"can_hot_swap",
|
||||||
|
):
|
||||||
|
result = _handle_pipeline_mutation(pipeline, command)
|
||||||
|
if result:
|
||||||
|
state = display._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
display.broadcast_state(state)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Handle UI panel commands
|
||||||
|
if ui_panel.execute_command(command):
|
||||||
|
# Broadcast updated state after command execution
|
||||||
|
state = display._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
display.broadcast_state(state)
|
||||||
|
|
||||||
|
display.set_command_callback(handle_websocket_command)
|
||||||
|
|
||||||
|
def on_preset_changed(preset_name: str):
|
||||||
|
"""Handle preset change from UI - rebuild pipeline."""
|
||||||
|
nonlocal \
|
||||||
|
pipeline, \
|
||||||
|
display, \
|
||||||
|
items, \
|
||||||
|
params, \
|
||||||
|
ui_panel, \
|
||||||
|
current_width, \
|
||||||
|
current_height, \
|
||||||
|
web_control_active, \
|
||||||
|
render_ui_panel_in_terminal
|
||||||
|
|
||||||
|
print(f" \033[38;5;245mSwitching to preset: {preset_name}\033[0m")
|
||||||
|
|
||||||
|
# Save current UI panel state before rebuild
|
||||||
|
ui_state = ui_panel.save_state() if ui_panel else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clean up old pipeline
|
||||||
|
pipeline.cleanup()
|
||||||
|
|
||||||
|
# Get new preset
|
||||||
|
new_preset = get_preset(preset_name)
|
||||||
|
if not new_preset:
|
||||||
|
print(f" \033[38;5;196mUnknown preset: {preset_name}\033[0m")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update params for new preset
|
||||||
|
params = new_preset.to_params()
|
||||||
|
params.viewport_width = current_width
|
||||||
|
params.viewport_height = current_height
|
||||||
|
|
||||||
|
# Reconstruct pipeline configuration
|
||||||
|
new_config = PipelineConfig(
|
||||||
|
source=new_preset.source,
|
||||||
|
display=new_preset.display,
|
||||||
|
camera=new_preset.camera,
|
||||||
|
effects=new_preset.effects,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new pipeline instance
|
||||||
|
pipeline = Pipeline(config=new_config, context=PipelineContext())
|
||||||
|
|
||||||
|
# Re-add stages (similar to initial construction)
|
||||||
|
# Source stage
|
||||||
|
if new_preset.source == "pipeline-inspect":
|
||||||
|
from engine.data_sources.pipeline_introspection import (
|
||||||
|
PipelineIntrospectionSource,
|
||||||
|
)
|
||||||
|
from engine.pipeline.adapters import DataSourceStage
|
||||||
|
|
||||||
|
introspection_source = PipelineIntrospectionSource(
|
||||||
|
pipeline=None,
|
||||||
|
viewport_width=current_width,
|
||||||
|
viewport_height=current_height,
|
||||||
|
)
|
||||||
|
pipeline.add_stage(
|
||||||
|
"source",
|
||||||
|
DataSourceStage(introspection_source, name="pipeline-inspect"),
|
||||||
|
)
|
||||||
|
elif new_preset.source == "empty":
|
||||||
|
from engine.data_sources.sources import EmptyDataSource
|
||||||
|
from engine.pipeline.adapters import DataSourceStage
|
||||||
|
|
||||||
|
empty_source = EmptyDataSource(
|
||||||
|
width=current_width, height=current_height
|
||||||
|
)
|
||||||
|
pipeline.add_stage(
|
||||||
|
"source", DataSourceStage(empty_source, name="empty")
|
||||||
|
)
|
||||||
|
elif new_preset.source == "fixture":
|
||||||
|
items = load_cache()
|
||||||
|
if not items:
|
||||||
|
print(" \033[38;5;196mNo fixture cache available\033[0m")
|
||||||
|
return
|
||||||
|
from engine.data_sources.sources import ListDataSource
|
||||||
|
from engine.pipeline.adapters import DataSourceStage
|
||||||
|
|
||||||
|
list_source = ListDataSource(items, name="fixture")
|
||||||
|
pipeline.add_stage(
|
||||||
|
"source", DataSourceStage(list_source, name="fixture")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fetch or use cached items
|
||||||
|
cached = load_cache()
|
||||||
|
if cached:
|
||||||
|
items = cached
|
||||||
|
elif new_preset.source == "poetry":
|
||||||
|
items, _, _ = fetch_poetry()
|
||||||
|
else:
|
||||||
|
items, _, _ = fetch_all()
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
print(" \033[38;5;196mNo content available\033[0m")
|
||||||
|
return
|
||||||
|
|
||||||
|
from engine.data_sources.sources import ListDataSource
|
||||||
|
from engine.pipeline.adapters import DataSourceStage
|
||||||
|
|
||||||
|
list_source = ListDataSource(items, name=new_preset.source)
|
||||||
|
pipeline.add_stage(
|
||||||
|
"source", DataSourceStage(list_source, name=new_preset.source)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add viewport filter and font for headline/poetry sources
|
||||||
|
if new_preset.source in ["headlines", "poetry", "fixture"]:
|
||||||
|
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
||||||
|
|
||||||
|
pipeline.add_stage(
|
||||||
|
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
||||||
|
)
|
||||||
|
pipeline.add_stage("font", FontStage(name="font"))
|
||||||
|
|
||||||
|
# Add camera if specified
|
||||||
|
if new_preset.camera:
|
||||||
|
from engine.camera import Camera
|
||||||
|
from engine.pipeline.adapters import CameraClockStage, CameraStage
|
||||||
|
|
||||||
|
speed = getattr(new_preset, "camera_speed", 1.0)
|
||||||
|
camera = None
|
||||||
|
cam_type = new_preset.camera
|
||||||
|
if cam_type == "feed":
|
||||||
|
camera = Camera.feed(speed=speed)
|
||||||
|
elif cam_type == "scroll" or cam_type == "vertical":
|
||||||
|
camera = Camera.scroll(speed=speed)
|
||||||
|
elif cam_type == "horizontal":
|
||||||
|
camera = Camera.horizontal(speed=speed)
|
||||||
|
elif cam_type == "omni":
|
||||||
|
camera = Camera.omni(speed=speed)
|
||||||
|
elif cam_type == "floating":
|
||||||
|
camera = Camera.floating(speed=speed)
|
||||||
|
elif cam_type == "bounce":
|
||||||
|
camera = Camera.bounce(speed=speed)
|
||||||
|
elif cam_type == "radial":
|
||||||
|
camera = Camera.radial(speed=speed)
|
||||||
|
elif cam_type == "static" or cam_type == "":
|
||||||
|
# Static camera: no movement, but provides camera_y=0 for viewport filter
|
||||||
|
camera = Camera.scroll(speed=0.0)
|
||||||
|
camera.set_canvas_size(200, 200)
|
||||||
|
|
||||||
|
if camera:
|
||||||
|
# Add camera update stage to ensure camera_y is available for viewport filter
|
||||||
|
pipeline.add_stage(
|
||||||
|
"camera_update",
|
||||||
|
CameraClockStage(camera, name="camera-clock"),
|
||||||
|
)
|
||||||
|
pipeline.add_stage("camera", CameraStage(camera, name=cam_type))
|
||||||
|
|
||||||
|
# Add effects
|
||||||
|
effect_registry = get_registry()
|
||||||
|
for effect_name in new_preset.effects:
|
||||||
|
effect = effect_registry.get(effect_name)
|
||||||
|
if effect:
|
||||||
|
pipeline.add_stage(
|
||||||
|
f"effect_{effect_name}",
|
||||||
|
create_stage_from_effect(effect, effect_name),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add display (respect CLI override)
|
||||||
|
display_name = new_preset.display
|
||||||
|
if "--display" in sys.argv:
|
||||||
|
idx = sys.argv.index("--display")
|
||||||
|
if idx + 1 < len(sys.argv):
|
||||||
|
display_name = sys.argv[idx + 1]
|
||||||
|
|
||||||
|
new_display = DisplayRegistry.create(display_name)
|
||||||
|
if not new_display and not display_name.startswith("multi"):
|
||||||
|
print(
|
||||||
|
f" \033[38;5;196mFailed to create display: {display_name}\033[0m"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not new_display and display_name.startswith("multi"):
|
||||||
|
parts = display_name[6:].split(",")
|
||||||
|
new_display = DisplayRegistry.create_multi(parts)
|
||||||
|
if not new_display:
|
||||||
|
print(
|
||||||
|
f" \033[38;5;196mFailed to create multi display: {parts}\033[0m"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not new_display:
|
||||||
|
print(
|
||||||
|
f" \033[38;5;196mFailed to create display: {display_name}\033[0m"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_display.init(0, 0)
|
||||||
|
|
||||||
|
pipeline.add_stage(
|
||||||
|
"display", create_stage_from_display(new_display, display_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
# Set pipeline for introspection source if needed
|
||||||
|
if (
|
||||||
|
new_preset.source == "pipeline-inspect"
|
||||||
|
and introspection_source is not None
|
||||||
|
):
|
||||||
|
introspection_source.set_pipeline(pipeline)
|
||||||
|
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Replace global references with new pipeline and display
|
||||||
|
display = new_display
|
||||||
|
|
||||||
|
# Reinitialize UI panel with new effect stages
|
||||||
|
# Update web_control_active for new display
|
||||||
|
web_control_active = WebSocketDisplay is not None and isinstance(
|
||||||
|
display, WebSocketDisplay
|
||||||
|
)
|
||||||
|
# Update render_ui_panel_in_terminal
|
||||||
|
render_ui_panel_in_terminal = (
|
||||||
|
isinstance(params.border, BorderMode)
|
||||||
|
and params.border == BorderMode.UI
|
||||||
|
)
|
||||||
|
|
||||||
|
if need_ui_controller:
|
||||||
|
ui_panel = UIPanel(
|
||||||
|
UIConfig(panel_width=24, start_with_preset_picker=True)
|
||||||
|
)
|
||||||
|
for stage in pipeline.stages.values():
|
||||||
|
if isinstance(stage, EffectPluginStage):
|
||||||
|
effect = stage._effect
|
||||||
|
enabled = (
|
||||||
|
effect.config.enabled
|
||||||
|
if hasattr(effect, "config")
|
||||||
|
else True
|
||||||
|
)
|
||||||
|
stage_control = ui_panel.register_stage(
|
||||||
|
stage, enabled=enabled
|
||||||
|
)
|
||||||
|
stage_control.effect = effect # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# Restore UI panel state if it was saved
|
||||||
|
if ui_state:
|
||||||
|
ui_panel.restore_state(ui_state)
|
||||||
|
|
||||||
|
if ui_panel.stages:
|
||||||
|
first_stage = next(iter(ui_panel.stages))
|
||||||
|
ui_panel.select_stage(first_stage)
|
||||||
|
ctrl = ui_panel.stages[first_stage]
|
||||||
|
if hasattr(ctrl, "effect"):
|
||||||
|
effect = ctrl.effect
|
||||||
|
if hasattr(effect, "config"):
|
||||||
|
config = effect.config
|
||||||
|
try:
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
if dataclasses.is_dataclass(config):
|
||||||
|
for field_name, field_obj in dataclasses.fields(
|
||||||
|
config
|
||||||
|
):
|
||||||
|
if field_name == "enabled":
|
||||||
|
continue
|
||||||
|
value = getattr(config, field_name, None)
|
||||||
|
if value is not None:
|
||||||
|
ctrl.params[field_name] = value
|
||||||
|
ctrl.param_schema[field_name] = {
|
||||||
|
"type": type(value).__name__,
|
||||||
|
"min": 0
|
||||||
|
if isinstance(value, (int, float))
|
||||||
|
else None,
|
||||||
|
"max": 1
|
||||||
|
if isinstance(value, float)
|
||||||
|
else None,
|
||||||
|
"step": 0.1
|
||||||
|
if isinstance(value, float)
|
||||||
|
else 1,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Reconnect WebSocket to UI panel if needed
|
||||||
|
if web_control_active and isinstance(display, WebSocketDisplay):
|
||||||
|
display.set_controller(ui_panel)
|
||||||
|
|
||||||
|
def handle_websocket_command(command: dict) -> None:
|
||||||
|
"""Handle commands from WebSocket clients."""
|
||||||
|
if ui_panel.execute_command(command):
|
||||||
|
# Broadcast updated state after command execution
|
||||||
|
state = display._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
display.broadcast_state(state)
|
||||||
|
|
||||||
|
display.set_command_callback(handle_websocket_command)
|
||||||
|
|
||||||
|
# Broadcast initial state after preset change
|
||||||
|
state = display._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
display.broadcast_state(state)
|
||||||
|
|
||||||
|
print(f" \033[38;5;82mPreset switched to {preset_name}\033[0m")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" \033[38;5;196mError switching preset: {e}\033[0m")
|
||||||
|
|
||||||
|
ui_panel.set_event_callback("preset_changed", on_preset_changed)
|
||||||
|
|
||||||
|
print(" \033[38;5;82mStarting pipeline...\033[0m")
|
||||||
|
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
||||||
|
|
||||||
|
ctx = pipeline.context
|
||||||
|
ctx.params = params
|
||||||
|
ctx.set("display", display)
|
||||||
|
ctx.set("items", items)
|
||||||
|
ctx.set("pipeline", pipeline)
|
||||||
|
ctx.set("pipeline_order", pipeline.execution_order)
|
||||||
|
ctx.set("camera_y", 0)
|
||||||
|
|
||||||
|
current_width = params.viewport_width
|
||||||
|
current_height = params.viewport_height
|
||||||
|
|
||||||
|
# Only get dimensions from display if viewport wasn't explicitly set
|
||||||
|
if "--viewport" not in sys.argv and hasattr(display, "get_dimensions"):
|
||||||
|
current_width, current_height = display.get_dimensions()
|
||||||
|
params.viewport_width = current_width
|
||||||
|
params.viewport_height = current_height
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = 0
|
||||||
|
while True:
|
||||||
|
params.frame_number = frame
|
||||||
|
ctx.params = params
|
||||||
|
|
||||||
|
result = pipeline.execute(items)
|
||||||
|
if result.success:
|
||||||
|
# Handle UI panel compositing if enabled
|
||||||
|
if ui_panel is not None and render_ui_panel_in_terminal:
|
||||||
|
from engine.display import render_ui_panel
|
||||||
|
|
||||||
|
buf = render_ui_panel(
|
||||||
|
result.data,
|
||||||
|
current_width,
|
||||||
|
current_height,
|
||||||
|
ui_panel,
|
||||||
|
fps=params.fps if hasattr(params, "fps") else 60.0,
|
||||||
|
frame_time=0.0,
|
||||||
|
)
|
||||||
|
# Render with border=OFF since we already added borders
|
||||||
|
display.show(buf, border=False)
|
||||||
|
# Handle pygame events for UI
|
||||||
|
if display_name == "pygame":
|
||||||
|
import pygame
|
||||||
|
|
||||||
|
for event in pygame.event.get():
|
||||||
|
if event.type == pygame.KEYDOWN:
|
||||||
|
ui_panel.process_key_event(event.key, event.mod)
|
||||||
|
# If space toggled stage, we could rebuild here (TODO)
|
||||||
|
else:
|
||||||
|
# Normal border handling
|
||||||
|
show_border = (
|
||||||
|
params.border if isinstance(params.border, bool) else False
|
||||||
|
)
|
||||||
|
display.show(result.data, border=show_border)
|
||||||
|
|
||||||
|
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
|
||||||
|
if hasattr(display, "clear_quit_request"):
|
||||||
|
display.clear_quit_request()
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
|
||||||
|
if "--viewport" not in sys.argv and hasattr(display, "get_dimensions"):
|
||||||
|
new_w, new_h = display.get_dimensions()
|
||||||
|
if new_w != current_width or new_h != current_height:
|
||||||
|
current_width, current_height = new_w, new_h
|
||||||
|
params.viewport_width = current_width
|
||||||
|
params.viewport_height = current_height
|
||||||
|
|
||||||
|
time.sleep(1 / 60)
|
||||||
|
frame += 1
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pipeline.cleanup()
|
||||||
|
display.cleanup()
|
||||||
|
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
||||||
|
return
|
||||||
|
|
||||||
|
pipeline.cleanup()
|
||||||
|
display.cleanup()
|
||||||
|
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
||||||
73
engine/benchmark.py
Normal file
73
engine/benchmark.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Benchmark module for performance testing.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m engine.benchmark # Run all benchmarks
|
||||||
|
python -m engine.benchmark --hook # Run benchmarks in hook mode (for CI)
|
||||||
|
python -m engine.benchmark --displays null --iterations 20
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Run performance benchmarks")
|
||||||
|
parser.add_argument(
|
||||||
|
"--hook",
|
||||||
|
action="store_true",
|
||||||
|
help="Run in hook mode (fail on regression)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--displays",
|
||||||
|
default="null",
|
||||||
|
help="Comma-separated list of displays to benchmark",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--iterations",
|
||||||
|
type=int,
|
||||||
|
default=100,
|
||||||
|
help="Number of iterations per benchmark",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Run pytest with benchmark markers
|
||||||
|
pytest_args = [
|
||||||
|
"-v",
|
||||||
|
"-m",
|
||||||
|
"benchmark",
|
||||||
|
]
|
||||||
|
|
||||||
|
if args.hook:
|
||||||
|
# Hook mode: stricter settings
|
||||||
|
pytest_args.extend(
|
||||||
|
[
|
||||||
|
"--benchmark-only",
|
||||||
|
"--benchmark-compare",
|
||||||
|
"--benchmark-compare-fail=min:5%", # Fail if >5% slower
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add display filter if specified
|
||||||
|
if args.displays:
|
||||||
|
pytest_args.extend(["-k", args.displays])
|
||||||
|
|
||||||
|
# Add iterations
|
||||||
|
if args.iterations:
|
||||||
|
# Set environment variable for benchmark tests
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["BENCHMARK_ITERATIONS"] = str(args.iterations)
|
||||||
|
|
||||||
|
# Run pytest
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "pytest", "tests/test_benchmark.py"] + pytest_args,
|
||||||
|
cwd=None, # Current directory
|
||||||
|
)
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
473
engine/camera.py
Normal file
473
engine/camera.py
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
"""
|
||||||
|
Camera system for viewport scrolling.
|
||||||
|
|
||||||
|
Provides abstraction for camera motion in different modes:
|
||||||
|
- Vertical: traditional upward scroll
|
||||||
|
- Horizontal: left/right movement
|
||||||
|
- Omni: combination of both
|
||||||
|
- Floating: sinusoidal/bobbing motion
|
||||||
|
|
||||||
|
The camera defines a visible viewport into a larger Canvas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
|
||||||
|
class CameraMode(Enum):
|
||||||
|
FEED = auto() # Single item view (static or rapid cycling)
|
||||||
|
SCROLL = auto() # Smooth vertical scrolling (movie credits style)
|
||||||
|
HORIZONTAL = auto()
|
||||||
|
OMNI = auto()
|
||||||
|
FLOATING = auto()
|
||||||
|
BOUNCE = auto()
|
||||||
|
RADIAL = auto() # Polar coordinates (r, theta) for radial scanning
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CameraViewport:
|
||||||
|
"""Represents the visible viewport."""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Camera:
|
||||||
|
"""Camera for viewport scrolling.
|
||||||
|
|
||||||
|
The camera defines a visible viewport into a Canvas.
|
||||||
|
It can be smaller than the canvas to allow scrolling,
|
||||||
|
and supports zoom to scale the view.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
x: Current horizontal offset (positive = scroll left)
|
||||||
|
y: Current vertical offset (positive = scroll up)
|
||||||
|
mode: Current camera mode
|
||||||
|
speed: Base scroll speed
|
||||||
|
zoom: Zoom factor (1.0 = 100%, 2.0 = 200% zoom out)
|
||||||
|
canvas_width: Width of the canvas being viewed
|
||||||
|
canvas_height: Height of the canvas being viewed
|
||||||
|
custom_update: Optional custom update function
|
||||||
|
"""
|
||||||
|
|
||||||
|
x: int = 0
|
||||||
|
y: int = 0
|
||||||
|
mode: CameraMode = CameraMode.FEED
|
||||||
|
speed: float = 1.0
|
||||||
|
zoom: float = 1.0
|
||||||
|
canvas_width: int = 200 # Larger than viewport for scrolling
|
||||||
|
canvas_height: int = 200
|
||||||
|
custom_update: Callable[["Camera", float], None] | None = None
|
||||||
|
_x_float: float = field(default=0.0, repr=False)
|
||||||
|
_y_float: float = field(default=0.0, repr=False)
|
||||||
|
_time: float = field(default=0.0, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def w(self) -> int:
|
||||||
|
"""Shorthand for viewport_width."""
|
||||||
|
return self.viewport_width
|
||||||
|
|
||||||
|
def set_speed(self, speed: float) -> None:
|
||||||
|
"""Set the camera scroll speed dynamically.
|
||||||
|
|
||||||
|
This allows camera speed to be modulated during runtime
|
||||||
|
via PipelineParams or directly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
speed: New speed value (0.0 = stopped, >0 = movement)
|
||||||
|
"""
|
||||||
|
self.speed = max(0.0, speed)
|
||||||
|
|
||||||
|
@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, viewport_height: int | None = None) -> CameraViewport:
|
||||||
|
"""Get the current viewport bounds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
viewport_height: Optional viewport height to use instead of camera's viewport_height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CameraViewport with position and size (clamped to canvas bounds)
|
||||||
|
"""
|
||||||
|
vw = self.viewport_width
|
||||||
|
vh = viewport_height if viewport_height is not None else 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CameraViewport(
|
||||||
|
x=clamped_x,
|
||||||
|
y=clamped_y,
|
||||||
|
width=vw,
|
||||||
|
height=vh,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_zoom(self, zoom: float) -> None:
|
||||||
|
"""Set the zoom factor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
zoom: Zoom factor (1.0 = 100%, 2.0 = zoomed out 2x, 0.5 = zoomed in 2x)
|
||||||
|
"""
|
||||||
|
self.zoom = max(0.1, min(10.0, zoom))
|
||||||
|
|
||||||
|
def update(self, dt: float) -> None:
|
||||||
|
"""Update camera position based on mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Delta time in seconds
|
||||||
|
"""
|
||||||
|
self._time += dt
|
||||||
|
|
||||||
|
if self.custom_update:
|
||||||
|
self.custom_update(self, dt)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.mode == CameraMode.FEED:
|
||||||
|
self._update_feed(dt)
|
||||||
|
elif self.mode == CameraMode.SCROLL:
|
||||||
|
self._update_scroll(dt)
|
||||||
|
elif self.mode == CameraMode.HORIZONTAL:
|
||||||
|
self._update_horizontal(dt)
|
||||||
|
elif self.mode == CameraMode.OMNI:
|
||||||
|
self._update_omni(dt)
|
||||||
|
elif self.mode == CameraMode.FLOATING:
|
||||||
|
self._update_floating(dt)
|
||||||
|
elif self.mode == CameraMode.BOUNCE:
|
||||||
|
self._update_bounce(dt)
|
||||||
|
elif self.mode == CameraMode.RADIAL:
|
||||||
|
self._update_radial(dt)
|
||||||
|
|
||||||
|
# Bounce mode handles its own bounds checking
|
||||||
|
if self.mode != CameraMode.BOUNCE:
|
||||||
|
self._clamp_to_bounds()
|
||||||
|
|
||||||
|
def _clamp_to_bounds(self) -> None:
|
||||||
|
"""Clamp camera position to stay within canvas bounds.
|
||||||
|
|
||||||
|
Only clamps if the viewport is smaller than the canvas.
|
||||||
|
If viewport equals canvas (no scrolling needed), allows any position
|
||||||
|
for backwards compatibility with original behavior.
|
||||||
|
"""
|
||||||
|
vw = self.viewport_width
|
||||||
|
vh = self.viewport_height
|
||||||
|
|
||||||
|
# Only clamp if there's room to scroll
|
||||||
|
if vw < self.canvas_width:
|
||||||
|
self.x = max(0, min(self.x, self.canvas_width - vw))
|
||||||
|
if vh < self.canvas_height:
|
||||||
|
self.y = max(0, min(self.y, self.canvas_height - vh))
|
||||||
|
|
||||||
|
def _update_feed(self, dt: float) -> None:
|
||||||
|
"""Feed mode: rapid scrolling (1 row per frame at speed=1.0)."""
|
||||||
|
self.y += int(self.speed * dt * 60)
|
||||||
|
|
||||||
|
def _update_scroll(self, dt: float) -> None:
|
||||||
|
"""Scroll mode: smooth vertical scrolling with float accumulation."""
|
||||||
|
self._y_float += self.speed * dt * 60
|
||||||
|
self.y = int(self._y_float)
|
||||||
|
|
||||||
|
def _update_horizontal(self, dt: float) -> None:
|
||||||
|
self.x += int(self.speed * dt * 60)
|
||||||
|
|
||||||
|
def _update_omni(self, dt: float) -> None:
|
||||||
|
speed = self.speed * dt * 60
|
||||||
|
self.y += int(speed)
|
||||||
|
self.x += int(speed * 0.5)
|
||||||
|
|
||||||
|
def _update_floating(self, dt: float) -> None:
|
||||||
|
base = self.speed * 30
|
||||||
|
self.y = int(math.sin(self._time * 2) * base)
|
||||||
|
self.x = int(math.cos(self._time * 1.5) * base * 0.5)
|
||||||
|
|
||||||
|
def _update_bounce(self, dt: float) -> None:
|
||||||
|
"""Bouncing DVD-style camera that bounces off canvas edges."""
|
||||||
|
vw = self.viewport_width
|
||||||
|
vh = self.viewport_height
|
||||||
|
|
||||||
|
# Initialize direction if not set
|
||||||
|
if not hasattr(self, "_bounce_dx"):
|
||||||
|
self._bounce_dx = 1
|
||||||
|
self._bounce_dy = 1
|
||||||
|
|
||||||
|
# Calculate max positions
|
||||||
|
max_x = max(0, self.canvas_width - vw)
|
||||||
|
max_y = max(0, self.canvas_height - vh)
|
||||||
|
|
||||||
|
# Move
|
||||||
|
move_speed = self.speed * dt * 60
|
||||||
|
|
||||||
|
# Bounce off edges - reverse direction when hitting bounds
|
||||||
|
self.x += int(move_speed * self._bounce_dx)
|
||||||
|
self.y += int(move_speed * self._bounce_dy)
|
||||||
|
|
||||||
|
# Bounce horizontally
|
||||||
|
if self.x <= 0:
|
||||||
|
self.x = 0
|
||||||
|
self._bounce_dx = 1
|
||||||
|
elif self.x >= max_x:
|
||||||
|
self.x = max_x
|
||||||
|
self._bounce_dx = -1
|
||||||
|
|
||||||
|
# Bounce vertically
|
||||||
|
if self.y <= 0:
|
||||||
|
self.y = 0
|
||||||
|
self._bounce_dy = 1
|
||||||
|
elif self.y >= max_y:
|
||||||
|
self.y = max_y
|
||||||
|
self._bounce_dy = -1
|
||||||
|
|
||||||
|
def _update_radial(self, dt: float) -> None:
|
||||||
|
"""Radial camera mode: polar coordinate scrolling (r, theta).
|
||||||
|
|
||||||
|
The camera rotates around the center of the canvas while optionally
|
||||||
|
moving outward/inward along rays. This enables:
|
||||||
|
- Radar sweep animations
|
||||||
|
- Pendulum view oscillation
|
||||||
|
- Spiral scanning motion
|
||||||
|
|
||||||
|
Uses polar coordinates internally:
|
||||||
|
- _r_float: radial distance from center (accumulates smoothly)
|
||||||
|
- _theta_float: angle in radians (accumulates smoothly)
|
||||||
|
- Updates x, y based on conversion from polar to Cartesian
|
||||||
|
"""
|
||||||
|
# Initialize radial state if needed
|
||||||
|
if not hasattr(self, "_r_float"):
|
||||||
|
self._r_float = 0.0
|
||||||
|
self._theta_float = 0.0
|
||||||
|
|
||||||
|
# Update angular position (rotation around center)
|
||||||
|
# Speed controls rotation rate
|
||||||
|
theta_speed = self.speed * dt * 1.0 # radians per second
|
||||||
|
self._theta_float += theta_speed
|
||||||
|
|
||||||
|
# Update radial position (inward/outward from center)
|
||||||
|
# Can be modulated by external sensor
|
||||||
|
if hasattr(self, "_radial_input"):
|
||||||
|
r_input = self._radial_input
|
||||||
|
else:
|
||||||
|
# Default: slow outward drift
|
||||||
|
r_input = 0.0
|
||||||
|
|
||||||
|
r_speed = self.speed * dt * 20.0 # pixels per second
|
||||||
|
self._r_float += r_input + r_speed * 0.01
|
||||||
|
|
||||||
|
# Clamp radial position to canvas bounds
|
||||||
|
max_r = min(self.canvas_width, self.canvas_height) / 2
|
||||||
|
self._r_float = max(0.0, min(self._r_float, max_r))
|
||||||
|
|
||||||
|
# Convert polar to Cartesian, centered at canvas center
|
||||||
|
center_x = self.canvas_width / 2
|
||||||
|
center_y = self.canvas_height / 2
|
||||||
|
|
||||||
|
self.x = int(center_x + self._r_float * math.cos(self._theta_float))
|
||||||
|
self.y = int(center_y + self._r_float * math.sin(self._theta_float))
|
||||||
|
|
||||||
|
# Clamp to canvas bounds
|
||||||
|
self._clamp_to_bounds()
|
||||||
|
|
||||||
|
def set_radial_input(self, value: float) -> None:
|
||||||
|
"""Set radial input for sensor-driven radius modulation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Sensor value (0-1) that modulates radial distance
|
||||||
|
"""
|
||||||
|
self._radial_input = value * 10.0 # Scale to reasonable pixel range
|
||||||
|
|
||||||
|
def set_radial_angle(self, angle: float) -> None:
|
||||||
|
"""Set radial angle directly (for OSC integration).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
angle: Angle in radians (0 to 2π)
|
||||||
|
"""
|
||||||
|
self._theta_float = angle
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset camera position and state."""
|
||||||
|
self.x = 0
|
||||||
|
self.y = 0
|
||||||
|
self._time = 0.0
|
||||||
|
self.zoom = 1.0
|
||||||
|
# Reset bounce direction state
|
||||||
|
if hasattr(self, "_bounce_dx"):
|
||||||
|
self._bounce_dx = 1
|
||||||
|
self._bounce_dy = 1
|
||||||
|
# Reset radial state
|
||||||
|
if hasattr(self, "_r_float"):
|
||||||
|
self._r_float = 0.0
|
||||||
|
self._theta_float = 0.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(viewport_height)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# Skip padding for empty lines to preserve intentional blank lines
|
||||||
|
import re
|
||||||
|
|
||||||
|
visible_len = len(re.sub(r"\x1b\[[0-9;]*m", "", truncated_line))
|
||||||
|
if visible_len < viewport_width and visible_len > 0:
|
||||||
|
truncated_line += " " * (viewport_width - visible_len)
|
||||||
|
|
||||||
|
horizontal_slice.append(truncated_line)
|
||||||
|
|
||||||
|
# Pad with empty lines if needed to fill viewport height
|
||||||
|
while len(horizontal_slice) < vh:
|
||||||
|
horizontal_slice.append("")
|
||||||
|
|
||||||
|
return horizontal_slice
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def feed(cls, speed: float = 1.0) -> "Camera":
|
||||||
|
"""Create a feed camera (rapid single-item scrolling, 1 row/frame at speed=1.0)."""
|
||||||
|
return cls(mode=CameraMode.FEED, speed=speed, canvas_height=200)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def scroll(cls, speed: float = 0.5) -> "Camera":
|
||||||
|
"""Create a smooth scrolling camera (movie credits style).
|
||||||
|
|
||||||
|
Uses float accumulation for sub-integer speeds.
|
||||||
|
Sets canvas_width=0 so it matches viewport_width for proper text wrapping.
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
mode=CameraMode.SCROLL, speed=speed, canvas_width=0, canvas_height=200
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def vertical(cls, speed: float = 1.0) -> "Camera":
|
||||||
|
"""Deprecated: Use feed() or scroll() instead."""
|
||||||
|
return cls(mode=CameraMode.FEED, speed=speed, canvas_height=200)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def horizontal(cls, speed: float = 1.0) -> "Camera":
|
||||||
|
"""Create a horizontal scrolling camera."""
|
||||||
|
return cls(mode=CameraMode.HORIZONTAL, speed=speed, canvas_width=200)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def omni(cls, speed: float = 1.0) -> "Camera":
|
||||||
|
"""Create an omnidirectional scrolling camera."""
|
||||||
|
return cls(
|
||||||
|
mode=CameraMode.OMNI, speed=speed, canvas_width=200, canvas_height=200
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def floating(cls, speed: float = 1.0) -> "Camera":
|
||||||
|
"""Create a floating/bobbing camera."""
|
||||||
|
return cls(
|
||||||
|
mode=CameraMode.FLOATING, speed=speed, canvas_width=200, canvas_height=200
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def bounce(cls, speed: float = 1.0) -> "Camera":
|
||||||
|
"""Create a bouncing DVD-style camera that bounces off canvas edges."""
|
||||||
|
return cls(
|
||||||
|
mode=CameraMode.BOUNCE, speed=speed, canvas_width=200, canvas_height=200
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def radial(cls, speed: float = 1.0) -> "Camera":
|
||||||
|
"""Create a radial camera (polar coordinate scanning).
|
||||||
|
|
||||||
|
The camera rotates around the center of the canvas with smooth angular motion.
|
||||||
|
Enables radar sweep, pendulum view, and spiral scanning animations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
speed: Rotation speed (higher = faster rotation)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Camera configured for radial polar coordinate scanning
|
||||||
|
"""
|
||||||
|
cam = cls(
|
||||||
|
mode=CameraMode.RADIAL, speed=speed, canvas_width=200, canvas_height=200
|
||||||
|
)
|
||||||
|
# Initialize radial state
|
||||||
|
cam._r_float = 0.0
|
||||||
|
cam._theta_float = 0.0
|
||||||
|
return cam
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
||||||
|
"""Create a camera with custom update function."""
|
||||||
|
return cls(custom_update=update_fn)
|
||||||
186
engine/canvas.py
Normal file
186
engine/canvas.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
Canvas - 2D surface for rendering.
|
||||||
|
|
||||||
|
The Canvas represents a full rendered surface that can be larger than the display.
|
||||||
|
The Camera then defines the visible viewport into this canvas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CanvasRegion:
|
||||||
|
"""A rectangular region on the canvas."""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
def is_valid(self) -> bool:
|
||||||
|
"""Check if region has positive dimensions."""
|
||||||
|
return self.width > 0 and self.height > 0
|
||||||
|
|
||||||
|
def rows(self) -> set[int]:
|
||||||
|
"""Return set of row indices in this region."""
|
||||||
|
return set(range(self.y, self.y + self.height))
|
||||||
|
|
||||||
|
|
||||||
|
class Canvas:
|
||||||
|
"""2D canvas for rendering content.
|
||||||
|
|
||||||
|
The canvas is a 2D grid of cells that can hold text content.
|
||||||
|
It can be larger than the visible viewport (display).
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
width: Total width in characters
|
||||||
|
height: Total height in characters
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, width: int = 80, height: int = 24):
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._grid: list[list[str]] = [
|
||||||
|
[" " for _ in range(width)] for _ in range(height)
|
||||||
|
]
|
||||||
|
self._dirty_regions: list[CanvasRegion] = [] # Track dirty regions
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the entire canvas."""
|
||||||
|
self._grid = [[" " for _ in range(self.width)] for _ in range(self.height)]
|
||||||
|
self._dirty_regions = [CanvasRegion(0, 0, self.width, self.height)]
|
||||||
|
|
||||||
|
def mark_dirty(self, x: int, y: int, width: int, height: int) -> None:
|
||||||
|
"""Mark a region as dirty (caller declares what they changed)."""
|
||||||
|
self._dirty_regions.append(CanvasRegion(x, y, width, height))
|
||||||
|
|
||||||
|
def get_dirty_regions(self) -> list[CanvasRegion]:
|
||||||
|
"""Get all dirty regions and clear the set."""
|
||||||
|
regions = self._dirty_regions
|
||||||
|
self._dirty_regions = []
|
||||||
|
return regions
|
||||||
|
|
||||||
|
def get_dirty_rows(self) -> set[int]:
|
||||||
|
"""Get union of all dirty rows."""
|
||||||
|
rows: set[int] = set()
|
||||||
|
for region in self._dirty_regions:
|
||||||
|
rows.update(region.rows())
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def is_dirty(self) -> bool:
|
||||||
|
"""Check if any region is dirty."""
|
||||||
|
return len(self._dirty_regions) > 0
|
||||||
|
|
||||||
|
def get_region(self, x: int, y: int, width: int, height: int) -> list[list[str]]:
|
||||||
|
"""Get a rectangular region from the canvas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Top position
|
||||||
|
width: Region width
|
||||||
|
height: Region height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
2D list of characters (height rows, width columns)
|
||||||
|
"""
|
||||||
|
region: list[list[str]] = []
|
||||||
|
for py in range(y, y + height):
|
||||||
|
row: list[str] = []
|
||||||
|
for px in range(x, x + width):
|
||||||
|
if 0 <= py < self.height and 0 <= px < self.width:
|
||||||
|
row.append(self._grid[py][px])
|
||||||
|
else:
|
||||||
|
row.append(" ")
|
||||||
|
region.append(row)
|
||||||
|
return region
|
||||||
|
|
||||||
|
def get_region_flat(self, x: int, y: int, width: int, height: int) -> list[str]:
|
||||||
|
"""Get a rectangular region as flat list of lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Top position
|
||||||
|
width: Region width
|
||||||
|
height: Region height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of strings (one per row)
|
||||||
|
"""
|
||||||
|
region = self.get_region(x, y, width, height)
|
||||||
|
return ["".join(row) for row in region]
|
||||||
|
|
||||||
|
def put_region(self, x: int, y: int, content: list[list[str]]) -> None:
|
||||||
|
"""Put content into a rectangular region on the canvas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Top position
|
||||||
|
content: 2D list of characters to place
|
||||||
|
"""
|
||||||
|
height = len(content) if content else 0
|
||||||
|
width = len(content[0]) if height > 0 else 0
|
||||||
|
|
||||||
|
for py, row in enumerate(content):
|
||||||
|
for px, char in enumerate(row):
|
||||||
|
canvas_x = x + px
|
||||||
|
canvas_y = y + py
|
||||||
|
if 0 <= canvas_y < self.height and 0 <= canvas_x < self.width:
|
||||||
|
self._grid[canvas_y][canvas_x] = char
|
||||||
|
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
self.mark_dirty(x, y, width, height)
|
||||||
|
|
||||||
|
def put_text(self, x: int, y: int, text: str) -> None:
|
||||||
|
"""Put a single line of text at position.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Row position
|
||||||
|
text: Text to place
|
||||||
|
"""
|
||||||
|
text_len = len(text)
|
||||||
|
for i, char in enumerate(text):
|
||||||
|
canvas_x = x + i
|
||||||
|
if 0 <= canvas_x < self.width and 0 <= y < self.height:
|
||||||
|
self._grid[y][canvas_x] = char
|
||||||
|
|
||||||
|
if text_len > 0:
|
||||||
|
self.mark_dirty(x, y, text_len, 1)
|
||||||
|
|
||||||
|
def fill(self, x: int, y: int, width: int, height: int, char: str = " ") -> None:
|
||||||
|
"""Fill a rectangular region with a character.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
x: Left position
|
||||||
|
y: Top position
|
||||||
|
width: Region width
|
||||||
|
height: Region height
|
||||||
|
char: Character to fill with
|
||||||
|
"""
|
||||||
|
for py in range(y, y + height):
|
||||||
|
for px in range(x, x + width):
|
||||||
|
if 0 <= py < self.height and 0 <= px < self.width:
|
||||||
|
self._grid[py][px] = char
|
||||||
|
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
self.mark_dirty(x, y, width, height)
|
||||||
|
|
||||||
|
def resize(self, width: int, height: int) -> None:
|
||||||
|
"""Resize the canvas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: New width
|
||||||
|
height: New height
|
||||||
|
"""
|
||||||
|
if width == self.width and height == self.height:
|
||||||
|
return
|
||||||
|
|
||||||
|
new_grid: list[list[str]] = [[" " for _ in range(width)] for _ in range(height)]
|
||||||
|
|
||||||
|
for py in range(min(self.height, height)):
|
||||||
|
for px in range(min(self.width, width)):
|
||||||
|
new_grid[py][px] = self._grid[py][px]
|
||||||
|
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._grid = new_grid
|
||||||
@@ -105,6 +105,8 @@ class Config:
|
|||||||
firehose: bool = False
|
firehose: bool = False
|
||||||
|
|
||||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
|
ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
ntfy_reconnect_delay: int = 5
|
ntfy_reconnect_delay: int = 5
|
||||||
message_display_secs: int = 30
|
message_display_secs: int = 30
|
||||||
|
|
||||||
@@ -127,6 +129,10 @@ class Config:
|
|||||||
|
|
||||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||||
|
|
||||||
|
display: str = "pygame"
|
||||||
|
websocket: bool = False
|
||||||
|
websocket_port: int = 8765
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||||
"""Create Config from CLI arguments (or custom argv for testing)."""
|
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||||
@@ -148,6 +154,8 @@ class Config:
|
|||||||
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||||
firehose="--firehose" in argv,
|
firehose="--firehose" in argv,
|
||||||
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||||
|
ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
|
||||||
|
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
|
||||||
ntfy_reconnect_delay=5,
|
ntfy_reconnect_delay=5,
|
||||||
message_display_secs=30,
|
message_display_secs=30,
|
||||||
font_dir=font_dir,
|
font_dir=font_dir,
|
||||||
@@ -164,6 +172,9 @@ class Config:
|
|||||||
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||||
script_fonts=_get_platform_font_paths(),
|
script_fonts=_get_platform_font_paths(),
|
||||||
|
display=_arg_value("--display", argv) or "terminal",
|
||||||
|
websocket="--websocket" in argv,
|
||||||
|
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -188,19 +199,13 @@ def set_config(config: Config) -> None:
|
|||||||
HEADLINE_LIMIT = 1000
|
HEADLINE_LIMIT = 1000
|
||||||
FEED_TIMEOUT = 10
|
FEED_TIMEOUT = 10
|
||||||
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
MIC_THRESHOLD_DB = 50 # dB above which glitches intensify
|
||||||
MODE = (
|
MODE = "poetry" if "--poetry" in sys.argv or "-p" in sys.argv else "news"
|
||||||
"poetry"
|
|
||||||
if "--poetry" in sys.argv or "-p" in sys.argv
|
|
||||||
else "code"
|
|
||||||
if "--code" in sys.argv
|
|
||||||
else "news"
|
|
||||||
)
|
|
||||||
FIREHOSE = "--firehose" in sys.argv
|
FIREHOSE = "--firehose" in sys.argv
|
||||||
FIGMENT = "--figment" in sys.argv
|
|
||||||
FIGMENT_INTERVAL = _arg_int("--figment-interval", 60) # seconds between appearances
|
|
||||||
|
|
||||||
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||||
|
NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||||
|
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||||
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||||
|
|
||||||
@@ -231,6 +236,26 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
|||||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||||
|
|
||||||
|
# ─── WEBSOCKET ─────────────────────────────────────────────
|
||||||
|
DISPLAY = _arg_value("--display", sys.argv) or "pygame"
|
||||||
|
WEBSOCKET = "--websocket" in sys.argv
|
||||||
|
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
||||||
|
|
||||||
|
# ─── DEMO MODE ────────────────────────────────────────────
|
||||||
|
DEMO = "--demo" in sys.argv
|
||||||
|
DEMO_EFFECT_DURATION = 5.0 # seconds per effect
|
||||||
|
PIPELINE_DEMO = "--pipeline-demo" in sys.argv
|
||||||
|
|
||||||
|
# ─── PIPELINE MODE (new unified architecture) ─────────────
|
||||||
|
PIPELINE_MODE = "--pipeline" in sys.argv
|
||||||
|
PIPELINE_PRESET = _arg_value("--pipeline-preset", sys.argv) or "demo"
|
||||||
|
|
||||||
|
# ─── PRESET MODE ────────────────────────────────────────────
|
||||||
|
PRESET = _arg_value("--preset", sys.argv)
|
||||||
|
|
||||||
|
# ─── PIPELINE DIAGRAM ────────────────────────────────────
|
||||||
|
PIPELINE_DIAGRAM = "--pipeline-diagram" in sys.argv
|
||||||
|
|
||||||
|
|
||||||
def set_font_selection(font_path=None, font_index=None):
|
def set_font_selection(font_path=None, font_index=None):
|
||||||
"""Set runtime primary font selection."""
|
"""Set runtime primary font selection."""
|
||||||
@@ -239,26 +264,3 @@ def set_font_selection(font_path=None, font_index=None):
|
|||||||
FONT_PATH = _resolve_font_path(font_path)
|
FONT_PATH = _resolve_font_path(font_path)
|
||||||
if font_index is not None:
|
if font_index is not None:
|
||||||
FONT_INDEX = max(0, int(font_index))
|
FONT_INDEX = max(0, int(font_index))
|
||||||
|
|
||||||
|
|
||||||
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
|
||||||
ACTIVE_THEME = None
|
|
||||||
|
|
||||||
|
|
||||||
def set_active_theme(theme_id: str = "green"):
|
|
||||||
"""Set the active theme by ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme_id: Theme identifier ("green", "orange", or "purple")
|
|
||||||
Defaults to "green"
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
KeyError: If theme_id is not in the theme registry
|
|
||||||
|
|
||||||
Side Effects:
|
|
||||||
Sets the ACTIVE_THEME global variable
|
|
||||||
"""
|
|
||||||
global ACTIVE_THEME
|
|
||||||
from engine import themes
|
|
||||||
|
|
||||||
ACTIVE_THEME = themes.get_theme(theme_id)
|
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
"""
|
|
||||||
Stream controller - manages input sources and orchestrates the render stream.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from engine.config import Config, get_config
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class StreamController:
|
|
||||||
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
return bool(mic_ok), ntfy_ok
|
|
||||||
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
stream(items, self.ntfy, self.mic)
|
|
||||||
|
|
||||||
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"]
|
||||||
60
engine/data_sources/checkerboard.py
Normal file
60
engine/data_sources/checkerboard.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Checkerboard data source for visual pattern generation."""
|
||||||
|
|
||||||
|
from engine.data_sources.sources import DataSource, SourceItem
|
||||||
|
|
||||||
|
|
||||||
|
class CheckerboardDataSource(DataSource):
|
||||||
|
"""Data source that generates a checkerboard pattern.
|
||||||
|
|
||||||
|
Creates a grid of alternating characters, useful for testing motion effects
|
||||||
|
and camera movement. The pattern is static; movement comes from camera panning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
width: int = 200,
|
||||||
|
height: int = 200,
|
||||||
|
square_size: int = 10,
|
||||||
|
char_a: str = "#",
|
||||||
|
char_b: str = " ",
|
||||||
|
):
|
||||||
|
"""Initialize checkerboard data source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Total pattern width in characters
|
||||||
|
height: Total pattern height in lines
|
||||||
|
square_size: Size of each checker square in characters
|
||||||
|
char_a: Character for "filled" squares (default: '#')
|
||||||
|
char_b: Character for "empty" squares (default: ' ')
|
||||||
|
"""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.square_size = square_size
|
||||||
|
self.char_a = char_a
|
||||||
|
self.char_b = char_b
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "checkerboard"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dynamic(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
"""Generate the checkerboard pattern as a single SourceItem."""
|
||||||
|
lines = []
|
||||||
|
for y in range(self.height):
|
||||||
|
line_chars = []
|
||||||
|
for x in range(self.width):
|
||||||
|
# Determine which square this position belongs to
|
||||||
|
square_x = x // self.square_size
|
||||||
|
square_y = y // self.square_size
|
||||||
|
# Alternate pattern based on parity of square coordinates
|
||||||
|
if (square_x + square_y) % 2 == 0:
|
||||||
|
line_chars.append(self.char_a)
|
||||||
|
else:
|
||||||
|
line_chars.append(self.char_b)
|
||||||
|
lines.append("".join(line_chars))
|
||||||
|
content = "\n".join(lines)
|
||||||
|
return [SourceItem(content=content, source="checkerboard", timestamp="0")]
|
||||||
312
engine/data_sources/pipeline_introspection.py
Normal file
312
engine/data_sources/pipeline_introspection.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
"""
|
||||||
|
Pipeline introspection source - Renders live visualization of pipeline DAG and metrics.
|
||||||
|
|
||||||
|
This DataSource introspects one or more Pipeline instances and renders
|
||||||
|
an ASCII visualization showing:
|
||||||
|
- Stage DAG with signal flow connections
|
||||||
|
- Per-stage execution times
|
||||||
|
- Sparkline of frame times
|
||||||
|
- Stage breakdown bars
|
||||||
|
|
||||||
|
Example:
|
||||||
|
source = PipelineIntrospectionSource(pipelines=[my_pipeline])
|
||||||
|
items = source.fetch() # Returns ASCII visualization
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from engine.data_sources.sources import DataSource, SourceItem
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from engine.pipeline.controller import Pipeline
|
||||||
|
|
||||||
|
|
||||||
|
SPARKLINE_CHARS = " ▁▂▃▄▅▆▇█"
|
||||||
|
BAR_CHARS = " ▁▂▃▄▅▆▇█"
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineIntrospectionSource(DataSource):
|
||||||
|
"""Data source that renders live pipeline introspection visualization.
|
||||||
|
|
||||||
|
Renders:
|
||||||
|
- DAG of stages with signal flow
|
||||||
|
- Per-stage execution times
|
||||||
|
- Sparkline of frame history
|
||||||
|
- Stage breakdown bars
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
pipeline: "Pipeline | None" = None,
|
||||||
|
viewport_width: int = 100,
|
||||||
|
viewport_height: int = 35,
|
||||||
|
):
|
||||||
|
self._pipeline = pipeline # May be None initially, set later via set_pipeline()
|
||||||
|
self.viewport_width = viewport_width
|
||||||
|
self.viewport_height = viewport_height
|
||||||
|
self.frame = 0
|
||||||
|
self._ready = False
|
||||||
|
|
||||||
|
def set_pipeline(self, pipeline: "Pipeline") -> None:
|
||||||
|
"""Set the pipeline to introspect (call after pipeline is built)."""
|
||||||
|
self._pipeline = [pipeline] # Wrap in list for iteration
|
||||||
|
self._ready = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Check if source is ready to fetch."""
|
||||||
|
return self._ready
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "pipeline-inspect"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dynamic(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.NONE}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
def add_pipeline(self, pipeline: "Pipeline") -> None:
|
||||||
|
"""Add a pipeline to visualize."""
|
||||||
|
if self._pipeline is None:
|
||||||
|
self._pipeline = [pipeline]
|
||||||
|
elif isinstance(self._pipeline, list):
|
||||||
|
self._pipeline.append(pipeline)
|
||||||
|
else:
|
||||||
|
self._pipeline = [self._pipeline, pipeline]
|
||||||
|
self._ready = True
|
||||||
|
|
||||||
|
def remove_pipeline(self, pipeline: "Pipeline") -> None:
|
||||||
|
"""Remove a pipeline from visualization."""
|
||||||
|
if self._pipeline is None:
|
||||||
|
return
|
||||||
|
elif isinstance(self._pipeline, list):
|
||||||
|
self._pipeline = [p for p in self._pipeline if p is not pipeline]
|
||||||
|
if not self._pipeline:
|
||||||
|
self._pipeline = None
|
||||||
|
self._ready = False
|
||||||
|
elif self._pipeline is pipeline:
|
||||||
|
self._pipeline = None
|
||||||
|
self._ready = False
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
"""Fetch the introspection visualization."""
|
||||||
|
if not self._ready:
|
||||||
|
# Return a placeholder until ready
|
||||||
|
return [
|
||||||
|
SourceItem(
|
||||||
|
content="Initializing...",
|
||||||
|
source="pipeline-inspect",
|
||||||
|
timestamp="init",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
lines = self._render()
|
||||||
|
self.frame += 1
|
||||||
|
content = "\n".join(lines)
|
||||||
|
return [
|
||||||
|
SourceItem(
|
||||||
|
content=content, source="pipeline-inspect", timestamp=f"f{self.frame}"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_items(self) -> list[SourceItem]:
|
||||||
|
return self.fetch()
|
||||||
|
|
||||||
|
def _render(self) -> list[str]:
|
||||||
|
"""Render the full visualization."""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
# Header
|
||||||
|
lines.extend(self._render_header())
|
||||||
|
|
||||||
|
# Render pipeline(s) if ready
|
||||||
|
if self._ready and self._pipeline:
|
||||||
|
pipelines = (
|
||||||
|
self._pipeline if isinstance(self._pipeline, list) else [self._pipeline]
|
||||||
|
)
|
||||||
|
for pipeline in pipelines:
|
||||||
|
lines.extend(self._render_pipeline(pipeline))
|
||||||
|
|
||||||
|
# Footer with sparkline
|
||||||
|
lines.extend(self._render_footer())
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _pipelines(self) -> list:
|
||||||
|
"""Return pipelines as a list for iteration."""
|
||||||
|
if self._pipeline is None:
|
||||||
|
return []
|
||||||
|
elif isinstance(self._pipeline, list):
|
||||||
|
return self._pipeline
|
||||||
|
else:
|
||||||
|
return [self._pipeline]
|
||||||
|
|
||||||
|
def _render_header(self) -> list[str]:
|
||||||
|
"""Render the header with frame info and metrics summary."""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
if not self._pipeline:
|
||||||
|
return ["PIPELINE INTROSPECTION"]
|
||||||
|
|
||||||
|
# Get aggregate metrics
|
||||||
|
total_ms = 0.0
|
||||||
|
fps = 0.0
|
||||||
|
frame_count = 0
|
||||||
|
|
||||||
|
for pipeline in self._pipelines:
|
||||||
|
try:
|
||||||
|
metrics = pipeline.get_metrics_summary()
|
||||||
|
if metrics and "error" not in metrics:
|
||||||
|
# Get avg_ms from pipeline metrics
|
||||||
|
pipeline_avg = metrics.get("pipeline", {}).get("avg_ms", 0)
|
||||||
|
total_ms = max(total_ms, pipeline_avg)
|
||||||
|
# Calculate FPS from avg_ms
|
||||||
|
if pipeline_avg > 0:
|
||||||
|
fps = max(fps, 1000.0 / pipeline_avg)
|
||||||
|
frame_count = max(frame_count, metrics.get("frame_count", 0))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
header = f"PIPELINE INTROSPECTION -- frame: {self.frame} -- avg: {total_ms:.1f}ms -- fps: {fps:.1f}"
|
||||||
|
lines.append(header)
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _render_pipeline(self, pipeline: "Pipeline") -> list[str]:
|
||||||
|
"""Render a single pipeline's DAG."""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
stages = pipeline.stages
|
||||||
|
execution_order = pipeline.execution_order
|
||||||
|
|
||||||
|
if not stages:
|
||||||
|
lines.append(" (no stages)")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
# Build stage info
|
||||||
|
stage_infos: list[dict] = []
|
||||||
|
for name in execution_order:
|
||||||
|
stage = stages.get(name)
|
||||||
|
if not stage:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
metrics = pipeline.get_metrics_summary()
|
||||||
|
stage_ms = metrics.get("stages", {}).get(name, {}).get("avg_ms", 0.0)
|
||||||
|
except Exception:
|
||||||
|
stage_ms = 0.0
|
||||||
|
|
||||||
|
stage_infos.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"category": stage.category,
|
||||||
|
"ms": stage_ms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate total time for percentages
|
||||||
|
total_time = sum(s["ms"] for s in stage_infos) or 1.0
|
||||||
|
|
||||||
|
# Render DAG - group by category
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" Signal Flow:")
|
||||||
|
|
||||||
|
# Group stages by category for display
|
||||||
|
categories: dict[str, list[dict]] = {}
|
||||||
|
for info in stage_infos:
|
||||||
|
cat = info["category"]
|
||||||
|
if cat not in categories:
|
||||||
|
categories[cat] = []
|
||||||
|
categories[cat].append(info)
|
||||||
|
|
||||||
|
# Render categories in order
|
||||||
|
cat_order = ["source", "render", "effect", "overlay", "display", "system"]
|
||||||
|
|
||||||
|
for cat in cat_order:
|
||||||
|
if cat not in categories:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cat_stages = categories[cat]
|
||||||
|
cat_names = [s["name"] for s in cat_stages]
|
||||||
|
lines.append(f" {cat}: {' → '.join(cat_names)}")
|
||||||
|
|
||||||
|
# Render timing breakdown
|
||||||
|
lines.append("")
|
||||||
|
lines.append(" Stage Timings:")
|
||||||
|
|
||||||
|
for info in stage_infos:
|
||||||
|
name = info["name"]
|
||||||
|
ms = info["ms"]
|
||||||
|
pct = (ms / total_time) * 100
|
||||||
|
bar = self._render_bar(pct, 20)
|
||||||
|
lines.append(f" {name:12s} {ms:6.2f}ms {bar} {pct:5.1f}%")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _render_footer(self) -> list[str]:
|
||||||
|
"""Render the footer with sparkline."""
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
# Get frame history from first pipeline
|
||||||
|
pipelines = self._pipelines
|
||||||
|
if pipelines:
|
||||||
|
try:
|
||||||
|
frame_times = pipelines[0].get_frame_times()
|
||||||
|
except Exception:
|
||||||
|
frame_times = []
|
||||||
|
else:
|
||||||
|
frame_times = []
|
||||||
|
|
||||||
|
if frame_times:
|
||||||
|
sparkline = self._render_sparkline(frame_times[-60:], 50)
|
||||||
|
lines.append(f" Frame Time History (last {len(frame_times[-60:])} frames)")
|
||||||
|
lines.append(f" {sparkline}")
|
||||||
|
else:
|
||||||
|
lines.append(" Frame Time History")
|
||||||
|
lines.append(" (collecting data...)")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _render_bar(self, percentage: float, width: int) -> str:
|
||||||
|
"""Render a horizontal bar for percentage."""
|
||||||
|
filled = int((percentage / 100.0) * width)
|
||||||
|
bar = "█" * filled + "░" * (width - filled)
|
||||||
|
return bar
|
||||||
|
|
||||||
|
def _render_sparkline(self, values: list[float], width: int) -> str:
|
||||||
|
"""Render a sparkline from values."""
|
||||||
|
if not values:
|
||||||
|
return " " * width
|
||||||
|
|
||||||
|
min_val = min(values)
|
||||||
|
max_val = max(values)
|
||||||
|
range_val = max_val - min_val or 1.0
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for v in values[-width:]:
|
||||||
|
normalized = (v - min_val) / range_val
|
||||||
|
idx = int(normalized * (len(SPARKLINE_CHARS) - 1))
|
||||||
|
idx = max(0, min(idx, len(SPARKLINE_CHARS) - 1))
|
||||||
|
result.append(SPARKLINE_CHARS[idx])
|
||||||
|
|
||||||
|
# Pad to width
|
||||||
|
while len(result) < width:
|
||||||
|
result.insert(0, " ")
|
||||||
|
return "".join(result[:width])
|
||||||
490
engine/data_sources/sources.py
Normal file
490
engine/data_sources/sources.py
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
"""
|
||||||
|
Data sources for the pipeline architecture.
|
||||||
|
|
||||||
|
This module contains all DataSource implementations:
|
||||||
|
- DataSource: Abstract base class
|
||||||
|
- SourceItem, ImageItem: Data containers
|
||||||
|
- HeadlinesDataSource, PoetryDataSource, ImageDataSource: Concrete sources
|
||||||
|
- SourceRegistry: Registry for source discovery
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SourceItem:
|
||||||
|
"""A single item from a data source."""
|
||||||
|
|
||||||
|
content: str
|
||||||
|
source: str
|
||||||
|
timestamp: str
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImageItem:
|
||||||
|
"""An image item from a data source - wraps a PIL Image."""
|
||||||
|
|
||||||
|
image: Any # PIL Image
|
||||||
|
source: str
|
||||||
|
timestamp: str
|
||||||
|
path: str | None = None # File path or URL if applicable
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DataSource(ABC):
|
||||||
|
"""Abstract base class for data sources.
|
||||||
|
|
||||||
|
Static sources: Data fetched once and cached. Safe to call fetch() multiple times.
|
||||||
|
Dynamic sources: Data changes over time. fetch() should be idempotent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Display name for this source."""
|
||||||
|
...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dynamic(self) -> bool:
|
||||||
|
"""Whether this source updates dynamically while the app runs. Default False."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
"""Fetch fresh data from the source. Must be idempotent."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_items(self) -> list[SourceItem]:
|
||||||
|
"""Get current items. Default implementation returns cached fetch results."""
|
||||||
|
if not hasattr(self, "_items") or self._items is None:
|
||||||
|
self._items = self.fetch()
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
def refresh(self) -> list[SourceItem]:
|
||||||
|
"""Force refresh - clear cache and fetch fresh data."""
|
||||||
|
self._items = self.fetch()
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
def stream(self):
|
||||||
|
"""Optional: Yield items continuously. Override for streaming sources."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self._items: list[SourceItem] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HeadlinesDataSource(DataSource):
|
||||||
|
"""Data source for RSS feed headlines."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "headlines"
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
from engine.fetch import fetch_all
|
||||||
|
|
||||||
|
items, _, _ = fetch_all()
|
||||||
|
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyDataSource(DataSource):
|
||||||
|
"""Empty data source that produces blank lines for testing.
|
||||||
|
|
||||||
|
Useful for testing display borders, effects, and other pipeline
|
||||||
|
components without needing actual content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, width: int = 80, height: int = 24):
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "empty"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dynamic(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
# Return empty lines as content
|
||||||
|
content = "\n".join([" " * self.width for _ in range(self.height)])
|
||||||
|
return [SourceItem(content=content, source="empty", timestamp="0")]
|
||||||
|
|
||||||
|
|
||||||
|
class ListDataSource(DataSource):
|
||||||
|
"""Data source that wraps a pre-fetched list of items.
|
||||||
|
|
||||||
|
Used for bootstrap loading when items are already available in memory.
|
||||||
|
This is a simple wrapper for already-fetched data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, items, name: str = "list"):
|
||||||
|
self._raw_items = items # Store raw items separately
|
||||||
|
self._items = None # Cache for converted SourceItem objects
|
||||||
|
self._name = name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dynamic(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
# Convert tuple items to SourceItem if needed
|
||||||
|
result = []
|
||||||
|
for item in self._raw_items:
|
||||||
|
if isinstance(item, SourceItem):
|
||||||
|
result.append(item)
|
||||||
|
elif isinstance(item, tuple) and len(item) >= 3:
|
||||||
|
# Assume (content, source, timestamp) tuple format
|
||||||
|
result.append(
|
||||||
|
SourceItem(content=item[0], source=item[1], timestamp=str(item[2]))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback: treat as string content
|
||||||
|
result.append(
|
||||||
|
SourceItem(content=str(item), source="list", timestamp="0")
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class PoetryDataSource(DataSource):
|
||||||
|
"""Data source for Poetry DB."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "poetry"
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
from engine.fetch import fetch_poetry
|
||||||
|
|
||||||
|
items, _, _ = fetch_poetry()
|
||||||
|
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
|
||||||
|
|
||||||
|
|
||||||
|
class ImageDataSource(DataSource):
|
||||||
|
"""Data source that loads PNG images from file paths or URLs.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Local file paths (e.g., /path/to/image.png)
|
||||||
|
- URLs (e.g., https://example.com/image.png)
|
||||||
|
|
||||||
|
Yields ImageItem objects containing PIL Image objects that can be
|
||||||
|
converted to text buffers by an ImageToTextTransform stage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
path: str | list[str] | None = None,
|
||||||
|
urls: str | list[str] | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
path: Single path or list of paths to PNG files
|
||||||
|
urls: Single URL or list of URLs to PNG images
|
||||||
|
"""
|
||||||
|
self._paths = [path] if isinstance(path, str) else (path or [])
|
||||||
|
self._urls = [urls] if isinstance(urls, str) else (urls or [])
|
||||||
|
self._images: list[ImageItem] = []
|
||||||
|
self._load_images()
|
||||||
|
|
||||||
|
def _load_images(self) -> None:
|
||||||
|
"""Load all images from paths and URLs."""
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
timestamp = datetime.now().isoformat()
|
||||||
|
|
||||||
|
for path in self._paths:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.open(path)
|
||||||
|
if img.mode != "RGBA":
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
self._images.append(
|
||||||
|
ImageItem(
|
||||||
|
image=img,
|
||||||
|
source=f"file:{path}",
|
||||||
|
timestamp=timestamp,
|
||||||
|
path=path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for url in self._urls:
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
with urlopen(url) as response:
|
||||||
|
img = Image.open(BytesIO(response.read()))
|
||||||
|
if img.mode != "RGBA":
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
self._images.append(
|
||||||
|
ImageItem(
|
||||||
|
image=img,
|
||||||
|
source=f"url:{url}",
|
||||||
|
timestamp=timestamp,
|
||||||
|
path=url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "image"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dynamic(self) -> bool:
|
||||||
|
return False # Static images, not updating
|
||||||
|
|
||||||
|
def fetch(self) -> list[ImageItem]:
|
||||||
|
"""Return loaded images as ImageItem list."""
|
||||||
|
return self._images
|
||||||
|
|
||||||
|
def get_items(self) -> list[ImageItem]:
|
||||||
|
"""Return current image items."""
|
||||||
|
return self._images
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsDataSource(DataSource):
|
||||||
|
"""Data source that renders live pipeline metrics as ASCII art.
|
||||||
|
|
||||||
|
Wraps a Pipeline and displays active stages with their average execution
|
||||||
|
time and approximate FPS impact. Updates lazily when camera is about to
|
||||||
|
focus on a new node (frame % 15 == 12).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
pipeline: Any,
|
||||||
|
viewport_width: int = 80,
|
||||||
|
viewport_height: int = 24,
|
||||||
|
):
|
||||||
|
self.pipeline = pipeline
|
||||||
|
self.viewport_width = viewport_width
|
||||||
|
self.viewport_height = viewport_height
|
||||||
|
self.frame = 0
|
||||||
|
self._cached_metrics: dict | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "metrics"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dynamic(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
if self.frame % 15 == 12:
|
||||||
|
self._cached_metrics = None
|
||||||
|
|
||||||
|
if self._cached_metrics is None:
|
||||||
|
self._cached_metrics = self._fetch_metrics()
|
||||||
|
|
||||||
|
buffer = self._render_metrics(self._cached_metrics)
|
||||||
|
self.frame += 1
|
||||||
|
content = "\n".join(buffer)
|
||||||
|
return [
|
||||||
|
SourceItem(content=content, source="metrics", timestamp=f"f{self.frame}")
|
||||||
|
]
|
||||||
|
|
||||||
|
def _fetch_metrics(self) -> dict:
|
||||||
|
if hasattr(self.pipeline, "get_metrics_summary"):
|
||||||
|
metrics = self.pipeline.get_metrics_summary()
|
||||||
|
if "error" not in metrics:
|
||||||
|
return metrics
|
||||||
|
return {"stages": {}, "pipeline": {"avg_ms": 0}}
|
||||||
|
|
||||||
|
def _render_metrics(self, metrics: dict) -> list[str]:
|
||||||
|
stages = metrics.get("stages", {})
|
||||||
|
|
||||||
|
if not stages:
|
||||||
|
return self._render_empty()
|
||||||
|
|
||||||
|
active_stages = {
|
||||||
|
name: stats for name, stats in stages.items() if stats.get("avg_ms", 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if not active_stages:
|
||||||
|
return self._render_empty()
|
||||||
|
|
||||||
|
total_avg = sum(s["avg_ms"] for s in active_stages.values())
|
||||||
|
if total_avg == 0:
|
||||||
|
total_avg = 1
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append("═" * self.viewport_width)
|
||||||
|
lines.append(" PIPELINE METRICS ".center(self.viewport_width, "─"))
|
||||||
|
lines.append("─" * self.viewport_width)
|
||||||
|
|
||||||
|
header = f"{'STAGE':<20} {'AVG_MS':>8} {'FPS %':>8}"
|
||||||
|
lines.append(header)
|
||||||
|
lines.append("─" * self.viewport_width)
|
||||||
|
|
||||||
|
for name, stats in sorted(active_stages.items()):
|
||||||
|
avg_ms = stats.get("avg_ms", 0)
|
||||||
|
fps_impact = (avg_ms / 16.67) * 100 if avg_ms > 0 else 0
|
||||||
|
|
||||||
|
row = f"{name:<20} {avg_ms:>7.2f} {fps_impact:>7.1f}%"
|
||||||
|
lines.append(row[: self.viewport_width])
|
||||||
|
|
||||||
|
lines.append("─" * self.viewport_width)
|
||||||
|
total_row = (
|
||||||
|
f"{'TOTAL':<20} {total_avg:>7.2f} {(total_avg / 16.67) * 100:>7.1f}%"
|
||||||
|
)
|
||||||
|
lines.append(total_row[: self.viewport_width])
|
||||||
|
lines.append("─" * self.viewport_width)
|
||||||
|
lines.append(
|
||||||
|
f" Frame:{self.frame:04d} Cache:{'HIT' if self._cached_metrics else 'MISS'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
while len(lines) < self.viewport_height:
|
||||||
|
lines.append(" " * self.viewport_width)
|
||||||
|
|
||||||
|
return lines[: self.viewport_height]
|
||||||
|
|
||||||
|
def _render_empty(self) -> list[str]:
|
||||||
|
lines = [" " * self.viewport_width for _ in range(self.viewport_height)]
|
||||||
|
msg = "No metrics available"
|
||||||
|
y = self.viewport_height // 2
|
||||||
|
x = (self.viewport_width - len(msg)) // 2
|
||||||
|
lines[y] = " " * x + msg + " " * (self.viewport_width - x - len(msg))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def get_items(self) -> list[SourceItem]:
|
||||||
|
return self.fetch()
|
||||||
|
|
||||||
|
|
||||||
|
class CachedDataSource(DataSource):
|
||||||
|
"""Data source that wraps another source with caching."""
|
||||||
|
|
||||||
|
def __init__(self, source: DataSource, max_items: int = 100):
|
||||||
|
self.source = source
|
||||||
|
self.max_items = max_items
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return f"cached:{self.source.name}"
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
items = self.source.fetch()
|
||||||
|
return items[: self.max_items]
|
||||||
|
|
||||||
|
def get_items(self) -> list[SourceItem]:
|
||||||
|
if not hasattr(self, "_items") or self._items is None:
|
||||||
|
self._items = self.fetch()
|
||||||
|
return self._items
|
||||||
|
|
||||||
|
|
||||||
|
class TransformDataSource(DataSource):
|
||||||
|
"""Data source that transforms items from another source.
|
||||||
|
|
||||||
|
Applies optional filter and map functions to each item.
|
||||||
|
This enables chaining: source → transform → transformed output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: The source to fetch items from
|
||||||
|
filter_fn: Optional function(item: SourceItem) -> bool
|
||||||
|
map_fn: Optional function(item: SourceItem) -> SourceItem
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: DataSource,
|
||||||
|
filter_fn: Callable[[SourceItem], bool] | None = None,
|
||||||
|
map_fn: Callable[[SourceItem], SourceItem] | None = None,
|
||||||
|
):
|
||||||
|
self.source = source
|
||||||
|
self.filter_fn = filter_fn
|
||||||
|
self.map_fn = map_fn
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return f"transform:{self.source.name}"
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
items = self.source.fetch()
|
||||||
|
|
||||||
|
if self.filter_fn:
|
||||||
|
items = [item for item in items if self.filter_fn(item)]
|
||||||
|
|
||||||
|
if self.map_fn:
|
||||||
|
items = [self.map_fn(item) for item in items]
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
class CompositeDataSource(DataSource):
|
||||||
|
"""Data source that combines multiple sources."""
|
||||||
|
|
||||||
|
def __init__(self, sources: list[DataSource]):
|
||||||
|
self.sources = sources
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "composite"
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
items = []
|
||||||
|
for source in self.sources:
|
||||||
|
items.extend(source.fetch())
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRegistry:
|
||||||
|
"""Registry for data sources."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._sources: dict[str, DataSource] = {}
|
||||||
|
self._default: str | None = None
|
||||||
|
|
||||||
|
def register(self, source: DataSource, default: bool = False) -> None:
|
||||||
|
self._sources[source.name] = source
|
||||||
|
if default or self._default is None:
|
||||||
|
self._default = source.name
|
||||||
|
|
||||||
|
def get(self, name: str) -> DataSource | None:
|
||||||
|
return self._sources.get(name)
|
||||||
|
|
||||||
|
def list_all(self) -> dict[str, DataSource]:
|
||||||
|
return dict(self._sources)
|
||||||
|
|
||||||
|
def default(self) -> DataSource | None:
|
||||||
|
if self._default:
|
||||||
|
return self._sources.get(self._default)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_headlines(self) -> HeadlinesDataSource:
|
||||||
|
return HeadlinesDataSource()
|
||||||
|
|
||||||
|
def create_poetry(self) -> PoetryDataSource:
|
||||||
|
return PoetryDataSource()
|
||||||
|
|
||||||
|
|
||||||
|
_global_registry: SourceRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_registry() -> SourceRegistry:
|
||||||
|
global _global_registry
|
||||||
|
if _global_registry is None:
|
||||||
|
_global_registry = SourceRegistry()
|
||||||
|
return _global_registry
|
||||||
|
|
||||||
|
|
||||||
|
def init_default_sources() -> SourceRegistry:
|
||||||
|
"""Initialize the default source registry with standard sources."""
|
||||||
|
registry = get_source_registry()
|
||||||
|
registry.register(HeadlinesDataSource(), default=True)
|
||||||
|
registry.register(PoetryDataSource())
|
||||||
|
return registry
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"""
|
|
||||||
Display output abstraction - allows swapping output backends.
|
|
||||||
|
|
||||||
Protocol:
|
|
||||||
- init(width, height): Initialize display with terminal dimensions
|
|
||||||
- show(buffer): Render buffer (list of strings) to display
|
|
||||||
- clear(): Clear the display
|
|
||||||
- cleanup(): Shutdown display
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class Display(Protocol):
|
|
||||||
"""Protocol for display backends."""
|
|
||||||
|
|
||||||
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:
|
|
||||||
from engine.effects.performance import get_monitor as _get_monitor
|
|
||||||
|
|
||||||
return _get_monitor()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class TerminalDisplay:
|
|
||||||
"""ANSI terminal display backend."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
from engine.terminal import CURSOR_OFF
|
|
||||||
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
print(CURSOR_OFF, end="", flush=True)
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
sys.stdout.buffer.write("".join(buffer).encode())
|
|
||||||
sys.stdout.flush()
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
from engine.terminal import CLR
|
|
||||||
|
|
||||||
print(CLR, end="", flush=True)
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
from engine.terminal import CURSOR_ON
|
|
||||||
|
|
||||||
print(CURSOR_ON, end="", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
class NullDisplay:
|
|
||||||
"""Headless/null display - discards all output."""
|
|
||||||
|
|
||||||
def init(self, width: int, height: int) -> None:
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
def show(self, buffer: list[str]) -> None:
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
pass
|
|
||||||
290
engine/display/__init__.py
Normal file
290
engine/display/__init__.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
"""
|
||||||
|
Display backend system with registry pattern.
|
||||||
|
|
||||||
|
Allows swapping output backends via the Display protocol.
|
||||||
|
Supports auto-discovery of display backends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
# 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.replay import ReplayDisplay
|
||||||
|
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.
|
||||||
|
|
||||||
|
Required attributes:
|
||||||
|
- width: int
|
||||||
|
- height: int
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayRegistry:
|
||||||
|
"""Registry for display backends with auto-discovery."""
|
||||||
|
|
||||||
|
_backends: dict[str, type[Display]] = {}
|
||||||
|
_initialized = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, name: str, backend_class: type[Display]) -> None:
|
||||||
|
cls._backends[name.lower()] = backend_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, name: str) -> type[Display] | None:
|
||||||
|
return cls._backends.get(name.lower())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_backends(cls) -> list[str]:
|
||||||
|
return list(cls._backends.keys())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, name: str, **kwargs) -> Display | None:
|
||||||
|
cls.initialize()
|
||||||
|
backend_class = cls.get(name)
|
||||||
|
if backend_class:
|
||||||
|
return backend_class(**kwargs)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def initialize(cls) -> None:
|
||||||
|
if cls._initialized:
|
||||||
|
return
|
||||||
|
cls.register("terminal", TerminalDisplay)
|
||||||
|
cls.register("null", NullDisplay)
|
||||||
|
cls.register("replay", ReplayDisplay)
|
||||||
|
cls.register("websocket", WebSocketDisplay)
|
||||||
|
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."""
|
||||||
|
try:
|
||||||
|
from engine.effects.performance import get_monitor as _get_monitor
|
||||||
|
|
||||||
|
return _get_monitor()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_ansi(s: str) -> str:
|
||||||
|
"""Strip ANSI escape sequences from string for length calculation."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_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",
|
||||||
|
"ReplayDisplay",
|
||||||
|
"WebSocketDisplay",
|
||||||
|
"MultiDisplay",
|
||||||
|
"PygameDisplay",
|
||||||
|
]
|
||||||
|
|
||||||
|
if _MODERNGL_AVAILABLE:
|
||||||
|
__all__.append("ModernGLDisplay")
|
||||||
50
engine/display/backends/multi.py
Normal file
50
engine/display/backends/multi.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Multi display backend - forwards to multiple displays.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MultiDisplay:
|
||||||
|
"""Display that forwards to multiple displays.
|
||||||
|
|
||||||
|
Supports reuse - passes reuse flag to all child displays.
|
||||||
|
"""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def __init__(self, displays: list):
|
||||||
|
self.displays = displays
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
|
||||||
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
|
"""Initialize all child displays with dimensions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Terminal width in characters
|
||||||
|
height: Terminal height in rows
|
||||||
|
reuse: If True, use reuse mode for child displays
|
||||||
|
"""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
for d in self.displays:
|
||||||
|
d.init(width, height, reuse=reuse)
|
||||||
|
|
||||||
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.show(buffer, border=border)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.clear()
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Get dimensions from the first child display that supports it."""
|
||||||
|
for d in self.displays:
|
||||||
|
if hasattr(d, "get_dimensions"):
|
||||||
|
return d.get_dimensions()
|
||||||
|
return (self.width, self.height)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
for d in self.displays:
|
||||||
|
d.cleanup()
|
||||||
183
engine/display/backends/null.py
Normal file
183
engine/display/backends/null.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Null/headless display backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class NullDisplay:
|
||||||
|
"""Headless/null display - discards all output.
|
||||||
|
|
||||||
|
This display does nothing - useful for headless benchmarking
|
||||||
|
or when no display output is needed. Captures last buffer
|
||||||
|
for testing purposes. Supports frame recording for replay
|
||||||
|
and file export/import.
|
||||||
|
"""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
_last_buffer: list[str] | None = None
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._last_buffer = None
|
||||||
|
self._is_recording = False
|
||||||
|
self._recorded_frames: list[dict[str, Any]] = []
|
||||||
|
self._frame_count = 0
|
||||||
|
|
||||||
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
|
"""Initialize display with dimensions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Terminal width in characters
|
||||||
|
height: Terminal height in rows
|
||||||
|
reuse: Ignored for NullDisplay (no resources to reuse)
|
||||||
|
"""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._last_buffer = None
|
||||||
|
|
||||||
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from engine.display import get_monitor, render_border
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if border:
|
||||||
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||||
|
|
||||||
|
self._last_buffer = buffer
|
||||||
|
|
||||||
|
if self._is_recording:
|
||||||
|
self._recorded_frames.append(
|
||||||
|
{
|
||||||
|
"frame_number": self._frame_count,
|
||||||
|
"buffer": buffer,
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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]):
|
||||||
|
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)
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
self._frame_count += 1
|
||||||
|
|
||||||
|
def start_recording(self) -> None:
|
||||||
|
"""Begin recording frames."""
|
||||||
|
self._is_recording = True
|
||||||
|
self._recorded_frames = []
|
||||||
|
|
||||||
|
def stop_recording(self) -> None:
|
||||||
|
"""Stop recording frames."""
|
||||||
|
self._is_recording = False
|
||||||
|
|
||||||
|
def get_frames(self) -> list[list[str]]:
|
||||||
|
"""Get recorded frames as list of buffers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of buffers, each buffer is a list of strings (lines)
|
||||||
|
"""
|
||||||
|
return [frame["buffer"] for frame in self._recorded_frames]
|
||||||
|
|
||||||
|
def get_recorded_data(self) -> list[dict[str, Any]]:
|
||||||
|
"""Get full recorded data including metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of frame dicts with 'frame_number', 'buffer', 'width', 'height'
|
||||||
|
"""
|
||||||
|
return self._recorded_frames
|
||||||
|
|
||||||
|
def clear_recording(self) -> None:
|
||||||
|
"""Clear recorded frames."""
|
||||||
|
self._recorded_frames = []
|
||||||
|
|
||||||
|
def save_recording(self, filepath: str | Path) -> None:
|
||||||
|
"""Save recorded frames to a JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to save the recording
|
||||||
|
"""
|
||||||
|
path = Path(filepath)
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"display": "null",
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"frame_count": len(self._recorded_frames),
|
||||||
|
"frames": self._recorded_frames,
|
||||||
|
}
|
||||||
|
path.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
def load_recording(self, filepath: str | Path) -> list[dict[str, Any]]:
|
||||||
|
"""Load recorded frames from a JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to load the recording from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of frame dicts
|
||||||
|
"""
|
||||||
|
path = Path(filepath)
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
self._recorded_frames = data.get("frames", [])
|
||||||
|
self.width = data.get("width", 80)
|
||||||
|
self.height = data.get("height", 24)
|
||||||
|
return self._recorded_frames
|
||||||
|
|
||||||
|
def replay_frames(self) -> list[list[str]]:
|
||||||
|
"""Get frames for replay.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of buffers for replay
|
||||||
|
"""
|
||||||
|
return self.get_frames()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Get current dimensions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(width, height) in character cells
|
||||||
|
"""
|
||||||
|
return (self.width, self.height)
|
||||||
|
|
||||||
|
def is_quit_requested(self) -> bool:
|
||||||
|
"""Check if quit was requested (optional protocol method)."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_quit_request(self) -> None:
|
||||||
|
"""Clear quit request (optional protocol method)."""
|
||||||
|
pass
|
||||||
369
engine/display/backends/pygame.py
Normal file
369
engine/display/backends/pygame.py
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
"""
|
||||||
|
Pygame display backend - renders to a native application window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from engine.display.renderer import parse_ansi
|
||||||
|
|
||||||
|
|
||||||
|
class PygameDisplay:
|
||||||
|
"""Pygame display backend - renders to native window.
|
||||||
|
|
||||||
|
Supports reuse mode - when reuse=True, skips SDL initialization
|
||||||
|
and reuses the existing pygame window from a previous instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
window_width: int = 800
|
||||||
|
window_height: int = 600
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cell_width: int = 10,
|
||||||
|
cell_height: int = 18,
|
||||||
|
window_width: int = 800,
|
||||||
|
window_height: int = 600,
|
||||||
|
target_fps: float = 30.0,
|
||||||
|
):
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
self.cell_width = cell_width
|
||||||
|
self.cell_height = cell_height
|
||||||
|
self.window_width = window_width
|
||||||
|
self.window_height = window_height
|
||||||
|
self.target_fps = target_fps
|
||||||
|
self._initialized = False
|
||||||
|
self._pygame = None
|
||||||
|
self._screen = None
|
||||||
|
self._font = None
|
||||||
|
self._resized = False
|
||||||
|
self._quit_requested = False
|
||||||
|
self._last_frame_time = 0.0
|
||||||
|
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
||||||
|
self._glyph_cache = {}
|
||||||
|
|
||||||
|
def _get_font_path(self) -> str | None:
|
||||||
|
"""Get font path for rendering."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
env_font = os.environ.get("MAINLINE_PYGAME_FONT")
|
||||||
|
if env_font and os.path.exists(env_font):
|
||||||
|
return env_font
|
||||||
|
|
||||||
|
def search_dir(base_path: str) -> str | None:
|
||||||
|
if not os.path.exists(base_path):
|
||||||
|
return None
|
||||||
|
if os.path.isfile(base_path):
|
||||||
|
return base_path
|
||||||
|
for font_file in Path(base_path).rglob("*"):
|
||||||
|
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||||
|
name = font_file.stem.lower()
|
||||||
|
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||||
|
return str(font_file)
|
||||||
|
return None
|
||||||
|
|
||||||
|
search_dirs = []
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
search_dirs.append(os.path.expanduser("~/Library/Fonts/"))
|
||||||
|
elif sys.platform == "win32":
|
||||||
|
search_dirs.append(
|
||||||
|
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
search_dirs.extend(
|
||||||
|
[
|
||||||
|
os.path.expanduser("~/.local/share/fonts/"),
|
||||||
|
os.path.expanduser("~/.fonts/"),
|
||||||
|
"/usr/share/fonts/",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for search_dir_path in search_dirs:
|
||||||
|
found = search_dir(search_dir_path)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
|
"""Initialize display with dimensions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Terminal width in characters
|
||||||
|
height: Terminal height in rows
|
||||||
|
reuse: If True, attach to existing pygame window instead of creating new
|
||||||
|
"""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pygame
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
if reuse and PygameDisplay._pygame_initialized:
|
||||||
|
self._pygame = pygame
|
||||||
|
self._initialized = True
|
||||||
|
return
|
||||||
|
|
||||||
|
pygame.init()
|
||||||
|
pygame.display.set_caption("Mainline")
|
||||||
|
|
||||||
|
self._screen = pygame.display.set_mode(
|
||||||
|
(self.window_width, self.window_height),
|
||||||
|
pygame.RESIZABLE,
|
||||||
|
)
|
||||||
|
self._pygame = pygame
|
||||||
|
PygameDisplay._pygame_initialized = True
|
||||||
|
|
||||||
|
# Calculate character dimensions from actual window size
|
||||||
|
self.width = max(1, self.window_width // self.cell_width)
|
||||||
|
self.height = max(1, self.window_height // self.cell_height)
|
||||||
|
|
||||||
|
font_path = self._get_font_path()
|
||||||
|
if font_path:
|
||||||
|
try:
|
||||||
|
self._font = pygame.font.Font(font_path, self.cell_height - 2)
|
||||||
|
except Exception:
|
||||||
|
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||||
|
else:
|
||||||
|
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||||
|
|
||||||
|
# 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], border: bool = False) -> None:
|
||||||
|
if not self._initialized or not self._pygame:
|
||||||
|
return
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
|
for event in self._pygame.event.get():
|
||||||
|
if event.type == self._pygame.QUIT:
|
||||||
|
self._quit_requested = True
|
||||||
|
elif event.type == self._pygame.KEYDOWN:
|
||||||
|
if event.key in (self._pygame.K_ESCAPE, self._pygame.K_c):
|
||||||
|
if event.key == self._pygame.K_c and not (
|
||||||
|
event.mod & self._pygame.KMOD_LCTRL
|
||||||
|
or event.mod & self._pygame.KMOD_RCTRL
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
self._quit_requested = True
|
||||||
|
elif event.type == self._pygame.VIDEORESIZE:
|
||||||
|
self.window_width = event.w
|
||||||
|
self.window_height = event.h
|
||||||
|
self.width = max(1, self.window_width // self.cell_width)
|
||||||
|
self.height = max(1, self.window_height // self.cell_height)
|
||||||
|
self._resized = True
|
||||||
|
|
||||||
|
# FPS limiting - skip frame if we're going too fast
|
||||||
|
if self._frame_period > 0:
|
||||||
|
now = time.perf_counter()
|
||||||
|
elapsed = now - self._last_frame_time
|
||||||
|
if elapsed < self._frame_period:
|
||||||
|
return # Skip this frame
|
||||||
|
self._last_frame_time = now
|
||||||
|
|
||||||
|
# Get metrics for border display
|
||||||
|
fps = 0.0
|
||||||
|
frame_time = 0.0
|
||||||
|
from engine.display import get_monitor
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
if monitor:
|
||||||
|
stats = monitor.get_stats()
|
||||||
|
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
||||||
|
frame_count = stats.get("frame_count", 0) if stats else 0
|
||||||
|
if avg_ms and frame_count > 0:
|
||||||
|
fps = 1000.0 / avg_ms
|
||||||
|
frame_time = avg_ms
|
||||||
|
|
||||||
|
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 = content_offset_x
|
||||||
|
|
||||||
|
for text, fg, bg, _bold in tokens:
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Use None as key for no background
|
||||||
|
bg_key = bg if bg != (0, 0, 0) else None
|
||||||
|
cache_key = (text, fg, bg_key)
|
||||||
|
|
||||||
|
if cache_key not in self._glyph_cache:
|
||||||
|
# Render and cache
|
||||||
|
if bg_key is not None:
|
||||||
|
self._glyph_cache[cache_key] = self._font.render(
|
||||||
|
text, True, fg, bg_key
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._glyph_cache[cache_key] = self._font.render(text, True, fg)
|
||||||
|
|
||||||
|
surface = self._glyph_cache[cache_key]
|
||||||
|
blit_list.append(
|
||||||
|
(surface, (x_pos, 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
|
||||||
|
|
||||||
|
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))
|
||||||
|
self._pygame.display.flip()
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Get current terminal dimensions based on window size.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(width, height) in character cells
|
||||||
|
"""
|
||||||
|
# Query actual window size and recalculate character cells
|
||||||
|
if self._screen and self._pygame:
|
||||||
|
try:
|
||||||
|
w, h = self._screen.get_size()
|
||||||
|
if w != self.window_width or h != self.window_height:
|
||||||
|
self.window_width = w
|
||||||
|
self.window_height = h
|
||||||
|
self.width = max(1, w // self.cell_width)
|
||||||
|
self.height = max(1, h // self.cell_height)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self.width, self.height
|
||||||
|
|
||||||
|
def cleanup(self, quit_pygame: bool = True) -> None:
|
||||||
|
"""Cleanup display resources.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quit_pygame: If True, quit pygame entirely. Set to False when
|
||||||
|
reusing the display to avoid closing shared window.
|
||||||
|
"""
|
||||||
|
if quit_pygame and self._pygame:
|
||||||
|
self._pygame.quit()
|
||||||
|
PygameDisplay._pygame_initialized = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset_state(cls) -> None:
|
||||||
|
"""Reset pygame state - useful for testing."""
|
||||||
|
cls._pygame_initialized = False
|
||||||
|
|
||||||
|
def is_quit_requested(self) -> bool:
|
||||||
|
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
|
||||||
|
|
||||||
|
Returns True if the user pressed Ctrl+C, Ctrl+Q, or Escape.
|
||||||
|
The main loop should check this and raise KeyboardInterrupt.
|
||||||
|
"""
|
||||||
|
return self._quit_requested
|
||||||
|
|
||||||
|
def clear_quit_request(self) -> bool:
|
||||||
|
"""Clear the quit request flag after handling.
|
||||||
|
|
||||||
|
Returns the previous quit request state.
|
||||||
|
"""
|
||||||
|
was_requested = self._quit_requested
|
||||||
|
self._quit_requested = False
|
||||||
|
return was_requested
|
||||||
122
engine/display/backends/replay.py
Normal file
122
engine/display/backends/replay.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Replay display backend - plays back recorded frames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayDisplay:
|
||||||
|
"""Replay display - plays back recorded frames.
|
||||||
|
|
||||||
|
This display reads frames from a recording (list of frame data)
|
||||||
|
and yields them sequentially, useful for testing and demo purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._frames: list[dict[str, Any]] = []
|
||||||
|
self._current_frame = 0
|
||||||
|
self._playback_index = 0
|
||||||
|
self._loop = False
|
||||||
|
|
||||||
|
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 ReplayDisplay
|
||||||
|
"""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
def set_frames(self, frames: list[dict[str, Any]]) -> None:
|
||||||
|
"""Set frames to replay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frames: List of frame dicts with 'buffer', 'width', 'height'
|
||||||
|
"""
|
||||||
|
self._frames = frames
|
||||||
|
self._current_frame = 0
|
||||||
|
self._playback_index = 0
|
||||||
|
|
||||||
|
def set_loop(self, loop: bool) -> None:
|
||||||
|
"""Set loop playback mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
loop: True to loop, False to stop at end
|
||||||
|
"""
|
||||||
|
self._loop = loop
|
||||||
|
|
||||||
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
|
"""Display a frame (ignored in replay mode).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer: Buffer to display (ignored)
|
||||||
|
border: Border flag (ignored)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_next_frame(self) -> list[str] | None:
|
||||||
|
"""Get the next frame in the recording.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Buffer list of strings, or None if playback is done
|
||||||
|
"""
|
||||||
|
if not self._frames:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._playback_index >= len(self._frames):
|
||||||
|
if self._loop:
|
||||||
|
self._playback_index = 0
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frame = self._frames[self._playback_index]
|
||||||
|
self._playback_index += 1
|
||||||
|
return frame.get("buffer")
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset playback to the beginning."""
|
||||||
|
self._playback_index = 0
|
||||||
|
|
||||||
|
def seek(self, index: int) -> None:
|
||||||
|
"""Seek to a specific frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Frame index to seek to
|
||||||
|
"""
|
||||||
|
if 0 <= index < len(self._frames):
|
||||||
|
self._playback_index = index
|
||||||
|
|
||||||
|
def is_finished(self) -> bool:
|
||||||
|
"""Check if playback is finished.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at end of frames and not looping
|
||||||
|
"""
|
||||||
|
return not self._loop and self._playback_index >= len(self._frames)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Get current dimensions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(width, height) in character cells
|
||||||
|
"""
|
||||||
|
return (self.width, self.height)
|
||||||
|
|
||||||
|
def is_quit_requested(self) -> bool:
|
||||||
|
"""Check if quit was requested (optional protocol method)."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_quit_request(self) -> None:
|
||||||
|
"""Clear quit request (optional protocol method)."""
|
||||||
|
pass
|
||||||
133
engine/display/backends/terminal.py
Normal file
133
engine/display/backends/terminal.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
ANSI terminal display backend.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalDisplay:
|
||||||
|
"""ANSI terminal display backend.
|
||||||
|
|
||||||
|
Renders buffer to stdout using ANSI escape codes.
|
||||||
|
Supports reuse - when reuse=True, skips re-initializing terminal state.
|
||||||
|
Auto-detects terminal dimensions on init.
|
||||||
|
"""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
_initialized: bool = False
|
||||||
|
|
||||||
|
def __init__(self, target_fps: float = 30.0):
|
||||||
|
self.target_fps = target_fps
|
||||||
|
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
||||||
|
self._last_frame_time = 0.0
|
||||||
|
self._cached_dimensions: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
|
"""Initialize display with dimensions.
|
||||||
|
|
||||||
|
If width/height are not provided (0/None), auto-detects terminal size.
|
||||||
|
Otherwise uses provided dimensions or falls back to terminal size
|
||||||
|
if the provided dimensions exceed terminal capacity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Desired terminal width (0 = auto-detect)
|
||||||
|
height: Desired terminal height (0 = auto-detect)
|
||||||
|
reuse: If True, skip terminal re-initialization
|
||||||
|
"""
|
||||||
|
from engine.terminal import CURSOR_OFF
|
||||||
|
|
||||||
|
# Auto-detect terminal size (handle case where no terminal)
|
||||||
|
try:
|
||||||
|
term_size = os.get_terminal_size()
|
||||||
|
term_width = term_size.columns
|
||||||
|
term_height = term_size.lines
|
||||||
|
except OSError:
|
||||||
|
# No terminal available (e.g., in tests)
|
||||||
|
term_width = width if width > 0 else 80
|
||||||
|
term_height = height if height > 0 else 24
|
||||||
|
|
||||||
|
# Use provided dimensions if valid, otherwise use terminal size
|
||||||
|
if width > 0 and height > 0:
|
||||||
|
self.width = min(width, term_width)
|
||||||
|
self.height = min(height, term_height)
|
||||||
|
else:
|
||||||
|
self.width = term_width
|
||||||
|
self.height = term_height
|
||||||
|
|
||||||
|
if not reuse or not self._initialized:
|
||||||
|
print(CURSOR_OFF, end="", flush=True)
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Get current terminal dimensions.
|
||||||
|
|
||||||
|
Returns cached dimensions to avoid querying terminal every frame,
|
||||||
|
which can cause inconsistent results. Dimensions are only refreshed
|
||||||
|
when they actually change.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(width, height) in character cells
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
term_size = os.get_terminal_size()
|
||||||
|
new_dims = (term_size.columns, term_size.lines)
|
||||||
|
except OSError:
|
||||||
|
new_dims = (self.width, self.height)
|
||||||
|
|
||||||
|
# Only update cached dimensions if they actually changed
|
||||||
|
if self._cached_dimensions is None or self._cached_dimensions != new_dims:
|
||||||
|
self._cached_dimensions = new_dims
|
||||||
|
self.width = new_dims[0]
|
||||||
|
self.height = new_dims[1]
|
||||||
|
|
||||||
|
return self._cached_dimensions
|
||||||
|
|
||||||
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from engine.display import get_monitor, render_border
|
||||||
|
|
||||||
|
# Note: Frame rate limiting is handled by the caller (e.g., FrameTimer).
|
||||||
|
# This display renders every frame it receives.
|
||||||
|
|
||||||
|
# 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
|
||||||
|
from engine.display import BorderMode
|
||||||
|
|
||||||
|
if border and border != BorderMode.OFF:
|
||||||
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||||
|
|
||||||
|
# Write buffer with cursor home + erase down to avoid flicker
|
||||||
|
output = "\033[H\033[J" + "".join(buffer)
|
||||||
|
sys.stdout.buffer.write(output.encode())
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
from engine.terminal import CLR
|
||||||
|
|
||||||
|
print(CLR, end="", flush=True)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
from engine.terminal import CURSOR_ON
|
||||||
|
|
||||||
|
print(CURSOR_ON, end="", flush=True)
|
||||||
|
|
||||||
|
def is_quit_requested(self) -> bool:
|
||||||
|
"""Check if quit was requested (optional protocol method)."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_quit_request(self) -> None:
|
||||||
|
"""Clear quit request (optional protocol method)."""
|
||||||
|
pass
|
||||||
464
engine/display/backends/websocket.py
Normal file
464
engine/display/backends/websocket.py
Normal file
@@ -0,0 +1,464 @@
|
|||||||
|
"""
|
||||||
|
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
||||||
|
|
||||||
|
Supports streaming protocols:
|
||||||
|
- Full frame (JSON) - default for compatibility
|
||||||
|
- Binary streaming - efficient binary protocol
|
||||||
|
- Diff streaming - only sends changed lines
|
||||||
|
|
||||||
|
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 base64
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from enum import IntFlag
|
||||||
|
|
||||||
|
from engine.display.streaming import (
|
||||||
|
MessageType,
|
||||||
|
compress_frame,
|
||||||
|
compute_diff,
|
||||||
|
encode_binary_message,
|
||||||
|
encode_diff_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingMode(IntFlag):
|
||||||
|
"""Streaming modes for WebSocket display."""
|
||||||
|
|
||||||
|
JSON = 0x01 # Full JSON frames (default, compatible)
|
||||||
|
BINARY = 0x02 # Binary compression
|
||||||
|
DIFF = 0x04 # Differential updates
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets
|
||||||
|
except ImportError:
|
||||||
|
websockets = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_monitor():
|
||||||
|
"""Get the performance monitor."""
|
||||||
|
try:
|
||||||
|
from engine.effects.performance import get_monitor as _get_monitor
|
||||||
|
|
||||||
|
return _get_monitor()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketDisplay:
|
||||||
|
"""WebSocket display backend - broadcasts to HTML Canvas clients."""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host: str = "0.0.0.0",
|
||||||
|
port: int = 8765,
|
||||||
|
http_port: int = 8766,
|
||||||
|
streaming_mode: StreamingMode = StreamingMode.JSON,
|
||||||
|
):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.http_port = http_port
|
||||||
|
self.width = 80
|
||||||
|
self.height = 24
|
||||||
|
self._clients: set = set()
|
||||||
|
self._server_running = False
|
||||||
|
self._http_running = False
|
||||||
|
self._server_thread: threading.Thread | None = None
|
||||||
|
self._http_thread: threading.Thread | None = None
|
||||||
|
self._available = True
|
||||||
|
self._max_clients = 10
|
||||||
|
self._client_connected_callback = None
|
||||||
|
self._client_disconnected_callback = None
|
||||||
|
self._command_callback = None
|
||||||
|
self._controller = None # Reference to UI panel or pipeline controller
|
||||||
|
self._frame_delay = 0.0
|
||||||
|
self._httpd = None # HTTP server instance
|
||||||
|
|
||||||
|
# Streaming configuration
|
||||||
|
self._streaming_mode = streaming_mode
|
||||||
|
self._last_buffer: list[str] = []
|
||||||
|
self._client_capabilities: dict = {} # Track client capabilities
|
||||||
|
|
||||||
|
try:
|
||||||
|
import websockets as _ws
|
||||||
|
|
||||||
|
self._available = _ws is not None
|
||||||
|
except ImportError:
|
||||||
|
self._available = False
|
||||||
|
|
||||||
|
def is_available(self) -> bool:
|
||||||
|
"""Check if WebSocket support is available."""
|
||||||
|
return self._available
|
||||||
|
|
||||||
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
|
"""Initialize display with dimensions and start server.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Terminal width in characters
|
||||||
|
height: Terminal height in rows
|
||||||
|
reuse: If True, skip starting servers (assume already running)
|
||||||
|
"""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
if not reuse or not self._server_running:
|
||||||
|
self.start_server()
|
||||||
|
self.start_http_server()
|
||||||
|
|
||||||
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
|
"""Broadcast buffer to all connected clients using streaming protocol."""
|
||||||
|
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 not self._clients:
|
||||||
|
self._last_buffer = buffer
|
||||||
|
return
|
||||||
|
|
||||||
|
# Send to each client based on their capabilities
|
||||||
|
disconnected = set()
|
||||||
|
for client in list(self._clients):
|
||||||
|
try:
|
||||||
|
client_id = id(client)
|
||||||
|
client_mode = self._client_capabilities.get(
|
||||||
|
client_id, StreamingMode.JSON
|
||||||
|
)
|
||||||
|
|
||||||
|
if client_mode & StreamingMode.DIFF:
|
||||||
|
self._send_diff_frame(client, buffer)
|
||||||
|
elif client_mode & StreamingMode.BINARY:
|
||||||
|
self._send_binary_frame(client, buffer)
|
||||||
|
else:
|
||||||
|
self._send_json_frame(client, buffer)
|
||||||
|
except Exception:
|
||||||
|
disconnected.add(client)
|
||||||
|
|
||||||
|
for client in disconnected:
|
||||||
|
self._clients.discard(client)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(client)
|
||||||
|
|
||||||
|
self._last_buffer = buffer
|
||||||
|
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
if monitor:
|
||||||
|
chars_in = sum(len(line) for line in buffer)
|
||||||
|
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def _send_json_frame(self, client, buffer: list[str]) -> None:
|
||||||
|
"""Send frame as JSON."""
|
||||||
|
frame_data = {
|
||||||
|
"type": "frame",
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"lines": buffer,
|
||||||
|
}
|
||||||
|
message = json.dumps(frame_data)
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
|
||||||
|
def _send_binary_frame(self, client, buffer: list[str]) -> None:
|
||||||
|
"""Send frame as compressed binary."""
|
||||||
|
compressed = compress_frame(buffer)
|
||||||
|
message = encode_binary_message(
|
||||||
|
MessageType.FULL_FRAME, self.width, self.height, compressed
|
||||||
|
)
|
||||||
|
encoded = base64.b64encode(message).decode("utf-8")
|
||||||
|
asyncio.run(client.send(encoded))
|
||||||
|
|
||||||
|
def _send_diff_frame(self, client, buffer: list[str]) -> None:
|
||||||
|
"""Send frame as diff."""
|
||||||
|
diff = compute_diff(self._last_buffer, buffer)
|
||||||
|
|
||||||
|
if not diff.changed_lines:
|
||||||
|
return
|
||||||
|
|
||||||
|
diff_payload = encode_diff_message(diff)
|
||||||
|
message = encode_binary_message(
|
||||||
|
MessageType.DIFF_FRAME, self.width, self.height, diff_payload
|
||||||
|
)
|
||||||
|
encoded = base64.b64encode(message).decode("utf-8")
|
||||||
|
asyncio.run(client.send(encoded))
|
||||||
|
|
||||||
|
def set_streaming_mode(self, mode: StreamingMode) -> None:
|
||||||
|
"""Set the default streaming mode for new clients."""
|
||||||
|
self._streaming_mode = mode
|
||||||
|
|
||||||
|
def get_streaming_mode(self) -> StreamingMode:
|
||||||
|
"""Get the current streaming mode."""
|
||||||
|
return self._streaming_mode
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Broadcast clear command to all clients."""
|
||||||
|
if self._clients:
|
||||||
|
clear_data = {"type": "clear"}
|
||||||
|
message = json.dumps(clear_data)
|
||||||
|
for client in list(self._clients):
|
||||||
|
try:
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Stop the servers."""
|
||||||
|
self.stop_server()
|
||||||
|
self.stop_http_server()
|
||||||
|
|
||||||
|
async def _websocket_handler(self, websocket):
|
||||||
|
"""Handle WebSocket connections."""
|
||||||
|
if len(self._clients) >= self._max_clients:
|
||||||
|
await websocket.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._clients.add(websocket)
|
||||||
|
if self._client_connected_callback:
|
||||||
|
self._client_connected_callback(websocket)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for message in websocket:
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
msg_type = data.get("type")
|
||||||
|
|
||||||
|
if msg_type == "resize":
|
||||||
|
self.width = data.get("width", 80)
|
||||||
|
self.height = data.get("height", 24)
|
||||||
|
elif msg_type == "command" and self._command_callback:
|
||||||
|
# Forward commands to the pipeline controller
|
||||||
|
command = data.get("command", {})
|
||||||
|
self._command_callback(command)
|
||||||
|
elif msg_type == "state_request":
|
||||||
|
# Send current state snapshot
|
||||||
|
state = self._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
response = {"type": "state", "state": state}
|
||||||
|
await websocket.send(json.dumps(response))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._clients.discard(websocket)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(websocket)
|
||||||
|
|
||||||
|
async def _run_websocket_server(self):
|
||||||
|
"""Run the WebSocket server."""
|
||||||
|
if not websockets:
|
||||||
|
return
|
||||||
|
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
||||||
|
while self._server_running:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
async def _run_http_server(self):
|
||||||
|
"""Run simple HTTP server for the client."""
|
||||||
|
import os
|
||||||
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
|
||||||
|
# Find the project root by locating 'engine' directory in the path
|
||||||
|
websocket_file = os.path.abspath(__file__)
|
||||||
|
parts = websocket_file.split(os.sep)
|
||||||
|
if "engine" in parts:
|
||||||
|
engine_idx = parts.index("engine")
|
||||||
|
project_root = os.sep.join(parts[:engine_idx])
|
||||||
|
client_dir = os.path.join(project_root, "client")
|
||||||
|
else:
|
||||||
|
# Fallback: go up 4 levels from websocket.py
|
||||||
|
# websocket.py: .../engine/display/backends/websocket.py
|
||||||
|
# We need: .../client
|
||||||
|
client_dir = os.path.join(
|
||||||
|
os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
),
|
||||||
|
"client",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, directory=client_dir, **kwargs)
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||||
|
# Store reference for shutdown
|
||||||
|
self._httpd = httpd
|
||||||
|
# Serve requests continuously
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
|
def _run_async(self, coro):
|
||||||
|
"""Run coroutine in background."""
|
||||||
|
try:
|
||||||
|
asyncio.run(coro)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WebSocket async error: {e}")
|
||||||
|
|
||||||
|
def start_server(self):
|
||||||
|
"""Start the WebSocket server in a background thread."""
|
||||||
|
if not self._available:
|
||||||
|
return
|
||||||
|
if self._server_thread is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._server_running = True
|
||||||
|
self._server_thread = threading.Thread(
|
||||||
|
target=self._run_async, args=(self._run_websocket_server(),), daemon=True
|
||||||
|
)
|
||||||
|
self._server_thread.start()
|
||||||
|
|
||||||
|
def stop_server(self):
|
||||||
|
"""Stop the WebSocket server."""
|
||||||
|
self._server_running = False
|
||||||
|
self._server_thread = None
|
||||||
|
|
||||||
|
def start_http_server(self):
|
||||||
|
"""Start the HTTP server in a background thread."""
|
||||||
|
if not self._available:
|
||||||
|
return
|
||||||
|
if self._http_thread is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._http_running = True
|
||||||
|
|
||||||
|
self._http_running = True
|
||||||
|
self._http_thread = threading.Thread(
|
||||||
|
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
||||||
|
)
|
||||||
|
self._http_thread.start()
|
||||||
|
|
||||||
|
def stop_http_server(self):
|
||||||
|
"""Stop the HTTP server."""
|
||||||
|
self._http_running = False
|
||||||
|
if hasattr(self, "_httpd") and self._httpd:
|
||||||
|
self._httpd.shutdown()
|
||||||
|
self._http_thread = None
|
||||||
|
|
||||||
|
def client_count(self) -> int:
|
||||||
|
"""Return number of connected clients."""
|
||||||
|
return len(self._clients)
|
||||||
|
|
||||||
|
def get_ws_port(self) -> int:
|
||||||
|
"""Return WebSocket port."""
|
||||||
|
return self.port
|
||||||
|
|
||||||
|
def get_http_port(self) -> int:
|
||||||
|
"""Return HTTP port."""
|
||||||
|
return self.http_port
|
||||||
|
|
||||||
|
def set_frame_delay(self, delay: float) -> None:
|
||||||
|
"""Set delay between frames in seconds."""
|
||||||
|
self._frame_delay = delay
|
||||||
|
|
||||||
|
def get_frame_delay(self) -> float:
|
||||||
|
"""Get delay between frames."""
|
||||||
|
return self._frame_delay
|
||||||
|
|
||||||
|
def set_client_connected_callback(self, callback) -> None:
|
||||||
|
"""Set callback for client connections."""
|
||||||
|
self._client_connected_callback = callback
|
||||||
|
|
||||||
|
def set_client_disconnected_callback(self, callback) -> None:
|
||||||
|
"""Set callback for client disconnections."""
|
||||||
|
self._client_disconnected_callback = callback
|
||||||
|
|
||||||
|
def set_command_callback(self, callback) -> None:
|
||||||
|
"""Set callback for incoming command messages from clients."""
|
||||||
|
self._command_callback = callback
|
||||||
|
|
||||||
|
def set_controller(self, controller) -> None:
|
||||||
|
"""Set controller (UI panel or pipeline) for state queries and command execution."""
|
||||||
|
self._controller = controller
|
||||||
|
|
||||||
|
def broadcast_state(self, state: dict) -> None:
|
||||||
|
"""Broadcast state update to all connected clients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dictionary containing state data to send to clients
|
||||||
|
"""
|
||||||
|
if not self._clients:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = json.dumps({"type": "state", "state": state})
|
||||||
|
|
||||||
|
disconnected = set()
|
||||||
|
for client in list(self._clients):
|
||||||
|
try:
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
except Exception:
|
||||||
|
disconnected.add(client)
|
||||||
|
|
||||||
|
for client in disconnected:
|
||||||
|
self._clients.discard(client)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(client)
|
||||||
|
|
||||||
|
def _get_state_snapshot(self) -> dict | None:
|
||||||
|
"""Get current state snapshot from controller."""
|
||||||
|
if not self._controller:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Expect controller to have methods we need
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
# Get stages info if UIPanel
|
||||||
|
if hasattr(self._controller, "stages"):
|
||||||
|
state["stages"] = {
|
||||||
|
name: {
|
||||||
|
"enabled": ctrl.enabled,
|
||||||
|
"params": ctrl.params,
|
||||||
|
"selected": ctrl.selected,
|
||||||
|
}
|
||||||
|
for name, ctrl in self._controller.stages.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current preset
|
||||||
|
if hasattr(self._controller, "_current_preset"):
|
||||||
|
state["preset"] = self._controller._current_preset
|
||||||
|
if hasattr(self._controller, "_presets"):
|
||||||
|
state["presets"] = self._controller._presets
|
||||||
|
|
||||||
|
# Get selected stage
|
||||||
|
if hasattr(self._controller, "selected_stage"):
|
||||||
|
state["selected_stage"] = self._controller.selected_stage
|
||||||
|
|
||||||
|
return state
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Get current dimensions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(width, height) in character cells
|
||||||
|
"""
|
||||||
|
return (self.width, self.height)
|
||||||
280
engine/display/renderer.py
Normal file
280
engine/display/renderer.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
Shared display rendering utilities.
|
||||||
|
|
||||||
|
Provides common functionality for displays that render text to images
|
||||||
|
(Pygame, Sixel, Kitty displays).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ANSI_COLORS = {
|
||||||
|
0: (0, 0, 0),
|
||||||
|
1: (205, 49, 49),
|
||||||
|
2: (13, 188, 121),
|
||||||
|
3: (229, 229, 16),
|
||||||
|
4: (36, 114, 200),
|
||||||
|
5: (188, 63, 188),
|
||||||
|
6: (17, 168, 205),
|
||||||
|
7: (229, 229, 229),
|
||||||
|
8: (102, 102, 102),
|
||||||
|
9: (241, 76, 76),
|
||||||
|
10: (35, 209, 139),
|
||||||
|
11: (245, 245, 67),
|
||||||
|
12: (59, 142, 234),
|
||||||
|
13: (214, 112, 214),
|
||||||
|
14: (41, 184, 219),
|
||||||
|
15: (255, 255, 255),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ansi(
|
||||||
|
text: str,
|
||||||
|
) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]:
|
||||||
|
"""Parse ANSI escape sequences into text tokens with colors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text containing ANSI escape sequences
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (text, fg_rgb, bg_rgb, bold) tuples
|
||||||
|
"""
|
||||||
|
tokens = []
|
||||||
|
current_text = ""
|
||||||
|
fg = (204, 204, 204)
|
||||||
|
bg = (0, 0, 0)
|
||||||
|
bold = False
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
ANSI_COLORS_4BIT = {
|
||||||
|
0: (0, 0, 0),
|
||||||
|
1: (205, 49, 49),
|
||||||
|
2: (13, 188, 121),
|
||||||
|
3: (229, 229, 16),
|
||||||
|
4: (36, 114, 200),
|
||||||
|
5: (188, 63, 188),
|
||||||
|
6: (17, 168, 205),
|
||||||
|
7: (229, 229, 229),
|
||||||
|
8: (102, 102, 102),
|
||||||
|
9: (241, 76, 76),
|
||||||
|
10: (35, 209, 139),
|
||||||
|
11: (245, 245, 67),
|
||||||
|
12: (59, 142, 234),
|
||||||
|
13: (214, 112, 214),
|
||||||
|
14: (41, 184, 219),
|
||||||
|
15: (255, 255, 255),
|
||||||
|
}
|
||||||
|
|
||||||
|
while i < len(text):
|
||||||
|
char = text[i]
|
||||||
|
|
||||||
|
if char == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
|
||||||
|
if current_text:
|
||||||
|
tokens.append((current_text, fg, bg, bold))
|
||||||
|
current_text = ""
|
||||||
|
|
||||||
|
i += 2
|
||||||
|
code = ""
|
||||||
|
while i < len(text):
|
||||||
|
c = text[i]
|
||||||
|
if c.isalpha():
|
||||||
|
break
|
||||||
|
code += c
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if code:
|
||||||
|
codes = code.split(";")
|
||||||
|
for c in codes:
|
||||||
|
if c == "0":
|
||||||
|
fg = (204, 204, 204)
|
||||||
|
bg = (0, 0, 0)
|
||||||
|
bold = False
|
||||||
|
elif c == "1":
|
||||||
|
bold = True
|
||||||
|
elif c == "22":
|
||||||
|
bold = False
|
||||||
|
elif c == "39":
|
||||||
|
fg = (204, 204, 204)
|
||||||
|
elif c == "49":
|
||||||
|
bg = (0, 0, 0)
|
||||||
|
elif c.isdigit():
|
||||||
|
color_idx = int(c)
|
||||||
|
if color_idx in ANSI_COLORS_4BIT:
|
||||||
|
fg = ANSI_COLORS_4BIT[color_idx]
|
||||||
|
elif 30 <= color_idx <= 37:
|
||||||
|
fg = ANSI_COLORS_4BIT.get(color_idx - 30, fg)
|
||||||
|
elif 40 <= color_idx <= 47:
|
||||||
|
bg = ANSI_COLORS_4BIT.get(color_idx - 40, bg)
|
||||||
|
elif 90 <= color_idx <= 97:
|
||||||
|
fg = ANSI_COLORS_4BIT.get(color_idx - 90 + 8, fg)
|
||||||
|
elif 100 <= color_idx <= 107:
|
||||||
|
bg = ANSI_COLORS_4BIT.get(color_idx - 100 + 8, bg)
|
||||||
|
elif c.startswith("38;5;"):
|
||||||
|
idx = int(c.split(";")[-1])
|
||||||
|
if idx < 256:
|
||||||
|
if idx < 16:
|
||||||
|
fg = ANSI_COLORS_4BIT.get(idx, fg)
|
||||||
|
elif idx < 232:
|
||||||
|
c_idx = idx - 16
|
||||||
|
fg = (
|
||||||
|
(c_idx >> 4) * 51,
|
||||||
|
((c_idx >> 2) & 7) * 51,
|
||||||
|
(c_idx & 3) * 85,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
gray = (idx - 232) * 10 + 8
|
||||||
|
fg = (gray, gray, gray)
|
||||||
|
elif c.startswith("48;5;"):
|
||||||
|
idx = int(c.split(";")[-1])
|
||||||
|
if idx < 256:
|
||||||
|
if idx < 16:
|
||||||
|
bg = ANSI_COLORS_4BIT.get(idx, bg)
|
||||||
|
elif idx < 232:
|
||||||
|
c_idx = idx - 16
|
||||||
|
bg = (
|
||||||
|
(c_idx >> 4) * 51,
|
||||||
|
((c_idx >> 2) & 7) * 51,
|
||||||
|
(c_idx & 3) * 85,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
gray = (idx - 232) * 10 + 8
|
||||||
|
bg = (gray, gray, gray)
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
current_text += char
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if current_text:
|
||||||
|
tokens.append((current_text, fg, bg, bold))
|
||||||
|
|
||||||
|
return tokens if tokens else [("", fg, bg, bold)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_font_path() -> str | None:
|
||||||
|
"""Get the path to a default monospace font."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def search_dir(base_path: str) -> str | None:
|
||||||
|
if not os.path.exists(base_path):
|
||||||
|
return None
|
||||||
|
if os.path.isfile(base_path):
|
||||||
|
return base_path
|
||||||
|
for font_file in Path(base_path).rglob("*"):
|
||||||
|
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||||
|
name = font_file.stem.lower()
|
||||||
|
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||||
|
return str(font_file)
|
||||||
|
if "mono" in name or "courier" in name or "terminal" in name:
|
||||||
|
return str(font_file)
|
||||||
|
return None
|
||||||
|
|
||||||
|
search_dirs = []
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
search_dirs.extend(
|
||||||
|
[
|
||||||
|
os.path.expanduser("~/Library/Fonts/"),
|
||||||
|
"/System/Library/Fonts/",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif sys.platform == "win32":
|
||||||
|
search_dirs.extend(
|
||||||
|
[
|
||||||
|
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\"),
|
||||||
|
"C:\\Windows\\Fonts\\",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
search_dirs.extend(
|
||||||
|
[
|
||||||
|
os.path.expanduser("~/.local/share/fonts/"),
|
||||||
|
os.path.expanduser("~/.fonts/"),
|
||||||
|
"/usr/share/fonts/",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for search_dir_path in search_dirs:
|
||||||
|
found = search_dir(search_dir_path)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
if sys.platform != "win32":
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
for pattern in ["monospace", "DejaVuSansMono", "LiberationMono"]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["fc-match", "-f", "%{file}", pattern],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
font_file = result.stdout.strip()
|
||||||
|
if os.path.exists(font_file):
|
||||||
|
return font_file
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def render_to_pil(
|
||||||
|
buffer: list[str],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
cell_width: int = 10,
|
||||||
|
cell_height: int = 18,
|
||||||
|
font_path: str | None = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Render buffer to a PIL Image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer: List of text lines to render
|
||||||
|
width: Terminal width in characters
|
||||||
|
height: Terminal height in rows
|
||||||
|
cell_width: Width of each character cell in pixels
|
||||||
|
cell_height: Height of each character cell in pixels
|
||||||
|
font_path: Path to TTF/OTF font file (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL Image object
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
img_width = width * cell_width
|
||||||
|
img_height = height * cell_height
|
||||||
|
|
||||||
|
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
if font_path:
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(font_path, cell_height - 2)
|
||||||
|
except Exception:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
else:
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
for row_idx, line in enumerate(buffer[:height]):
|
||||||
|
if row_idx >= height:
|
||||||
|
break
|
||||||
|
|
||||||
|
tokens = parse_ansi(line)
|
||||||
|
x_pos = 0
|
||||||
|
y_pos = row_idx * cell_height
|
||||||
|
|
||||||
|
for text, fg, bg, _bold in tokens:
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if bg != (0, 0, 0):
|
||||||
|
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||||
|
draw.rectangle(bbox, fill=(*bg, 255))
|
||||||
|
|
||||||
|
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||||
|
|
||||||
|
if font:
|
||||||
|
x_pos += draw.textlength(text, font=font)
|
||||||
|
|
||||||
|
return img
|
||||||
268
engine/display/streaming.py
Normal file
268
engine/display/streaming.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Streaming protocol utilities for efficient frame transmission.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Frame differencing: Only send changed lines
|
||||||
|
- Run-length encoding: Compress repeated lines
|
||||||
|
- Binary encoding: Compact message format
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
class MessageType(IntEnum):
|
||||||
|
"""Message types for streaming protocol."""
|
||||||
|
|
||||||
|
FULL_FRAME = 1
|
||||||
|
DIFF_FRAME = 2
|
||||||
|
STATE = 3
|
||||||
|
CLEAR = 4
|
||||||
|
PING = 5
|
||||||
|
PONG = 6
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameDiff:
|
||||||
|
"""Represents a diff between two frames."""
|
||||||
|
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
changed_lines: list[tuple[int, str]] # (line_index, content)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_diff(old_buffer: list[str], new_buffer: list[str]) -> FrameDiff:
|
||||||
|
"""Compute differences between old and new buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_buffer: Previous frame buffer
|
||||||
|
new_buffer: Current frame buffer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FrameDiff with only changed lines
|
||||||
|
"""
|
||||||
|
height = len(new_buffer)
|
||||||
|
changed_lines = []
|
||||||
|
|
||||||
|
for i, line in enumerate(new_buffer):
|
||||||
|
if i >= len(old_buffer) or line != old_buffer[i]:
|
||||||
|
changed_lines.append((i, line))
|
||||||
|
|
||||||
|
return FrameDiff(
|
||||||
|
width=len(new_buffer[0]) if new_buffer else 0,
|
||||||
|
height=height,
|
||||||
|
changed_lines=changed_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_rle(lines: list[tuple[int, str]]) -> list[tuple[int, str, int]]:
|
||||||
|
"""Run-length encode consecutive identical lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: List of (index, content) tuples (must be sorted by index)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (start_index, content, run_length) tuples
|
||||||
|
"""
|
||||||
|
if not lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
encoded = []
|
||||||
|
start_idx = lines[0][0]
|
||||||
|
current_line = lines[0][1]
|
||||||
|
current_rle = 1
|
||||||
|
|
||||||
|
for idx, line in lines[1:]:
|
||||||
|
if line == current_line:
|
||||||
|
current_rle += 1
|
||||||
|
else:
|
||||||
|
encoded.append((start_idx, current_line, current_rle))
|
||||||
|
start_idx = idx
|
||||||
|
current_line = line
|
||||||
|
current_rle = 1
|
||||||
|
|
||||||
|
encoded.append((start_idx, current_line, current_rle))
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
|
def decode_rle(encoded: list[tuple[int, str, int]]) -> list[tuple[int, str]]:
|
||||||
|
"""Decode run-length encoded lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encoded: List of (start_index, content, run_length) tuples
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (index, content) tuples
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for start_idx, line, rle in encoded:
|
||||||
|
for i in range(rle):
|
||||||
|
result.append((start_idx + i, line))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compress_frame(buffer: list[str], level: int = 6) -> bytes:
|
||||||
|
"""Compress a frame buffer using zlib.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer: Frame buffer (list of lines)
|
||||||
|
level: Compression level (0-9)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compressed bytes
|
||||||
|
"""
|
||||||
|
content = "\n".join(buffer)
|
||||||
|
return zlib.compress(content.encode("utf-8"), level)
|
||||||
|
|
||||||
|
|
||||||
|
def decompress_frame(data: bytes, height: int) -> list[str]:
|
||||||
|
"""Decompress a frame buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Compressed bytes
|
||||||
|
height: Number of lines in original buffer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Frame buffer (list of lines)
|
||||||
|
"""
|
||||||
|
content = zlib.decompress(data).decode("utf-8")
|
||||||
|
lines = content.split("\n")
|
||||||
|
if len(lines) > height:
|
||||||
|
lines = lines[:height]
|
||||||
|
while len(lines) < height:
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def encode_binary_message(
|
||||||
|
msg_type: MessageType, width: int, height: int, payload: bytes
|
||||||
|
) -> bytes:
|
||||||
|
"""Encode a binary message.
|
||||||
|
|
||||||
|
Message format:
|
||||||
|
- 1 byte: message type
|
||||||
|
- 2 bytes: width (uint16)
|
||||||
|
- 2 bytes: height (uint16)
|
||||||
|
- 4 bytes: payload length (uint32)
|
||||||
|
- N bytes: payload
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_type: Message type
|
||||||
|
width: Frame width
|
||||||
|
height: Frame height
|
||||||
|
payload: Message payload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded binary message
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
header = struct.pack("!BHHI", msg_type.value, width, height, len(payload))
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
|
||||||
|
def decode_binary_message(data: bytes) -> tuple[MessageType, int, int, bytes]:
|
||||||
|
"""Decode a binary message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Binary message data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (msg_type, width, height, payload)
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
msg_type_val, width, height, payload_len = struct.unpack("!BHHI", data[:9])
|
||||||
|
payload = data[9 : 9 + payload_len]
|
||||||
|
return MessageType(msg_type_val), width, height, payload
|
||||||
|
|
||||||
|
|
||||||
|
def encode_diff_message(diff: FrameDiff, use_rle: bool = True) -> bytes:
|
||||||
|
"""Encode a diff message for transmission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diff: Frame diff
|
||||||
|
use_rle: Whether to use run-length encoding
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded diff payload
|
||||||
|
"""
|
||||||
|
|
||||||
|
if use_rle:
|
||||||
|
encoded_lines = encode_rle(diff.changed_lines)
|
||||||
|
data = [[idx, line, rle] for idx, line, rle in encoded_lines]
|
||||||
|
else:
|
||||||
|
data = [[idx, line] for idx, line in diff.changed_lines]
|
||||||
|
|
||||||
|
payload = json.dumps(data).encode("utf-8")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def decode_diff_message(payload: bytes, use_rle: bool = True) -> list[tuple[int, str]]:
|
||||||
|
"""Decode a diff message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Encoded diff payload
|
||||||
|
use_rle: Whether run-length encoding was used
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (line_index, content) tuples
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = json.loads(payload.decode("utf-8"))
|
||||||
|
|
||||||
|
if use_rle:
|
||||||
|
return decode_rle([(idx, line, rle) for idx, line, rle in data])
|
||||||
|
else:
|
||||||
|
return [(idx, line) for idx, line in data]
|
||||||
|
|
||||||
|
|
||||||
|
def should_use_diff(
|
||||||
|
old_buffer: list[str], new_buffer: list[str], threshold: float = 0.3
|
||||||
|
) -> bool:
|
||||||
|
"""Determine if diff or full frame is more efficient.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_buffer: Previous frame
|
||||||
|
new_buffer: Current frame
|
||||||
|
threshold: Max changed ratio to use diff (0.0-1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if diff is more efficient
|
||||||
|
"""
|
||||||
|
if not old_buffer or not new_buffer:
|
||||||
|
return False
|
||||||
|
|
||||||
|
diff = compute_diff(old_buffer, new_buffer)
|
||||||
|
total_lines = len(new_buffer)
|
||||||
|
changed_ratio = len(diff.changed_lines) / total_lines if total_lines > 0 else 1.0
|
||||||
|
|
||||||
|
return changed_ratio <= threshold
|
||||||
|
|
||||||
|
|
||||||
|
def apply_diff(old_buffer: list[str], diff: FrameDiff) -> list[str]:
|
||||||
|
"""Apply a diff to an old buffer to get the new buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_buffer: Previous frame buffer
|
||||||
|
diff: Frame diff to apply
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New frame buffer
|
||||||
|
"""
|
||||||
|
new_buffer = list(old_buffer)
|
||||||
|
|
||||||
|
for line_idx, content in diff.changed_lines:
|
||||||
|
if line_idx < len(new_buffer):
|
||||||
|
new_buffer[line_idx] = content
|
||||||
|
else:
|
||||||
|
while len(new_buffer) < line_idx:
|
||||||
|
new_buffer.append("")
|
||||||
|
new_buffer.append(content)
|
||||||
|
|
||||||
|
while len(new_buffer) < diff.height:
|
||||||
|
new_buffer.append("")
|
||||||
|
|
||||||
|
return new_buffer[: diff.height]
|
||||||
@@ -6,18 +6,17 @@ from engine.effects.legacy import (
|
|||||||
glitch_bar,
|
glitch_bar,
|
||||||
next_headline,
|
next_headline,
|
||||||
noise,
|
noise,
|
||||||
|
vis_offset,
|
||||||
vis_trunc,
|
vis_trunc,
|
||||||
)
|
)
|
||||||
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
||||||
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||||
from engine.effects.types import EffectConfig, EffectContext, PipelineConfig
|
from engine.effects.types import (
|
||||||
|
EffectConfig,
|
||||||
|
EffectContext,
|
||||||
def get_effect_chain():
|
PipelineConfig,
|
||||||
from engine.layers import get_effect_chain as _chain
|
create_effect_context,
|
||||||
|
)
|
||||||
return _chain()
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"EffectChain",
|
"EffectChain",
|
||||||
@@ -25,9 +24,9 @@ __all__ = [
|
|||||||
"EffectConfig",
|
"EffectConfig",
|
||||||
"EffectContext",
|
"EffectContext",
|
||||||
"PipelineConfig",
|
"PipelineConfig",
|
||||||
|
"create_effect_context",
|
||||||
"get_registry",
|
"get_registry",
|
||||||
"set_registry",
|
"set_registry",
|
||||||
"get_effect_chain",
|
|
||||||
"get_monitor",
|
"get_monitor",
|
||||||
"set_monitor",
|
"set_monitor",
|
||||||
"PerformanceMonitor",
|
"PerformanceMonitor",
|
||||||
@@ -39,4 +38,5 @@ __all__ = [
|
|||||||
"noise",
|
"noise",
|
||||||
"next_headline",
|
"next_headline",
|
||||||
"vis_trunc",
|
"vis_trunc",
|
||||||
|
"vis_offset",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import time
|
|||||||
|
|
||||||
from engine.effects.performance import PerformanceMonitor, get_monitor
|
from engine.effects.performance import PerformanceMonitor, get_monitor
|
||||||
from engine.effects.registry import EffectRegistry
|
from engine.effects.registry import EffectRegistry
|
||||||
from engine.effects.types import EffectContext
|
from engine.effects.types import EffectContext, PartialUpdate
|
||||||
|
|
||||||
|
|
||||||
class EffectChain:
|
class EffectChain:
|
||||||
@@ -51,6 +51,18 @@ class EffectChain:
|
|||||||
frame_number = ctx.frame_number
|
frame_number = ctx.frame_number
|
||||||
monitor.start_frame(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()
|
frame_start = time.perf_counter()
|
||||||
result = list(buf)
|
result = list(buf)
|
||||||
for name in self._order:
|
for name in self._order:
|
||||||
@@ -59,7 +71,11 @@ class EffectChain:
|
|||||||
chars_in = sum(len(line) for line in result)
|
chars_in = sum(len(line) for line in result)
|
||||||
effect_start = time.perf_counter()
|
effect_start = time.perf_counter()
|
||||||
try:
|
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:
|
except Exception:
|
||||||
plugin.config.enabled = False
|
plugin.config.enabled = False
|
||||||
elapsed = time.perf_counter() - effect_start
|
elapsed = time.perf_counter() - effect_start
|
||||||
|
|||||||
@@ -6,14 +6,7 @@ _effect_chain_ref = None
|
|||||||
|
|
||||||
def _get_effect_chain():
|
def _get_effect_chain():
|
||||||
global _effect_chain_ref
|
global _effect_chain_ref
|
||||||
if _effect_chain_ref is not None:
|
return _effect_chain_ref
|
||||||
return _effect_chain_ref
|
|
||||||
try:
|
|
||||||
from engine.layers import get_effect_chain as _chain
|
|
||||||
|
|
||||||
return _chain()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def set_effect_chain_ref(chain) -> None:
|
def set_effect_chain_ref(chain) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Visual effects: noise, glitch, fade, ANSI-aware truncation, firehose, headline pool.
|
Visual effects: noise, glitch, fade, ANSI-aware truncation, firehose, headline pool.
|
||||||
Depends on: config, terminal, sources.
|
Depends on: config, terminal, sources.
|
||||||
|
|
||||||
|
These are low-level functional implementations of visual effects. They are used
|
||||||
|
internally by the EffectPlugin system (effects_plugins/*.py) and also directly
|
||||||
|
by layers.py and scroll.py for rendering.
|
||||||
|
|
||||||
|
The plugin system provides a higher-level OOP interface with configuration
|
||||||
|
support, while these legacy functions provide direct functional access.
|
||||||
|
Both systems coexist - there are no current plans to deprecate the legacy functions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
@@ -74,6 +82,37 @@ def vis_trunc(s, w):
|
|||||||
return "".join(result)
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
def vis_offset(s, offset):
|
||||||
|
"""Offset string by skipping first offset visual characters, skipping ANSI escape codes."""
|
||||||
|
if offset <= 0:
|
||||||
|
return s
|
||||||
|
result = []
|
||||||
|
vw = 0
|
||||||
|
i = 0
|
||||||
|
skipping = True
|
||||||
|
while i < len(s):
|
||||||
|
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||||
|
j = i + 2
|
||||||
|
while j < len(s) and not s[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
if skipping:
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
result.append(s[i : j + 1])
|
||||||
|
i = j + 1
|
||||||
|
else:
|
||||||
|
if skipping:
|
||||||
|
if vw >= offset:
|
||||||
|
skipping = False
|
||||||
|
result.append(s[i])
|
||||||
|
vw += 1
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
result.append(s[i])
|
||||||
|
i += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
def next_headline(pool, items, seen):
|
def next_headline(pool, items, seen):
|
||||||
"""Pull the next unique headline from pool, refilling as needed."""
|
"""Pull the next unique headline from pool, refilling as needed."""
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ def discover_plugins():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
module = __import__(f"effects_plugins.{module_name}", fromlist=[""])
|
module = __import__(f"engine.effects.plugins.{module_name}", fromlist=[""])
|
||||||
for attr_name in dir(module):
|
for attr_name in dir(module):
|
||||||
attr = getattr(module, attr_name)
|
attr = getattr(module, attr_name)
|
||||||
if (
|
if (
|
||||||
@@ -28,6 +28,8 @@ def discover_plugins():
|
|||||||
and attr_name.endswith("Effect")
|
and attr_name.endswith("Effect")
|
||||||
):
|
):
|
||||||
plugin = attr()
|
plugin = attr()
|
||||||
|
if not isinstance(plugin, EffectPlugin):
|
||||||
|
continue
|
||||||
registry.register(plugin)
|
registry.register(plugin)
|
||||||
imported[plugin.name] = plugin
|
imported[plugin.name] = plugin
|
||||||
except Exception:
|
except Exception:
|
||||||
122
engine/effects/plugins/afterimage.py
Normal file
122
engine/effects/plugins/afterimage.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Afterimage effect using previous frame."""
|
||||||
|
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class AfterimageEffect(EffectPlugin):
|
||||||
|
"""Show a faint ghost of the previous frame.
|
||||||
|
|
||||||
|
This effect requires a FrameBufferStage to be present in the pipeline.
|
||||||
|
It shows a dimmed version of the previous frame super-imposed on the
|
||||||
|
current frame.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: "afterimage"
|
||||||
|
config: EffectConfig with intensity parameter (0.0-1.0)
|
||||||
|
param_bindings: Optional sensor bindings for intensity modulation
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> effect = AfterimageEffect()
|
||||||
|
>>> effect.configure(EffectConfig(intensity=0.3))
|
||||||
|
>>> result = effect.process(buffer, ctx)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "afterimage"
|
||||||
|
config: EffectConfig = EffectConfig(enabled=True, intensity=0.3)
|
||||||
|
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||||
|
supports_partial_updates = False
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
"""Apply afterimage effect using the previous frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: Current text buffer (list of strings)
|
||||||
|
ctx: Effect context with access to framebuffer history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Buffer with ghost of previous frame overlaid
|
||||||
|
"""
|
||||||
|
if not buf:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get framebuffer history from context
|
||||||
|
history = None
|
||||||
|
|
||||||
|
for key in ctx.state:
|
||||||
|
if key.startswith("framebuffer.") and key.endswith(".history"):
|
||||||
|
history = ctx.state[key]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not history or len(history) < 1:
|
||||||
|
# No previous frame available
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get intensity from config
|
||||||
|
intensity = self.config.params.get("intensity", self.config.intensity)
|
||||||
|
intensity = max(0.0, min(1.0, intensity))
|
||||||
|
|
||||||
|
if intensity <= 0.0:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get the previous frame (index 1, since index 0 is current)
|
||||||
|
prev_frame = history[1] if len(history) > 1 else None
|
||||||
|
if not prev_frame:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Blend current and previous frames
|
||||||
|
viewport_height = ctx.terminal_height - ctx.ticker_height
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for row in range(len(buf)):
|
||||||
|
if row >= viewport_height:
|
||||||
|
result.append(buf[row])
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_line = buf[row]
|
||||||
|
prev_line = prev_frame[row] if row < len(prev_frame) else ""
|
||||||
|
|
||||||
|
if not prev_line:
|
||||||
|
result.append(current_line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply dimming effect by reducing ANSI color intensity or adding transparency
|
||||||
|
# For a simple text version, we'll use a blend strategy
|
||||||
|
blended = self._blend_lines(current_line, prev_line, intensity)
|
||||||
|
result.append(blended)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _blend_lines(self, current: str, previous: str, intensity: float) -> str:
|
||||||
|
"""Blend current and previous line with given intensity.
|
||||||
|
|
||||||
|
For text with ANSI codes, true blending is complex. This is a simplified
|
||||||
|
version that uses color averaging when possible.
|
||||||
|
|
||||||
|
A more sophisticated implementation would:
|
||||||
|
1. Parse ANSI color codes from both lines
|
||||||
|
2. Blend RGB values based on intensity
|
||||||
|
3. Reconstruct the line with blended colors
|
||||||
|
|
||||||
|
For now, we'll use a heuristic: if lines are similar, return current.
|
||||||
|
If they differ, we alternate or use the previous as a faint overlay.
|
||||||
|
"""
|
||||||
|
if current == previous:
|
||||||
|
return current
|
||||||
|
|
||||||
|
# Simple blending: intensity determines mix
|
||||||
|
# intensity=1.0 => fully current
|
||||||
|
# intensity=0.3 => 70% previous ghost, 30% current
|
||||||
|
|
||||||
|
if intensity > 0.7:
|
||||||
|
return current
|
||||||
|
elif intensity < 0.3:
|
||||||
|
# Show previous but dimmed (simulate by adding faint color/gray)
|
||||||
|
return previous # Would need to dim ANSI colors
|
||||||
|
else:
|
||||||
|
# For medium intensity, alternate based on character pattern
|
||||||
|
# This is a placeholder for proper blending
|
||||||
|
return current
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
"""Configure the effect."""
|
||||||
|
self.config = config
|
||||||
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):
|
class FadeEffect(EffectPlugin):
|
||||||
name = "fade"
|
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]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
if not ctx.ticker_height:
|
if not ctx.ticker_height:
|
||||||
@@ -36,7 +36,7 @@ class FadeEffect(EffectPlugin):
|
|||||||
if fade >= 1.0:
|
if fade >= 1.0:
|
||||||
return s
|
return s
|
||||||
if fade <= 0.0:
|
if fade <= 0.0:
|
||||||
return ""
|
return s # Preserve original line length - don't return empty
|
||||||
result = []
|
result = []
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(s):
|
while i < len(s):
|
||||||
@@ -54,5 +54,5 @@ class FadeEffect(EffectPlugin):
|
|||||||
i += 1
|
i += 1
|
||||||
return "".join(result)
|
return "".join(result)
|
||||||
|
|
||||||
def configure(self, cfg: EffectConfig) -> None:
|
def configure(self, config: EffectConfig) -> None:
|
||||||
self.config = cfg
|
self.config = config
|
||||||
@@ -9,7 +9,7 @@ from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
|||||||
|
|
||||||
class FirehoseEffect(EffectPlugin):
|
class FirehoseEffect(EffectPlugin):
|
||||||
name = "firehose"
|
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]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
@@ -68,5 +68,5 @@ class FirehoseEffect(EffectPlugin):
|
|||||||
color = random.choice([G_LO, C_DIM, W_GHOST])
|
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||||
return f"{color}{text}{RST}"
|
return f"{color}{text}{RST}"
|
||||||
|
|
||||||
def configure(self, cfg: EffectConfig) -> None:
|
def configure(self, config: EffectConfig) -> None:
|
||||||
self.config = cfg
|
self.config = config
|
||||||
52
engine/effects/plugins/glitch.py
Normal file
52
engine/effects/plugins/glitch.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.terminal import DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
|
class GlitchEffect(EffectPlugin):
|
||||||
|
name = "glitch"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0, 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
|
||||||
|
else:
|
||||||
|
result.append(line)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
self.config = config
|
||||||
119
engine/effects/plugins/motionblur.py
Normal file
119
engine/effects/plugins/motionblur.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Motion blur effect using frame history."""
|
||||||
|
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class MotionBlurEffect(EffectPlugin):
|
||||||
|
"""Apply motion blur by blending current frame with previous frames.
|
||||||
|
|
||||||
|
This effect requires a FrameBufferStage to be present in the pipeline.
|
||||||
|
The framebuffer provides frame history which is blended with the current
|
||||||
|
frame based on intensity.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: "motionblur"
|
||||||
|
config: EffectConfig with intensity parameter (0.0-1.0)
|
||||||
|
param_bindings: Optional sensor bindings for intensity modulation
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> effect = MotionBlurEffect()
|
||||||
|
>>> effect.configure(EffectConfig(intensity=0.5))
|
||||||
|
>>> result = effect.process(buffer, ctx)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "motionblur"
|
||||||
|
config: EffectConfig = EffectConfig(enabled=True, intensity=0.5)
|
||||||
|
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||||
|
supports_partial_updates = False
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
"""Apply motion blur by blending with previous frames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: Current text buffer (list of strings)
|
||||||
|
ctx: Effect context with access to framebuffer history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Blended buffer with motion blur effect applied
|
||||||
|
"""
|
||||||
|
if not buf:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get framebuffer history from context
|
||||||
|
# We'll look for the first available framebuffer history
|
||||||
|
history = None
|
||||||
|
|
||||||
|
for key in ctx.state:
|
||||||
|
if key.startswith("framebuffer.") and key.endswith(".history"):
|
||||||
|
history = ctx.state[key]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not history:
|
||||||
|
# No framebuffer available, return unchanged
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get intensity from config
|
||||||
|
intensity = self.config.params.get("intensity", self.config.intensity)
|
||||||
|
intensity = max(0.0, min(1.0, intensity))
|
||||||
|
|
||||||
|
if intensity <= 0.0:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get decay factor (how quickly older frames fade)
|
||||||
|
decay = self.config.params.get("decay", 0.7)
|
||||||
|
|
||||||
|
# Build output buffer
|
||||||
|
result = []
|
||||||
|
viewport_height = ctx.terminal_height - ctx.ticker_height
|
||||||
|
|
||||||
|
# Determine how many frames to blend (up to history depth)
|
||||||
|
max_frames = min(len(history), 5) # Cap at 5 frames for performance
|
||||||
|
|
||||||
|
for row in range(len(buf)):
|
||||||
|
if row >= viewport_height:
|
||||||
|
# Beyond viewport, just copy
|
||||||
|
result.append(buf[row])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Start with current frame
|
||||||
|
blended = buf[row]
|
||||||
|
|
||||||
|
# Blend with historical frames
|
||||||
|
weight_sum = 1.0
|
||||||
|
if max_frames > 0 and intensity > 0:
|
||||||
|
for i in range(max_frames):
|
||||||
|
frame_weight = intensity * (decay**i)
|
||||||
|
if frame_weight < 0.01: # Skip negligible weights
|
||||||
|
break
|
||||||
|
|
||||||
|
hist_row = history[i][row] if row < len(history[i]) else ""
|
||||||
|
# Simple string blending: we'll concatenate with space
|
||||||
|
# For a proper effect, we'd need to blend ANSI colors
|
||||||
|
# This is a simplified version that just adds the frames
|
||||||
|
blended = self._blend_strings(blended, hist_row, frame_weight)
|
||||||
|
weight_sum += frame_weight
|
||||||
|
|
||||||
|
result.append(blended)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _blend_strings(self, current: str, historical: str, weight: float) -> str:
|
||||||
|
"""Blend two strings with given weight.
|
||||||
|
|
||||||
|
This is a simplified blending that works with ANSI codes.
|
||||||
|
For proper blending we'd need to parse colors, but for now
|
||||||
|
we use a heuristic: if strings are identical, return one.
|
||||||
|
If they differ, we alternate or concatenate based on weight.
|
||||||
|
"""
|
||||||
|
if current == historical:
|
||||||
|
return current
|
||||||
|
|
||||||
|
# If weight is high, show current; if low, show historical
|
||||||
|
if weight > 0.5:
|
||||||
|
return current
|
||||||
|
else:
|
||||||
|
return historical
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
"""Configure the effect."""
|
||||||
|
self.config = config
|
||||||
@@ -7,7 +7,7 @@ from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
|||||||
|
|
||||||
class NoiseEffect(EffectPlugin):
|
class NoiseEffect(EffectPlugin):
|
||||||
name = "noise"
|
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]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
if not ctx.ticker_height:
|
if not ctx.ticker_height:
|
||||||
@@ -19,7 +19,8 @@ class NoiseEffect(EffectPlugin):
|
|||||||
for r in range(len(result)):
|
for r in range(len(result)):
|
||||||
cy = ctx.scroll_cam + r
|
cy = ctx.scroll_cam + r
|
||||||
if random.random() < probability:
|
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
|
return result
|
||||||
|
|
||||||
def _generate_noise(self, w: int, cy: int) -> str:
|
def _generate_noise(self, w: int, cy: int) -> str:
|
||||||
@@ -32,5 +33,5 @@ class NoiseEffect(EffectPlugin):
|
|||||||
for _ in range(w)
|
for _ in range(w)
|
||||||
)
|
)
|
||||||
|
|
||||||
def configure(self, cfg: EffectConfig) -> None:
|
def configure(self, config: EffectConfig) -> None:
|
||||||
self.config = cfg
|
self.config = config
|
||||||
99
engine/effects/plugins/tint.py
Normal file
99
engine/effects/plugins/tint.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class TintEffect(EffectPlugin):
|
||||||
|
"""Tint effect that applies an RGB color overlay to the buffer.
|
||||||
|
|
||||||
|
Uses ANSI escape codes to tint text with the specified RGB values.
|
||||||
|
Supports transparency (0-100%) for blending.
|
||||||
|
|
||||||
|
Inlets:
|
||||||
|
- r: Red component (0-255)
|
||||||
|
- g: Green component (0-255)
|
||||||
|
- b: Blue component (0-255)
|
||||||
|
- a: Alpha/transparency (0.0-1.0, where 0.0 = fully transparent)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "tint"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
# Define inlet types for PureData-style typing
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not buf:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get tint values from effect params or sensors
|
||||||
|
r = self.config.params.get("r", 255)
|
||||||
|
g = self.config.params.get("g", 255)
|
||||||
|
b = self.config.params.get("b", 255)
|
||||||
|
a = self.config.params.get("a", 0.3) # Default 30% tint
|
||||||
|
|
||||||
|
# Clamp values
|
||||||
|
r = max(0, min(255, int(r)))
|
||||||
|
g = max(0, min(255, int(g)))
|
||||||
|
b = max(0, min(255, int(b)))
|
||||||
|
a = max(0.0, min(1.0, float(a)))
|
||||||
|
|
||||||
|
if a <= 0:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Convert RGB to ANSI 256 color
|
||||||
|
ansi_color = self._rgb_to_ansi256(r, g, b)
|
||||||
|
|
||||||
|
# Apply tint with transparency effect
|
||||||
|
result = []
|
||||||
|
for line in buf:
|
||||||
|
if not line.strip():
|
||||||
|
result.append(line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if line already has ANSI codes
|
||||||
|
if "\033[" in line:
|
||||||
|
# For lines with existing colors, wrap the whole line
|
||||||
|
result.append(f"\033[38;5;{ansi_color}m{line}\033[0m")
|
||||||
|
else:
|
||||||
|
# Apply tint to plain text lines
|
||||||
|
result.append(f"\033[38;5;{ansi_color}m{line}\033[0m")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _rgb_to_ansi256(self, r: int, g: int, b: int) -> int:
|
||||||
|
"""Convert RGB (0-255 each) to ANSI 256 color code."""
|
||||||
|
if r == g == b == 0:
|
||||||
|
return 16
|
||||||
|
if r == g == b == 255:
|
||||||
|
return 231
|
||||||
|
|
||||||
|
# Calculate grayscale
|
||||||
|
gray = int((0.299 * r + 0.587 * g + 0.114 * b) / 255 * 24) + 232
|
||||||
|
|
||||||
|
# Calculate color cube
|
||||||
|
ri = int(r / 51)
|
||||||
|
gi = int(g / 51)
|
||||||
|
bi = int(b / 51)
|
||||||
|
color = 16 + 36 * ri + 6 * gi + bi
|
||||||
|
|
||||||
|
# Use whichever is closer - gray or color
|
||||||
|
gray_dist = abs(r - gray)
|
||||||
|
color_dist = (
|
||||||
|
(r - ri * 51) ** 2 + (g - gi * 51) ** 2 + (b - bi * 51) ** 2
|
||||||
|
) ** 0.5
|
||||||
|
|
||||||
|
if gray_dist < color_dist:
|
||||||
|
return gray
|
||||||
|
return color
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
self.config = config
|
||||||
@@ -1,10 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Visual effects type definitions and base classes.
|
||||||
|
|
||||||
|
EffectPlugin Architecture:
|
||||||
|
- Uses ABC (Abstract Base Class) for interface enforcement
|
||||||
|
- Runtime discovery via directory scanning (effects_plugins/)
|
||||||
|
- Configuration via EffectConfig dataclass
|
||||||
|
- Context passed through EffectContext dataclass
|
||||||
|
|
||||||
|
Plugin System Research (see AGENTS.md for references):
|
||||||
|
- VST: Standardized audio interfaces, chaining, presets (FXP/FXB)
|
||||||
|
- Python Entry Points: Namespace packages, importlib.metadata discovery
|
||||||
|
- Shadertoy: Shader-based with uniforms as context
|
||||||
|
|
||||||
|
Current gaps vs industry patterns:
|
||||||
|
- No preset save/load system
|
||||||
|
- No external plugin distribution via entry points
|
||||||
|
- No plugin metadata (version, author, description)
|
||||||
|
"""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
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
|
@dataclass
|
||||||
class EffectContext:
|
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_width: int
|
||||||
terminal_height: int
|
terminal_height: int
|
||||||
scroll_cam: int
|
scroll_cam: int
|
||||||
@@ -15,24 +59,140 @@ class EffectContext:
|
|||||||
frame_number: int = 0
|
frame_number: int = 0
|
||||||
has_message: bool = False
|
has_message: bool = False
|
||||||
items: list = field(default_factory=list)
|
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)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> dict[str, Any]:
|
||||||
|
"""Get the state dictionary for direct access by effects."""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EffectConfig:
|
class EffectConfig:
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
intensity: float = 1.0
|
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)
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class EffectPlugin(ABC):
|
class EffectPlugin(ABC):
|
||||||
|
"""Abstract base class for effect plugins.
|
||||||
|
|
||||||
|
Subclasses must define:
|
||||||
|
- name: str - unique identifier for the effect
|
||||||
|
- config: EffectConfig - current configuration
|
||||||
|
|
||||||
|
Optional class attribute:
|
||||||
|
- param_bindings: dict - Declarative sensor-to-param bindings
|
||||||
|
Example:
|
||||||
|
param_bindings = {
|
||||||
|
"intensity": {"sensor": "mic", "transform": "linear"},
|
||||||
|
"rate": {"sensor": "mic", "transform": "exponential"},
|
||||||
|
}
|
||||||
|
|
||||||
|
And implement:
|
||||||
|
- process(buf, ctx) -> list[str]
|
||||||
|
- configure(config) -> None
|
||||||
|
|
||||||
|
Effect Behavior with ticker_height=0:
|
||||||
|
- NoiseEffect: Returns buffer unchanged (no ticker to apply noise to)
|
||||||
|
- FadeEffect: Returns buffer unchanged (no ticker to fade)
|
||||||
|
- GlitchEffect: Processes normally (doesn't depend on ticker_height)
|
||||||
|
- FirehoseEffect: Returns buffer unchanged if no items in context
|
||||||
|
|
||||||
|
Effects should handle missing or zero context values gracefully by
|
||||||
|
returning the input buffer unchanged rather than raising errors.
|
||||||
|
|
||||||
|
The param_bindings system enables PureData-style signal routing:
|
||||||
|
- Sensors emit values that effects can bind to
|
||||||
|
- Transform functions map sensor values to param ranges
|
||||||
|
- Effects read bound values from context.state["sensor.{name}"]
|
||||||
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
config: EffectConfig
|
config: EffectConfig
|
||||||
|
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||||
|
supports_partial_updates: bool = False # Override in subclasses for optimization
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: ...
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
"""Process the buffer with this effect applied.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: List of lines to process
|
||||||
|
ctx: Effect context with terminal state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Processed buffer (may be same object or new list)
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def process_partial(
|
||||||
|
self, buf: list[str], ctx: EffectContext, partial: PartialUpdate
|
||||||
|
) -> list[str]:
|
||||||
|
"""Process a partial buffer for optimized rendering.
|
||||||
|
|
||||||
|
Override this in subclasses that support partial updates for performance.
|
||||||
|
Default implementation falls back to full buffer processing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: List of lines to process
|
||||||
|
ctx: Effect context with terminal state
|
||||||
|
partial: PartialUpdate indicating which regions changed
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Processed buffer (may be same object or new list)
|
||||||
|
"""
|
||||||
|
# Default: fall back to full processing
|
||||||
|
return self.process(buf, ctx)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def configure(self, config: EffectConfig) -> None: ...
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
"""Configure the effect with new settings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: New configuration to apply
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
def create_effect_context(
|
def create_effect_context(
|
||||||
@@ -40,7 +200,6 @@ def create_effect_context(
|
|||||||
terminal_height: int = 24,
|
terminal_height: int = 24,
|
||||||
scroll_cam: int = 0,
|
scroll_cam: int = 0,
|
||||||
ticker_height: int = 0,
|
ticker_height: int = 0,
|
||||||
camera_x: int = 0,
|
|
||||||
mic_excess: float = 0.0,
|
mic_excess: float = 0.0,
|
||||||
grad_offset: float = 0.0,
|
grad_offset: float = 0.0,
|
||||||
frame_number: int = 0,
|
frame_number: int = 0,
|
||||||
@@ -53,7 +212,6 @@ def create_effect_context(
|
|||||||
terminal_height=terminal_height,
|
terminal_height=terminal_height,
|
||||||
scroll_cam=scroll_cam,
|
scroll_cam=scroll_cam,
|
||||||
ticker_height=ticker_height,
|
ticker_height=ticker_height,
|
||||||
camera_x=camera_x,
|
|
||||||
mic_excess=mic_excess,
|
mic_excess=mic_excess,
|
||||||
grad_offset=grad_offset,
|
grad_offset=grad_offset,
|
||||||
frame_number=frame_number,
|
frame_number=frame_number,
|
||||||
@@ -66,3 +224,58 @@ def create_effect_context(
|
|||||||
class PipelineConfig:
|
class PipelineConfig:
|
||||||
order: list[str] = field(default_factory=list)
|
order: list[str] = field(default_factory=list)
|
||||||
effects: dict[str, EffectConfig] = field(default_factory=dict)
|
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: ...
|
|
||||||
@@ -18,7 +18,6 @@ class EventType(Enum):
|
|||||||
NTFY_MESSAGE = auto()
|
NTFY_MESSAGE = auto()
|
||||||
STREAM_START = auto()
|
STREAM_START = auto()
|
||||||
STREAM_END = auto()
|
STREAM_END = auto()
|
||||||
FIGMENT_TRIGGER = auto()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -66,12 +65,3 @@ class StreamEvent:
|
|||||||
event_type: EventType
|
event_type: EventType
|
||||||
headline_count: int = 0
|
headline_count: int = 0
|
||||||
timestamp: datetime | None = None
|
timestamp: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FigmentTriggerEvent:
|
|
||||||
"""Event emitted when a figment is triggered."""
|
|
||||||
|
|
||||||
action: str
|
|
||||||
value: float | str | None = None
|
|
||||||
timestamp: datetime | None = None
|
|
||||||
|
|||||||
157
engine/fetch.py
157
engine/fetch.py
@@ -7,6 +7,7 @@ import json
|
|||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,54 +18,98 @@ from engine.filter import skip, strip_tags
|
|||||||
from engine.sources import FEEDS, POETRY_SOURCES
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
from engine.terminal import boot_ln
|
from engine.terminal import boot_ln
|
||||||
|
|
||||||
# Type alias for headline items
|
|
||||||
HeadlineTuple = tuple[str, str, str]
|
HeadlineTuple = tuple[str, str, str]
|
||||||
|
|
||||||
|
DEFAULT_MAX_WORKERS = 10
|
||||||
|
FAST_START_SOURCES = 5
|
||||||
|
FAST_START_TIMEOUT = 3
|
||||||
|
|
||||||
# ─── SINGLE FEED ──────────────────────────────────────────
|
|
||||||
def fetch_feed(url: str) -> Any | None:
|
def fetch_feed(url: str) -> tuple[str, Any] | tuple[None, None]:
|
||||||
"""Fetch and parse a single RSS feed URL."""
|
"""Fetch and parse a single RSS feed URL. Returns (url, feed) tuple."""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
timeout = FAST_START_TIMEOUT if url in _fast_start_urls else config.FEED_TIMEOUT
|
||||||
return feedparser.parse(resp.read())
|
resp = urllib.request.urlopen(req, timeout=timeout)
|
||||||
|
return (url, feedparser.parse(resp.read()))
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return (url, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_feed(feed: Any, src: str) -> list[HeadlineTuple]:
|
||||||
|
"""Parse a feed and return list of headline tuples."""
|
||||||
|
items = []
|
||||||
|
if feed is None or (feed.bozo and not feed.entries):
|
||||||
|
return items
|
||||||
|
|
||||||
|
for e in feed.entries:
|
||||||
|
t = strip_tags(e.get("title", ""))
|
||||||
|
if not t or skip(t):
|
||||||
|
continue
|
||||||
|
pub = e.get("published_parsed") or e.get("updated_parsed")
|
||||||
|
try:
|
||||||
|
ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——"
|
||||||
|
except Exception:
|
||||||
|
ts = "——:——"
|
||||||
|
items.append((t, src, ts))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_fast() -> list[HeadlineTuple]:
|
||||||
|
"""Fetch only the first N sources for fast startup."""
|
||||||
|
global _fast_start_urls
|
||||||
|
_fast_start_urls = set(list(FEEDS.values())[:FAST_START_SOURCES])
|
||||||
|
|
||||||
|
items: list[HeadlineTuple] = []
|
||||||
|
with ThreadPoolExecutor(max_workers=FAST_START_SOURCES) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(fetch_feed, url): src
|
||||||
|
for src, url in list(FEEDS.items())[:FAST_START_SOURCES]
|
||||||
|
}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
src = futures[future]
|
||||||
|
url, feed = future.result()
|
||||||
|
if feed is None or (feed.bozo and not feed.entries):
|
||||||
|
boot_ln(src, "DARK", False)
|
||||||
|
continue
|
||||||
|
parsed = _parse_feed(feed, src)
|
||||||
|
if parsed:
|
||||||
|
items.extend(parsed)
|
||||||
|
boot_ln(src, f"LINKED [{len(parsed)}]", True)
|
||||||
|
else:
|
||||||
|
boot_ln(src, "EMPTY", False)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
|
||||||
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
||||||
"""Fetch all RSS feeds and return items, linked count, failed count."""
|
"""Fetch all RSS feeds concurrently and return items, linked count, failed count."""
|
||||||
|
global _fast_start_urls
|
||||||
|
_fast_start_urls = set()
|
||||||
|
|
||||||
items: list[HeadlineTuple] = []
|
items: list[HeadlineTuple] = []
|
||||||
linked = failed = 0
|
linked = failed = 0
|
||||||
for src, url in FEEDS.items():
|
|
||||||
feed = fetch_feed(url)
|
with ThreadPoolExecutor(max_workers=DEFAULT_MAX_WORKERS) as executor:
|
||||||
if feed is None or (feed.bozo and not feed.entries):
|
futures = {executor.submit(fetch_feed, url): src for src, url in FEEDS.items()}
|
||||||
boot_ln(src, "DARK", False)
|
for future in as_completed(futures):
|
||||||
failed += 1
|
src = futures[future]
|
||||||
continue
|
url, feed = future.result()
|
||||||
n = 0
|
if feed is None or (feed.bozo and not feed.entries):
|
||||||
for e in feed.entries:
|
boot_ln(src, "DARK", False)
|
||||||
t = strip_tags(e.get("title", ""))
|
failed += 1
|
||||||
if not t or skip(t):
|
|
||||||
continue
|
continue
|
||||||
pub = e.get("published_parsed") or e.get("updated_parsed")
|
parsed = _parse_feed(feed, src)
|
||||||
try:
|
if parsed:
|
||||||
ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——"
|
items.extend(parsed)
|
||||||
except Exception:
|
boot_ln(src, f"LINKED [{len(parsed)}]", True)
|
||||||
ts = "——:——"
|
linked += 1
|
||||||
items.append((t, src, ts))
|
else:
|
||||||
n += 1
|
boot_ln(src, "EMPTY", False)
|
||||||
if n:
|
failed += 1
|
||||||
boot_ln(src, f"LINKED [{n}]", True)
|
|
||||||
linked += 1
|
|
||||||
else:
|
|
||||||
boot_ln(src, "EMPTY", False)
|
|
||||||
failed += 1
|
|
||||||
return items, linked, failed
|
return items, linked, failed
|
||||||
|
|
||||||
|
|
||||||
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
|
||||||
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
||||||
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||||
try:
|
try:
|
||||||
@@ -76,23 +121,21 @@ def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
|||||||
.replace("\r\n", "\n")
|
.replace("\r\n", "\n")
|
||||||
.replace("\r", "\n")
|
.replace("\r", "\n")
|
||||||
)
|
)
|
||||||
# Strip PG boilerplate
|
|
||||||
m = re.search(r"\*\*\*\s*START OF[^\n]*\n", text)
|
m = re.search(r"\*\*\*\s*START OF[^\n]*\n", text)
|
||||||
if m:
|
if m:
|
||||||
text = text[m.end() :]
|
text = text[m.end() :]
|
||||||
m = re.search(r"\*\*\*\s*END OF", text)
|
m = re.search(r"\*\*\*\s*END OF", text)
|
||||||
if m:
|
if m:
|
||||||
text = text[: m.start()]
|
text = text[: m.start()]
|
||||||
# Split on blank lines into stanzas/passages
|
|
||||||
blocks = re.split(r"\n{2,}", text.strip())
|
blocks = re.split(r"\n{2,}", text.strip())
|
||||||
items = []
|
items = []
|
||||||
for blk in blocks:
|
for blk in blocks:
|
||||||
blk = " ".join(blk.split()) # flatten to one line
|
blk = " ".join(blk.split())
|
||||||
if len(blk) < 20 or len(blk) > 280:
|
if len(blk) < 20 or len(blk) > 280:
|
||||||
continue
|
continue
|
||||||
if blk.isupper(): # skip all-caps headers
|
if blk.isupper():
|
||||||
continue
|
continue
|
||||||
if re.match(r"^[IVXLCDM]+\.?\s*$", blk): # roman numerals
|
if re.match(r"^[IVXLCDM]+\.?\s*$", blk):
|
||||||
continue
|
continue
|
||||||
items.append((blk, label, ""))
|
items.append((blk, label, ""))
|
||||||
return items
|
return items
|
||||||
@@ -100,28 +143,35 @@ def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def fetch_poetry():
|
def fetch_poetry() -> tuple[list[HeadlineTuple], int, int]:
|
||||||
"""Fetch all poetry/literature sources."""
|
"""Fetch all poetry/literature sources concurrently."""
|
||||||
items = []
|
items = []
|
||||||
linked = failed = 0
|
linked = failed = 0
|
||||||
for label, url in POETRY_SOURCES.items():
|
|
||||||
stanzas = _fetch_gutenberg(url, label)
|
with ThreadPoolExecutor(max_workers=DEFAULT_MAX_WORKERS) as executor:
|
||||||
if stanzas:
|
futures = {
|
||||||
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
|
executor.submit(_fetch_gutenberg, url, label): label
|
||||||
items.extend(stanzas)
|
for label, url in POETRY_SOURCES.items()
|
||||||
linked += 1
|
}
|
||||||
else:
|
for future in as_completed(futures):
|
||||||
boot_ln(label, "DARK", False)
|
label = futures[future]
|
||||||
failed += 1
|
stanzas = future.result()
|
||||||
|
if stanzas:
|
||||||
|
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
|
||||||
|
items.extend(stanzas)
|
||||||
|
linked += 1
|
||||||
|
else:
|
||||||
|
boot_ln(label, "DARK", False)
|
||||||
|
failed += 1
|
||||||
|
|
||||||
return items, linked, failed
|
return items, linked, failed
|
||||||
|
|
||||||
|
|
||||||
# ─── CACHE ────────────────────────────────────────────────
|
_cache_dir = pathlib.Path(__file__).resolve().parent / "fixtures"
|
||||||
_CACHE_DIR = pathlib.Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_path():
|
def _cache_path():
|
||||||
return _CACHE_DIR / f".mainline_cache_{config.MODE}.json"
|
return _cache_dir / "headlines.json"
|
||||||
|
|
||||||
|
|
||||||
def load_cache():
|
def load_cache():
|
||||||
@@ -143,3 +193,6 @@ def save_cache(items):
|
|||||||
_cache_path().write_text(json.dumps({"items": items}))
|
_cache_path().write_text(json.dumps({"items": items}))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_fast_start_urls: set = set()
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
"""
|
|
||||||
Source code feed — reads engine/*.py and emits non-blank, non-comment lines
|
|
||||||
as scroll items. Used by --code mode.
|
|
||||||
Depends on: nothing (stdlib only).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import ast
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
_ENGINE_DIR = Path(__file__).resolve().parent
|
|
||||||
|
|
||||||
|
|
||||||
def _scope_map(source: str) -> dict[int, str]:
|
|
||||||
"""Return {line_number: scope_label} for every line in source.
|
|
||||||
|
|
||||||
Nodes are sorted by range size descending so inner scopes overwrite
|
|
||||||
outer ones, guaranteeing the narrowest enclosing scope wins.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tree = ast.parse(source)
|
|
||||||
except SyntaxError:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
nodes = []
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
||||||
end = getattr(node, "end_lineno", node.lineno)
|
|
||||||
span = end - node.lineno
|
|
||||||
nodes.append((span, node))
|
|
||||||
|
|
||||||
# Largest range first → inner scopes overwrite on second pass
|
|
||||||
nodes.sort(key=lambda x: x[0], reverse=True)
|
|
||||||
|
|
||||||
scope = {}
|
|
||||||
for _, node in nodes:
|
|
||||||
end = getattr(node, "end_lineno", node.lineno)
|
|
||||||
if isinstance(node, ast.ClassDef):
|
|
||||||
label = node.name
|
|
||||||
else:
|
|
||||||
label = f"{node.name}()"
|
|
||||||
for ln in range(node.lineno, end + 1):
|
|
||||||
scope[ln] = label
|
|
||||||
|
|
||||||
return scope
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_code():
|
|
||||||
"""Read engine/*.py and return (items, line_count, 0).
|
|
||||||
|
|
||||||
Each item is (text, src, ts) where:
|
|
||||||
text = the code line (rstripped, indentation preserved)
|
|
||||||
src = enclosing function/class name, e.g. 'stream()' or '<module>'
|
|
||||||
ts = dotted module path, e.g. 'engine.scroll'
|
|
||||||
"""
|
|
||||||
items = []
|
|
||||||
for path in sorted(_ENGINE_DIR.glob("*.py")):
|
|
||||||
module = f"engine.{path.stem}"
|
|
||||||
source = path.read_text(encoding="utf-8")
|
|
||||||
scope = _scope_map(source)
|
|
||||||
for lineno, raw in enumerate(source.splitlines(), start=1):
|
|
||||||
stripped = raw.strip()
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
continue
|
|
||||||
label = scope.get(lineno, "<module>")
|
|
||||||
items.append((raw.rstrip(), label, module))
|
|
||||||
|
|
||||||
return items, len(items), 0
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
"""
|
|
||||||
SVG to half-block terminal art rasterization.
|
|
||||||
|
|
||||||
Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
|
|
||||||
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
|
|
||||||
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
|
|
||||||
# /usr/local/lib (Intel), which are not in dyld's default search path.
|
|
||||||
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
|
|
||||||
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
|
|
||||||
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
|
|
||||||
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
|
|
||||||
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
|
|
||||||
break
|
|
||||||
|
|
||||||
import cairosvg
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
_cache: dict[tuple[str, int, int], list[str]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
|
||||||
"""Convert SVG file to list of half-block terminal rows (uncolored).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
svg_path: Path to SVG file.
|
|
||||||
width: Target terminal width in columns.
|
|
||||||
height: Target terminal height in rows.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of strings, one per terminal row, containing block characters.
|
|
||||||
"""
|
|
||||||
cache_key = (svg_path, width, height)
|
|
||||||
if cache_key in _cache:
|
|
||||||
return _cache[cache_key]
|
|
||||||
|
|
||||||
# SVG -> PNG in memory
|
|
||||||
png_bytes = cairosvg.svg2png(
|
|
||||||
url=svg_path,
|
|
||||||
output_width=width,
|
|
||||||
output_height=height * 2, # 2 pixel rows per terminal row
|
|
||||||
)
|
|
||||||
|
|
||||||
# PNG -> greyscale PIL image
|
|
||||||
# Composite RGBA onto white background so transparent areas become white (255)
|
|
||||||
# and drawn pixels retain their luminance values.
|
|
||||||
img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA")
|
|
||||||
img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS)
|
|
||||||
background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255))
|
|
||||||
background.paste(img_rgba, mask=img_rgba.split()[3])
|
|
||||||
img = background.convert("L")
|
|
||||||
|
|
||||||
data = img.tobytes()
|
|
||||||
pix_w = width
|
|
||||||
pix_h = height * 2
|
|
||||||
# White (255) = empty space, dark (< threshold) = filled pixel
|
|
||||||
threshold = 128
|
|
||||||
|
|
||||||
# Half-block encode: walk pixel pairs
|
|
||||||
rows: list[str] = []
|
|
||||||
for y in range(0, pix_h, 2):
|
|
||||||
row: list[str] = []
|
|
||||||
for x in range(pix_w):
|
|
||||||
top = data[y * pix_w + x] < threshold
|
|
||||||
bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False
|
|
||||||
if top and bot:
|
|
||||||
row.append("█")
|
|
||||||
elif top:
|
|
||||||
row.append("▀")
|
|
||||||
elif bot:
|
|
||||||
row.append("▄")
|
|
||||||
else:
|
|
||||||
row.append(" ")
|
|
||||||
rows.append("".join(row))
|
|
||||||
|
|
||||||
_cache[cache_key] = rows
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def clear_cache() -> None:
|
|
||||||
"""Clear the rasterization cache (e.g., on terminal resize)."""
|
|
||||||
_cache.clear()
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""
|
|
||||||
Figment trigger protocol and command types.
|
|
||||||
|
|
||||||
Defines the extensible input abstraction for triggering figment displays
|
|
||||||
from any control surface (ntfy, MQTT, serial, etc.).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class FigmentAction(Enum):
|
|
||||||
TRIGGER = "trigger"
|
|
||||||
SET_INTENSITY = "set_intensity"
|
|
||||||
SET_INTERVAL = "set_interval"
|
|
||||||
SET_COLOR = "set_color"
|
|
||||||
STOP = "stop"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FigmentCommand:
|
|
||||||
action: FigmentAction
|
|
||||||
value: float | str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class FigmentTrigger(Protocol):
|
|
||||||
"""Protocol for figment trigger sources.
|
|
||||||
|
|
||||||
Any input source (ntfy, MQTT, serial) can implement this
|
|
||||||
to trigger and control figment displays.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def poll(self) -> FigmentCommand | None: ...
|
|
||||||
1
engine/fixtures/headlines.json
Normal file
1
engine/fixtures/headlines.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"items": []}
|
||||||
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",
|
||||||
|
]
|
||||||
356
engine/layers.py
356
engine/layers.py
@@ -1,356 +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_trunc,
|
|
||||||
)
|
|
||||||
from engine.render import big_wrap, lr_gradient, msg_gradient
|
|
||||||
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 = msg_gradient(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,
|
|
||||||
ticker_h: int,
|
|
||||||
w: int,
|
|
||||||
noise_cache: dict,
|
|
||||||
grad_offset: float,
|
|
||||||
) -> 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)
|
|
||||||
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)
|
|
||||||
"""
|
|
||||||
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(colored, w)
|
|
||||||
if row_fade < 1.0:
|
|
||||||
ln = fade_line(ln, row_fade)
|
|
||||||
|
|
||||||
if cr == midx:
|
|
||||||
buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
|
|
||||||
elif ln.strip():
|
|
||||||
buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
|
|
||||||
else:
|
|
||||||
buf.append(f"\033[{scr_row};1H\033[K")
|
|
||||||
drawn = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not drawn:
|
|
||||||
n = noise_at(cy)
|
|
||||||
if row_fade < 1.0 and n:
|
|
||||||
n = fade_line(n, row_fade)
|
|
||||||
if n:
|
|
||||||
buf.append(f"\033[{scr_row};1H{n}")
|
|
||||||
else:
|
|
||||||
buf.append(f"\033[{scr_row};1H\033[K")
|
|
||||||
|
|
||||||
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,
|
|
||||||
mic_excess: float,
|
|
||||||
grad_offset: float,
|
|
||||||
frame_number: int,
|
|
||||||
has_message: bool,
|
|
||||||
items: list,
|
|
||||||
) -> 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,
|
|
||||||
ticker_height=ticker_h,
|
|
||||||
mic_excess=mic_excess,
|
|
||||||
grad_offset=grad_offset,
|
|
||||||
frame_number=frame_number,
|
|
||||||
has_message=has_message,
|
|
||||||
items=items,
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def render_figment_overlay(
|
|
||||||
figment_state,
|
|
||||||
w: int,
|
|
||||||
h: int,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Render figment overlay as ANSI cursor-positioning commands.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
figment_state: FigmentState with phase, progress, rows, gradient, centering.
|
|
||||||
w: terminal width
|
|
||||||
h: terminal height
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of ANSI strings to append to display buffer.
|
|
||||||
"""
|
|
||||||
from engine.render import _color_codes_to_ansi
|
|
||||||
|
|
||||||
rows = figment_state.rows
|
|
||||||
if not rows:
|
|
||||||
return []
|
|
||||||
|
|
||||||
phase = figment_state.phase
|
|
||||||
progress = figment_state.progress
|
|
||||||
gradient = figment_state.gradient
|
|
||||||
center_row = figment_state.center_row
|
|
||||||
center_col = figment_state.center_col
|
|
||||||
|
|
||||||
cols = _color_codes_to_ansi(gradient)
|
|
||||||
|
|
||||||
# Build a list of non-space cell positions
|
|
||||||
cell_positions = []
|
|
||||||
for r_idx, row in enumerate(rows):
|
|
||||||
for c_idx, ch in enumerate(row):
|
|
||||||
if ch != " ":
|
|
||||||
cell_positions.append((r_idx, c_idx))
|
|
||||||
|
|
||||||
n_cells = len(cell_positions)
|
|
||||||
if n_cells == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
|
|
||||||
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
|
|
||||||
shuffled = list(cell_positions)
|
|
||||||
rng.shuffle(shuffled)
|
|
||||||
|
|
||||||
# Phase-dependent visibility
|
|
||||||
from effects_plugins.figment import FigmentPhase
|
|
||||||
|
|
||||||
if phase == FigmentPhase.REVEAL:
|
|
||||||
visible_count = int(n_cells * progress)
|
|
||||||
visible = set(shuffled[:visible_count])
|
|
||||||
elif phase == FigmentPhase.HOLD:
|
|
||||||
visible = set(cell_positions)
|
|
||||||
# Strobe: dim some cells periodically
|
|
||||||
if int(progress * 20) % 3 == 0:
|
|
||||||
dim_count = int(n_cells * 0.3)
|
|
||||||
visible -= set(shuffled[:dim_count])
|
|
||||||
elif phase == FigmentPhase.DISSOLVE:
|
|
||||||
remaining_count = int(n_cells * (1.0 - progress))
|
|
||||||
visible = set(shuffled[:remaining_count])
|
|
||||||
else:
|
|
||||||
visible = set(cell_positions)
|
|
||||||
|
|
||||||
# Build overlay commands
|
|
||||||
overlay: list[str] = []
|
|
||||||
n_cols = len(cols)
|
|
||||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
|
||||||
|
|
||||||
for r_idx, row in enumerate(rows):
|
|
||||||
scr_row = center_row + r_idx + 1 # 1-indexed
|
|
||||||
if scr_row < 1 or scr_row > h:
|
|
||||||
continue
|
|
||||||
|
|
||||||
line_buf: list[str] = []
|
|
||||||
has_content = False
|
|
||||||
|
|
||||||
for c_idx, ch in enumerate(row):
|
|
||||||
scr_col = center_col + c_idx + 1
|
|
||||||
if scr_col < 1 or scr_col > w:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if ch != " " and (r_idx, c_idx) in visible:
|
|
||||||
# Apply gradient color
|
|
||||||
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
|
|
||||||
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
|
|
||||||
line_buf.append(f"{cols[idx]}{ch}{RST}")
|
|
||||||
has_content = True
|
|
||||||
else:
|
|
||||||
line_buf.append(" ")
|
|
||||||
|
|
||||||
if has_content:
|
|
||||||
line_str = "".join(line_buf).rstrip()
|
|
||||||
if line_str.strip():
|
|
||||||
overlay.append(f"\033[{scr_row};{center_col + 1}H{line_str}{RST}")
|
|
||||||
|
|
||||||
return overlay
|
|
||||||
@@ -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
|
|
||||||
106
engine/pipeline/__init__.py
Normal file
106
engine/pipeline/__init__.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
Unified Pipeline Architecture.
|
||||||
|
|
||||||
|
This module provides a clean, dependency-managed pipeline system:
|
||||||
|
- Stage: Base class for all pipeline components
|
||||||
|
- Pipeline: DAG-based execution orchestrator
|
||||||
|
- PipelineParams: Runtime configuration for animation
|
||||||
|
- PipelinePreset: Pre-configured pipeline configurations
|
||||||
|
- StageRegistry: Unified registration for all stage types
|
||||||
|
|
||||||
|
The pipeline architecture supports:
|
||||||
|
- Sources: Data providers (headlines, poetry, pipeline viz)
|
||||||
|
- Effects: Post-processors (noise, fade, glitch, hud)
|
||||||
|
- Displays: Output backends (terminal, pygame, websocket)
|
||||||
|
- Cameras: Viewport controllers (vertical, horizontal, omni)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
from engine.pipeline import Pipeline, PipelineConfig, StageRegistry
|
||||||
|
|
||||||
|
pipeline = Pipeline(PipelineConfig(source="headlines", display="terminal"))
|
||||||
|
pipeline.add_stage("source", StageRegistry.create("source", "headlines"))
|
||||||
|
pipeline.add_stage("display", StageRegistry.create("display", "terminal"))
|
||||||
|
pipeline.build().initialize()
|
||||||
|
|
||||||
|
result = pipeline.execute(initial_data)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.pipeline.controller import (
|
||||||
|
Pipeline,
|
||||||
|
PipelineConfig,
|
||||||
|
PipelineRunner,
|
||||||
|
create_default_pipeline,
|
||||||
|
create_pipeline_from_params,
|
||||||
|
)
|
||||||
|
from engine.pipeline.core import (
|
||||||
|
PipelineContext,
|
||||||
|
Stage,
|
||||||
|
StageConfig,
|
||||||
|
StageError,
|
||||||
|
StageResult,
|
||||||
|
)
|
||||||
|
from engine.pipeline.params import (
|
||||||
|
DEFAULT_HEADLINE_PARAMS,
|
||||||
|
DEFAULT_PIPELINE_PARAMS,
|
||||||
|
DEFAULT_PYGAME_PARAMS,
|
||||||
|
PipelineParams,
|
||||||
|
)
|
||||||
|
from engine.pipeline.presets import (
|
||||||
|
DEMO_PRESET,
|
||||||
|
FIREHOSE_PRESET,
|
||||||
|
PIPELINE_VIZ_PRESET,
|
||||||
|
POETRY_PRESET,
|
||||||
|
UI_PRESET,
|
||||||
|
WEBSOCKET_PRESET,
|
||||||
|
PipelinePreset,
|
||||||
|
create_preset_from_params,
|
||||||
|
get_preset,
|
||||||
|
list_presets,
|
||||||
|
)
|
||||||
|
from engine.pipeline.registry import (
|
||||||
|
StageRegistry,
|
||||||
|
discover_stages,
|
||||||
|
register_camera,
|
||||||
|
register_display,
|
||||||
|
register_effect,
|
||||||
|
register_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Core
|
||||||
|
"Stage",
|
||||||
|
"StageConfig",
|
||||||
|
"StageError",
|
||||||
|
"StageResult",
|
||||||
|
"PipelineContext",
|
||||||
|
# Controller
|
||||||
|
"Pipeline",
|
||||||
|
"PipelineConfig",
|
||||||
|
"PipelineRunner",
|
||||||
|
"create_default_pipeline",
|
||||||
|
"create_pipeline_from_params",
|
||||||
|
# Params
|
||||||
|
"PipelineParams",
|
||||||
|
"DEFAULT_HEADLINE_PARAMS",
|
||||||
|
"DEFAULT_PIPELINE_PARAMS",
|
||||||
|
"DEFAULT_PYGAME_PARAMS",
|
||||||
|
# Presets
|
||||||
|
"PipelinePreset",
|
||||||
|
"PRESETS",
|
||||||
|
"DEMO_PRESET",
|
||||||
|
"POETRY_PRESET",
|
||||||
|
"PIPELINE_VIZ_PRESET",
|
||||||
|
"WEBSOCKET_PRESET",
|
||||||
|
"FIREHOSE_PRESET",
|
||||||
|
"UI_PRESET",
|
||||||
|
"get_preset",
|
||||||
|
"list_presets",
|
||||||
|
"create_preset_from_params",
|
||||||
|
# Registry
|
||||||
|
"StageRegistry",
|
||||||
|
"discover_stages",
|
||||||
|
"register_source",
|
||||||
|
"register_effect",
|
||||||
|
"register_display",
|
||||||
|
"register_camera",
|
||||||
|
]
|
||||||
50
engine/pipeline/adapters.py
Normal file
50
engine/pipeline/adapters.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
Stage adapters - Bridge existing components to the Stage interface.
|
||||||
|
|
||||||
|
This module provides adapters that wrap existing components
|
||||||
|
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||||
|
|
||||||
|
DEPRECATED: This file is now a compatibility wrapper.
|
||||||
|
Use `engine.pipeline.adapters` package instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Re-export from the new package structure for backward compatibility
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
|
# Adapter classes
|
||||||
|
CameraStage,
|
||||||
|
CanvasStage,
|
||||||
|
DataSourceStage,
|
||||||
|
DisplayStage,
|
||||||
|
EffectPluginStage,
|
||||||
|
FontStage,
|
||||||
|
ImageToTextStage,
|
||||||
|
PassthroughStage,
|
||||||
|
SourceItemsToBufferStage,
|
||||||
|
ViewportFilterStage,
|
||||||
|
# Factory functions
|
||||||
|
create_stage_from_camera,
|
||||||
|
create_stage_from_display,
|
||||||
|
create_stage_from_effect,
|
||||||
|
create_stage_from_font,
|
||||||
|
create_stage_from_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Adapter classes
|
||||||
|
"EffectPluginStage",
|
||||||
|
"DisplayStage",
|
||||||
|
"DataSourceStage",
|
||||||
|
"PassthroughStage",
|
||||||
|
"SourceItemsToBufferStage",
|
||||||
|
"CameraStage",
|
||||||
|
"ViewportFilterStage",
|
||||||
|
"FontStage",
|
||||||
|
"ImageToTextStage",
|
||||||
|
"CanvasStage",
|
||||||
|
# Factory functions
|
||||||
|
"create_stage_from_display",
|
||||||
|
"create_stage_from_effect",
|
||||||
|
"create_stage_from_source",
|
||||||
|
"create_stage_from_camera",
|
||||||
|
"create_stage_from_font",
|
||||||
|
]
|
||||||
44
engine/pipeline/adapters/__init__.py
Normal file
44
engine/pipeline/adapters/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Stage adapters - Bridge existing components to the Stage interface.
|
||||||
|
|
||||||
|
This module provides adapters that wrap existing components
|
||||||
|
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .camera import CameraClockStage, CameraStage
|
||||||
|
from .data_source import DataSourceStage, PassthroughStage, SourceItemsToBufferStage
|
||||||
|
from .display import DisplayStage
|
||||||
|
from .effect_plugin import EffectPluginStage
|
||||||
|
from .factory import (
|
||||||
|
create_stage_from_camera,
|
||||||
|
create_stage_from_display,
|
||||||
|
create_stage_from_effect,
|
||||||
|
create_stage_from_font,
|
||||||
|
create_stage_from_source,
|
||||||
|
)
|
||||||
|
from .transform import (
|
||||||
|
CanvasStage,
|
||||||
|
FontStage,
|
||||||
|
ImageToTextStage,
|
||||||
|
ViewportFilterStage,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Adapter classes
|
||||||
|
"EffectPluginStage",
|
||||||
|
"DisplayStage",
|
||||||
|
"DataSourceStage",
|
||||||
|
"PassthroughStage",
|
||||||
|
"SourceItemsToBufferStage",
|
||||||
|
"CameraStage",
|
||||||
|
"CameraClockStage",
|
||||||
|
"ViewportFilterStage",
|
||||||
|
"FontStage",
|
||||||
|
"ImageToTextStage",
|
||||||
|
"CanvasStage",
|
||||||
|
# Factory functions
|
||||||
|
"create_stage_from_display",
|
||||||
|
"create_stage_from_effect",
|
||||||
|
"create_stage_from_source",
|
||||||
|
"create_stage_from_camera",
|
||||||
|
"create_stage_from_font",
|
||||||
|
]
|
||||||
219
engine/pipeline/adapters/camera.py
Normal file
219
engine/pipeline/adapters/camera.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""Adapter for camera stage."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class CameraClockStage(Stage):
|
||||||
|
"""Per-frame clock stage that updates camera state.
|
||||||
|
|
||||||
|
This stage runs once per frame and updates the camera's internal state
|
||||||
|
(position, time). It makes camera_y/camera_x available to subsequent
|
||||||
|
stages via the pipeline context.
|
||||||
|
|
||||||
|
Unlike other stages, this is a pure clock stage and doesn't process
|
||||||
|
data - it just updates camera state and passes data through unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, camera, name: str = "camera-clock"):
|
||||||
|
self._camera = camera
|
||||||
|
self.name = name
|
||||||
|
self.category = "camera"
|
||||||
|
self.optional = False
|
||||||
|
self._last_frame_time: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "camera"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
# Provides camera state info only
|
||||||
|
# NOTE: Do NOT provide "source" as it conflicts with viewport_filter's "source.filtered"
|
||||||
|
return {"camera.state"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
# Clock stage - no dependencies (updates every frame regardless of data flow)
|
||||||
|
return set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
# Accept any data type - this is a pass-through stage
|
||||||
|
return {DataType.ANY}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
# Pass through whatever was received
|
||||||
|
return {DataType.ANY}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Update camera state and pass data through.
|
||||||
|
|
||||||
|
This stage updates the camera's internal state (position, time) and
|
||||||
|
makes the updated camera_y/camera_x available to subsequent stages
|
||||||
|
via the pipeline context.
|
||||||
|
|
||||||
|
The data is passed through unchanged - this stage only updates
|
||||||
|
camera state, it doesn't transform the data.
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Update camera speed from params if explicitly set (for dynamic modulation)
|
||||||
|
# Only update if camera_speed in params differs from the default (1.0)
|
||||||
|
# This preserves camera speed set during construction
|
||||||
|
if (
|
||||||
|
ctx.params
|
||||||
|
and hasattr(ctx.params, "camera_speed")
|
||||||
|
and ctx.params.camera_speed != 1.0
|
||||||
|
):
|
||||||
|
self._camera.set_speed(ctx.params.camera_speed)
|
||||||
|
|
||||||
|
current_time = time.perf_counter()
|
||||||
|
dt = 0.0
|
||||||
|
if self._last_frame_time is not None:
|
||||||
|
dt = current_time - self._last_frame_time
|
||||||
|
self._camera.update(dt)
|
||||||
|
self._last_frame_time = current_time
|
||||||
|
|
||||||
|
# Update context with current camera position
|
||||||
|
ctx.set_state("camera_y", self._camera.y)
|
||||||
|
ctx.set_state("camera_x", self._camera.x)
|
||||||
|
|
||||||
|
# Pass data through unchanged
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class CameraStage(Stage):
|
||||||
|
"""Adapter wrapping Camera as a Stage.
|
||||||
|
|
||||||
|
This stage applies camera viewport transformation to the rendered buffer.
|
||||||
|
Camera state updates are handled by CameraClockStage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, camera, name: str = "vertical"):
|
||||||
|
self._camera = camera
|
||||||
|
self.name = name
|
||||||
|
self.category = "camera"
|
||||||
|
self.optional = True
|
||||||
|
self._last_frame_time: float | None = None
|
||||||
|
|
||||||
|
def save_state(self) -> dict[str, Any]:
|
||||||
|
"""Save camera state for restoration after pipeline rebuild.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing camera state that can be restored
|
||||||
|
"""
|
||||||
|
state = {
|
||||||
|
"x": self._camera.x,
|
||||||
|
"y": self._camera.y,
|
||||||
|
"mode": self._camera.mode.value
|
||||||
|
if hasattr(self._camera.mode, "value")
|
||||||
|
else self._camera.mode,
|
||||||
|
"speed": self._camera.speed,
|
||||||
|
"zoom": self._camera.zoom,
|
||||||
|
"canvas_width": self._camera.canvas_width,
|
||||||
|
"canvas_height": self._camera.canvas_height,
|
||||||
|
"_x_float": getattr(self._camera, "_x_float", 0.0),
|
||||||
|
"_y_float": getattr(self._camera, "_y_float", 0.0),
|
||||||
|
"_time": getattr(self._camera, "_time", 0.0),
|
||||||
|
}
|
||||||
|
# Save radial camera state if present
|
||||||
|
if hasattr(self._camera, "_r_float"):
|
||||||
|
state["_r_float"] = self._camera._r_float
|
||||||
|
if hasattr(self._camera, "_theta_float"):
|
||||||
|
state["_theta_float"] = self._camera._theta_float
|
||||||
|
if hasattr(self._camera, "_radial_input"):
|
||||||
|
state["_radial_input"] = self._camera._radial_input
|
||||||
|
return state
|
||||||
|
|
||||||
|
def restore_state(self, state: dict[str, Any]) -> None:
|
||||||
|
"""Restore camera state from saved state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dictionary containing camera state from save_state()
|
||||||
|
"""
|
||||||
|
from engine.camera import CameraMode
|
||||||
|
|
||||||
|
self._camera.x = state.get("x", 0)
|
||||||
|
self._camera.y = state.get("y", 0)
|
||||||
|
|
||||||
|
# Restore mode - handle both enum value and direct enum
|
||||||
|
mode_value = state.get("mode", 0)
|
||||||
|
if isinstance(mode_value, int):
|
||||||
|
self._camera.mode = CameraMode(mode_value)
|
||||||
|
else:
|
||||||
|
self._camera.mode = mode_value
|
||||||
|
|
||||||
|
self._camera.speed = state.get("speed", 1.0)
|
||||||
|
self._camera.zoom = state.get("zoom", 1.0)
|
||||||
|
self._camera.canvas_width = state.get("canvas_width", 200)
|
||||||
|
self._camera.canvas_height = state.get("canvas_height", 200)
|
||||||
|
|
||||||
|
# Restore internal state
|
||||||
|
if hasattr(self._camera, "_x_float"):
|
||||||
|
self._camera._x_float = state.get("_x_float", 0.0)
|
||||||
|
if hasattr(self._camera, "_y_float"):
|
||||||
|
self._camera._y_float = state.get("_y_float", 0.0)
|
||||||
|
if hasattr(self._camera, "_time"):
|
||||||
|
self._camera._time = state.get("_time", 0.0)
|
||||||
|
|
||||||
|
# Restore radial camera state if present
|
||||||
|
if hasattr(self._camera, "_r_float"):
|
||||||
|
self._camera._r_float = state.get("_r_float", 0.0)
|
||||||
|
if hasattr(self._camera, "_theta_float"):
|
||||||
|
self._camera._theta_float = state.get("_theta_float", 0.0)
|
||||||
|
if hasattr(self._camera, "_radial_input"):
|
||||||
|
self._camera._radial_input = state.get("_radial_input", 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "camera"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"camera"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return {"render.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Apply camera transformation to items."""
|
||||||
|
if data is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Camera state is updated by CameraClockStage
|
||||||
|
# We only apply the viewport transformation here
|
||||||
|
|
||||||
|
if hasattr(self._camera, "apply"):
|
||||||
|
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||||
|
|
||||||
|
# Use filtered camera position if available (from ViewportFilterStage)
|
||||||
|
# This handles the case where the buffer has been filtered and starts at row 0
|
||||||
|
filtered_camera_y = ctx.get("camera_y", self._camera.y)
|
||||||
|
|
||||||
|
# Temporarily adjust camera position for filtering
|
||||||
|
original_y = self._camera.y
|
||||||
|
self._camera.y = filtered_camera_y
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._camera.apply(data, viewport_width, viewport_height)
|
||||||
|
finally:
|
||||||
|
# Restore original camera position
|
||||||
|
self._camera.y = original_y
|
||||||
|
|
||||||
|
return result
|
||||||
|
return data
|
||||||
143
engine/pipeline/adapters/data_source.py
Normal file
143
engine/pipeline/adapters/data_source.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Stage adapters - Bridge existing components to the Stage interface.
|
||||||
|
|
||||||
|
This module provides adapters that wrap existing components
|
||||||
|
(DataSource) as Stage implementations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.data_sources import SourceItem
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class DataSourceStage(Stage):
|
||||||
|
"""Adapter wrapping DataSource as a Stage."""
|
||||||
|
|
||||||
|
def __init__(self, data_source, name: str = "headlines"):
|
||||||
|
self._source = data_source
|
||||||
|
self.name = name
|
||||||
|
self.category = "source"
|
||||||
|
self.optional = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {f"source.{self.name}"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.NONE} # Sources don't take input
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Fetch data from source."""
|
||||||
|
if hasattr(self._source, "get_items"):
|
||||||
|
return self._source.get_items()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class PassthroughStage(Stage):
|
||||||
|
"""Simple stage that passes data through unchanged.
|
||||||
|
|
||||||
|
Used for sources that already provide the data in the correct format
|
||||||
|
(e.g., pipeline introspection that outputs text directly).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "passthrough"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "render"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"render.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return {"source"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Pass data through unchanged."""
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class SourceItemsToBufferStage(Stage):
|
||||||
|
"""Convert SourceItem objects to text buffer.
|
||||||
|
|
||||||
|
Takes a list of SourceItem objects and extracts their content,
|
||||||
|
splitting on newlines to create a proper text buffer for display.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "items-to-buffer"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "render"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"render.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return {"source"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
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
|
||||||
|
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)]
|
||||||
93
engine/pipeline/adapters/display.py
Normal file
93
engine/pipeline/adapters/display.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Adapter wrapping Display as a Stage."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.pipeline.core import PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayStage(Stage):
|
||||||
|
"""Adapter wrapping Display as a Stage."""
|
||||||
|
|
||||||
|
def __init__(self, display, name: str = "terminal"):
|
||||||
|
self._display = display
|
||||||
|
self.name = name
|
||||||
|
self.category = "display"
|
||||||
|
self.optional = False
|
||||||
|
self._initialized = False
|
||||||
|
self._init_width = 80
|
||||||
|
self._init_height = 24
|
||||||
|
|
||||||
|
def save_state(self) -> dict[str, Any]:
|
||||||
|
"""Save display state for restoration after pipeline rebuild.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing display state that can be restored
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"initialized": self._initialized,
|
||||||
|
"init_width": self._init_width,
|
||||||
|
"init_height": self._init_height,
|
||||||
|
"width": getattr(self._display, "width", 80),
|
||||||
|
"height": getattr(self._display, "height", 24),
|
||||||
|
}
|
||||||
|
|
||||||
|
def restore_state(self, state: dict[str, Any]) -> None:
|
||||||
|
"""Restore display state from saved state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dictionary containing display state from save_state()
|
||||||
|
"""
|
||||||
|
self._initialized = state.get("initialized", False)
|
||||||
|
self._init_width = state.get("init_width", 80)
|
||||||
|
self._init_height = state.get("init_height", 24)
|
||||||
|
|
||||||
|
# Restore display dimensions if the display supports it
|
||||||
|
if hasattr(self._display, "width"):
|
||||||
|
self._display.width = state.get("width", 80)
|
||||||
|
if hasattr(self._display, "height"):
|
||||||
|
self._display.height = state.get("height", 24)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"display.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return {"render.output"} # Display needs rendered content
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER} # Display consumes rendered text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.NONE} # Display is a terminal stage (no output)
|
||||||
|
|
||||||
|
def init(self, ctx: PipelineContext) -> bool:
|
||||||
|
w = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
h = ctx.params.viewport_height if ctx.params else 24
|
||||||
|
|
||||||
|
# Try to reuse display if already initialized
|
||||||
|
reuse = self._initialized
|
||||||
|
result = self._display.init(w, h, reuse=reuse)
|
||||||
|
|
||||||
|
# Update initialization state
|
||||||
|
if result is not False:
|
||||||
|
self._initialized = True
|
||||||
|
self._init_width = w
|
||||||
|
self._init_height = h
|
||||||
|
|
||||||
|
return result is not False
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Output data to display."""
|
||||||
|
if data is not None:
|
||||||
|
self._display.show(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
self._display.cleanup()
|
||||||
117
engine/pipeline/adapters/effect_plugin.py
Normal file
117
engine/pipeline/adapters/effect_plugin.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Adapter wrapping EffectPlugin as a Stage."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.pipeline.core import PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPluginStage(Stage):
|
||||||
|
"""Adapter wrapping EffectPlugin as a Stage.
|
||||||
|
|
||||||
|
Supports capability-based dependencies through the dependencies parameter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
effect_plugin,
|
||||||
|
name: str = "effect",
|
||||||
|
dependencies: set[str] | None = None,
|
||||||
|
):
|
||||||
|
self._effect = effect_plugin
|
||||||
|
self.name = name
|
||||||
|
self.category = "effect"
|
||||||
|
self.optional = False
|
||||||
|
self._dependencies = dependencies or set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
"""Return stage_type based on effect name.
|
||||||
|
|
||||||
|
HUD effects are overlays.
|
||||||
|
"""
|
||||||
|
if self.name == "hud":
|
||||||
|
return "overlay"
|
||||||
|
return self.category
|
||||||
|
|
||||||
|
@property
|
||||||
|
def render_order(self) -> int:
|
||||||
|
"""Return render_order based on effect type.
|
||||||
|
|
||||||
|
HUD effects have high render_order to appear on top.
|
||||||
|
"""
|
||||||
|
if self.name == "hud":
|
||||||
|
return 100 # High order for overlays
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_overlay(self) -> bool:
|
||||||
|
"""Return True for HUD effects.
|
||||||
|
|
||||||
|
HUD is an overlay - it composes on top of the buffer
|
||||||
|
rather than transforming it for the next stage.
|
||||||
|
"""
|
||||||
|
return self.name == "hud"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {f"effect.{self.name}"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return self._dependencies
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Process data through the effect."""
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
from engine.effects.types import EffectContext, apply_param_bindings
|
||||||
|
|
||||||
|
w = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
h = ctx.params.viewport_height if ctx.params else 24
|
||||||
|
frame = ctx.params.frame_number if ctx.params else 0
|
||||||
|
|
||||||
|
effect_ctx = EffectContext(
|
||||||
|
terminal_width=w,
|
||||||
|
terminal_height=h,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=h,
|
||||||
|
camera_x=0,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=(frame * 0.01) % 1.0,
|
||||||
|
frame_number=frame,
|
||||||
|
has_message=False,
|
||||||
|
items=ctx.get("items", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy sensor state from PipelineContext to EffectContext
|
||||||
|
for key, value in ctx.state.items():
|
||||||
|
if key.startswith("sensor."):
|
||||||
|
effect_ctx.set_state(key, value)
|
||||||
|
|
||||||
|
# Copy metrics from PipelineContext to EffectContext
|
||||||
|
if "metrics" in ctx.state:
|
||||||
|
effect_ctx.set_state("metrics", ctx.state["metrics"])
|
||||||
|
|
||||||
|
# Copy pipeline_order from PipelineContext services to EffectContext state
|
||||||
|
pipeline_order = ctx.get("pipeline_order")
|
||||||
|
if pipeline_order:
|
||||||
|
effect_ctx.set_state("pipeline_order", pipeline_order)
|
||||||
|
|
||||||
|
# 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)
|
||||||
38
engine/pipeline/adapters/factory.py
Normal file
38
engine/pipeline/adapters/factory.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Factory functions for creating stage instances."""
|
||||||
|
|
||||||
|
from engine.pipeline.adapters.camera import CameraStage
|
||||||
|
from engine.pipeline.adapters.data_source import DataSourceStage
|
||||||
|
from engine.pipeline.adapters.display import DisplayStage
|
||||||
|
from engine.pipeline.adapters.effect_plugin import EffectPluginStage
|
||||||
|
from engine.pipeline.adapters.transform import FontStage
|
||||||
|
|
||||||
|
|
||||||
|
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
|
||||||
|
"""Create a DisplayStage from a display instance."""
|
||||||
|
return DisplayStage(display, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_stage_from_effect(effect_plugin, name: str) -> EffectPluginStage:
|
||||||
|
"""Create an EffectPluginStage from an effect plugin."""
|
||||||
|
return EffectPluginStage(effect_plugin, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_stage_from_source(data_source, name: str = "headlines") -> DataSourceStage:
|
||||||
|
"""Create a DataSourceStage from a data source."""
|
||||||
|
return DataSourceStage(data_source, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
|
||||||
|
"""Create a CameraStage from a camera instance."""
|
||||||
|
return CameraStage(camera, name=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 with specified font configuration."""
|
||||||
|
# FontStage currently doesn't use these parameters but keeps them for compatibility
|
||||||
|
return FontStage(name=name)
|
||||||
293
engine/pipeline/adapters/transform.py
Normal file
293
engine/pipeline/adapters/transform.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""Adapters for transform stages (viewport, font, image, canvas)."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import engine.render
|
||||||
|
from engine.data_sources import SourceItem
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_simple_height(text: str, width: int) -> int:
|
||||||
|
"""Estimate height in terminal rows using simple word wrap.
|
||||||
|
|
||||||
|
Uses conservative estimation suitable for headlines.
|
||||||
|
Each wrapped line is approximately 6 terminal rows (big block rendering).
|
||||||
|
"""
|
||||||
|
words = text.split()
|
||||||
|
if not words:
|
||||||
|
return 6
|
||||||
|
|
||||||
|
lines = 1
|
||||||
|
current_len = 0
|
||||||
|
for word in words:
|
||||||
|
word_len = len(word)
|
||||||
|
if current_len + word_len + 1 > width - 4: # -4 for margins
|
||||||
|
lines += 1
|
||||||
|
current_len = word_len
|
||||||
|
else:
|
||||||
|
current_len += word_len + 1
|
||||||
|
|
||||||
|
return lines * 6 # 6 rows per line for big block rendering
|
||||||
|
|
||||||
|
|
||||||
|
class ViewportFilterStage(Stage):
|
||||||
|
"""Filter items to viewport height based on rendered height."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "viewport-filter"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = True
|
||||||
|
self._layout: list[int] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "render"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"source.filtered"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
# Always requires camera.state for viewport filtering
|
||||||
|
# CameraUpdateStage provides this (auto-injected if missing)
|
||||||
|
return {"source", "camera.state"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Filter items to viewport height based on rendered height."""
|
||||||
|
if data is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return data
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get viewport parameters from context
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Estimate height for each item and cache layout
|
||||||
|
self._layout = []
|
||||||
|
cumulative_heights = []
|
||||||
|
current_height = 0
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
title = item.content if isinstance(item, SourceItem) else str(item)
|
||||||
|
# Use simple height estimation (not PIL-based)
|
||||||
|
estimated_height = estimate_simple_height(title, viewport_width)
|
||||||
|
self._layout.append(estimated_height)
|
||||||
|
current_height += estimated_height
|
||||||
|
cumulative_heights.append(current_height)
|
||||||
|
|
||||||
|
# Find visible range based on camera_y and viewport_height
|
||||||
|
# camera_y is the scroll offset (how many rows are scrolled up)
|
||||||
|
start_y = camera_y
|
||||||
|
end_y = camera_y + viewport_height
|
||||||
|
|
||||||
|
# Find start index (first item that intersects with visible range)
|
||||||
|
start_idx = 0
|
||||||
|
start_item_y = 0 # Y position where the first visible item starts
|
||||||
|
for i, total_h in enumerate(cumulative_heights):
|
||||||
|
if total_h > start_y:
|
||||||
|
start_idx = i
|
||||||
|
# Calculate the Y position of the start of this item
|
||||||
|
if i > 0:
|
||||||
|
start_item_y = cumulative_heights[i - 1]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Find end index (first item that extends beyond visible range)
|
||||||
|
end_idx = len(data)
|
||||||
|
for i, total_h in enumerate(cumulative_heights):
|
||||||
|
if total_h >= end_y:
|
||||||
|
end_idx = i + 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# Adjust camera_y for the filtered buffer
|
||||||
|
# The filtered buffer starts at row 0, but the camera position
|
||||||
|
# needs to be relative to where the first visible item starts
|
||||||
|
filtered_camera_y = camera_y - start_item_y
|
||||||
|
|
||||||
|
# Update context with the filtered camera position
|
||||||
|
# This ensures CameraStage can correctly slice the filtered buffer
|
||||||
|
ctx.set_state("camera_y", filtered_camera_y)
|
||||||
|
ctx.set_state("camera_x", ctx.get("camera_x", 0)) # Keep camera_x unchanged
|
||||||
|
|
||||||
|
# Return visible items
|
||||||
|
return data[start_idx:end_idx]
|
||||||
|
|
||||||
|
|
||||||
|
class FontStage(Stage):
|
||||||
|
"""Render items using font."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "font"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "render"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"render.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_dependencies(self) -> set[str]:
|
||||||
|
# Must connect to viewport_filter stage to get filtered source
|
||||||
|
return {"viewport_filter"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
# Depend on source.filtered (provided by viewport_filter)
|
||||||
|
# This ensures we get the filtered/processed source, not raw source
|
||||||
|
return {"source.filtered"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Render items to text buffer using font."""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [str(data)]
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
if os.environ.get("DEBUG_CAMERA"):
|
||||||
|
print(f"FontStage: input items={len(data)}")
|
||||||
|
|
||||||
|
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, SourceItem):
|
||||||
|
title = item.content
|
||||||
|
src = item.source
|
||||||
|
ts = item.timestamp
|
||||||
|
content_lines, _, _ = engine.render.make_block(
|
||||||
|
title, src, ts, viewport_width
|
||||||
|
)
|
||||||
|
result.extend(content_lines)
|
||||||
|
elif hasattr(item, "content"):
|
||||||
|
title = str(item.content)
|
||||||
|
content_lines, _, _ = engine.render.make_block(
|
||||||
|
title, "", "", viewport_width
|
||||||
|
)
|
||||||
|
result.extend(content_lines)
|
||||||
|
else:
|
||||||
|
result.append(str(item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class ImageToTextStage(Stage):
|
||||||
|
"""Convert image items to text."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "image-to-text"):
|
||||||
|
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:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Convert image items to text representation."""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [str(data)]
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for item in data:
|
||||||
|
# Check if item is an image
|
||||||
|
if hasattr(item, "image_path") or hasattr(item, "image_data"):
|
||||||
|
# Placeholder: would normally render image to ASCII art
|
||||||
|
result.append(f"[Image: {getattr(item, 'image_path', 'data')}]")
|
||||||
|
elif isinstance(item, SourceItem):
|
||||||
|
result.extend(item.content.split("\n"))
|
||||||
|
else:
|
||||||
|
result.append(str(item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CanvasStage(Stage):
|
||||||
|
"""Render items to canvas."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "canvas"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = False
|
||||||
|
|
||||||
|
@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:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Render items to canvas."""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [str(data)]
|
||||||
|
|
||||||
|
# Simple canvas rendering
|
||||||
|
result = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, SourceItem):
|
||||||
|
result.extend(item.content.split("\n"))
|
||||||
|
else:
|
||||||
|
result.append(str(item))
|
||||||
|
return result
|
||||||
1055
engine/pipeline/controller.py
Normal file
1055
engine/pipeline/controller.py
Normal file
File diff suppressed because it is too large
Load Diff
321
engine/pipeline/core.py
Normal file
321
engine/pipeline/core.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""
|
||||||
|
Pipeline core - Unified Stage abstraction and PipelineContext.
|
||||||
|
|
||||||
|
This module provides the foundation for a clean, dependency-managed pipeline:
|
||||||
|
- Stage: Base class for all pipeline components (sources, effects, displays, cameras)
|
||||||
|
- PipelineContext: Dependency injection context for runtime data exchange
|
||||||
|
- Capability system: Explicit capability declarations with duck-typing support
|
||||||
|
- DataType: PureData-style inlet/outlet typing for validation
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from engine.pipeline.params import PipelineParams
|
||||||
|
|
||||||
|
|
||||||
|
class DataType(Enum):
|
||||||
|
"""PureData-style data types for inlet/outlet validation.
|
||||||
|
|
||||||
|
Each type represents a specific data format that flows through the pipeline.
|
||||||
|
This enables compile-time-like validation of connections.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
SOURCE_ITEMS: List[SourceItem] - raw items from sources
|
||||||
|
ITEM_TUPLES: List[tuple] - (title, source, timestamp) tuples
|
||||||
|
TEXT_BUFFER: List[str] - rendered ANSI buffer for display
|
||||||
|
RAW_TEXT: str - raw text strings
|
||||||
|
PIL_IMAGE: PIL Image object
|
||||||
|
"""
|
||||||
|
|
||||||
|
SOURCE_ITEMS = auto() # List[SourceItem] - from DataSource
|
||||||
|
ITEM_TUPLES = auto() # List[tuple] - (title, source, ts)
|
||||||
|
TEXT_BUFFER = auto() # List[str] - ANSI buffer
|
||||||
|
RAW_TEXT = auto() # str - raw text
|
||||||
|
PIL_IMAGE = auto() # PIL Image object
|
||||||
|
ANY = auto() # Accepts any type
|
||||||
|
NONE = auto() # No data (terminator)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StageConfig:
|
||||||
|
"""Configuration for a single stage."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
enabled: bool = True
|
||||||
|
optional: bool = False
|
||||||
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class Stage(ABC):
|
||||||
|
"""Abstract base class for all pipeline stages.
|
||||||
|
|
||||||
|
A Stage is a single component in the rendering pipeline. Stages can be:
|
||||||
|
- Sources: Data providers (headlines, poetry, pipeline viz)
|
||||||
|
- Effects: Post-processors (noise, fade, glitch, hud)
|
||||||
|
- Displays: Output backends (terminal, pygame, websocket)
|
||||||
|
- Cameras: Viewport controllers (vertical, horizontal, omni)
|
||||||
|
- Overlays: UI elements that compose on top (HUD)
|
||||||
|
|
||||||
|
Stages declare:
|
||||||
|
- capabilities: What they provide to other stages
|
||||||
|
- dependencies: What they need from other stages
|
||||||
|
- stage_type: Category of stage (source, effect, overlay, display)
|
||||||
|
- render_order: Execution order within category
|
||||||
|
- is_overlay: If True, output is composited on top, not passed downstream
|
||||||
|
|
||||||
|
Duck-typing is supported: any class with the required methods can act as a Stage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
category: str # "source", "effect", "overlay", "display", "camera"
|
||||||
|
optional: bool = False # If True, pipeline continues even if stage fails
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
"""Category of stage for ordering.
|
||||||
|
|
||||||
|
Valid values: "source", "effect", "overlay", "display", "camera"
|
||||||
|
Defaults to category for backwards compatibility.
|
||||||
|
"""
|
||||||
|
return self.category
|
||||||
|
|
||||||
|
@property
|
||||||
|
def render_order(self) -> int:
|
||||||
|
"""Execution order within stage_type group.
|
||||||
|
|
||||||
|
Higher values execute later. Useful for ordering overlays
|
||||||
|
or effects that need specific execution order.
|
||||||
|
"""
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_overlay(self) -> bool:
|
||||||
|
"""If True, this stage's output is composited on top of the buffer.
|
||||||
|
|
||||||
|
Overlay stages don't pass their output to the next stage.
|
||||||
|
Instead, their output is layered on top of the final buffer.
|
||||||
|
Use this for HUD, status displays, and similar UI elements.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set[DataType]:
|
||||||
|
"""Return set of data types this stage accepts.
|
||||||
|
|
||||||
|
PureData-style inlet typing. If the connected upstream stage's
|
||||||
|
outlet_type is not in this set, the pipeline will raise an error.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Source stages: {DataType.NONE} (no input needed)
|
||||||
|
- Transform stages: {DataType.ITEM_TUPLES, DataType.TEXT_BUFFER}
|
||||||
|
- Display stages: {DataType.TEXT_BUFFER}
|
||||||
|
"""
|
||||||
|
return {DataType.ANY}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set[DataType]:
|
||||||
|
"""Return set of data types this stage produces.
|
||||||
|
|
||||||
|
PureData-style outlet typing. Downstream stages must accept
|
||||||
|
this type in their inlet_types.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Source stages: {DataType.SOURCE_ITEMS}
|
||||||
|
- Transform stages: {DataType.TEXT_BUFFER}
|
||||||
|
- Display stages: {DataType.NONE} (consumes data)
|
||||||
|
"""
|
||||||
|
return {DataType.ANY}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
"""Return set of capabilities this stage provides.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "source.headlines"
|
||||||
|
- "effect.noise"
|
||||||
|
- "display.output"
|
||||||
|
- "camera"
|
||||||
|
"""
|
||||||
|
return {f"{self.category}.{self.name}"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
"""Return set of capability names this stage needs.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- {"display.output"}
|
||||||
|
- {"source.headlines"}
|
||||||
|
- {"camera"}
|
||||||
|
"""
|
||||||
|
return set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_dependencies(self) -> set[str]:
|
||||||
|
"""Return set of stage names this stage must connect to directly.
|
||||||
|
|
||||||
|
This allows explicit stage-to-stage dependencies, useful for enforcing
|
||||||
|
pipeline structure when capability matching alone is insufficient.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- {"viewport_filter"} # Must connect to viewport_filter stage
|
||||||
|
- {"camera_update"} # Must connect to camera_update stage
|
||||||
|
|
||||||
|
NOTE: These are stage names (as added to pipeline), not capabilities.
|
||||||
|
"""
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def init(self, ctx: "PipelineContext") -> bool:
|
||||||
|
"""Initialize stage with pipeline context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: PipelineContext for accessing services
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if initialization succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process(self, data: Any, ctx: "PipelineContext") -> Any:
|
||||||
|
"""Process input data and return output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Input data from previous stage (or initial data for first stage)
|
||||||
|
ctx: PipelineContext for accessing services and state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Processed data for next stage
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def cleanup(self) -> None: # noqa: B027
|
||||||
|
"""Clean up resources when pipeline shuts down."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_config(self) -> StageConfig:
|
||||||
|
"""Return current configuration of this stage."""
|
||||||
|
return StageConfig(
|
||||||
|
name=self.name,
|
||||||
|
category=self.category,
|
||||||
|
optional=self.optional,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_enabled(self, enabled: bool) -> None:
|
||||||
|
"""Enable or disable this stage."""
|
||||||
|
self._enabled = enabled # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Check if stage is enabled."""
|
||||||
|
return getattr(self, "_enabled", True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StageResult:
|
||||||
|
"""Result of stage processing, including success/failure info."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
data: Any
|
||||||
|
error: str | None = None
|
||||||
|
stage_name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineContext:
|
||||||
|
"""Dependency injection context passed through the pipeline.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- services: Named services (display, config, event_bus, etc.)
|
||||||
|
- state: Runtime state shared between stages
|
||||||
|
- params: PipelineParams for animation-driven config
|
||||||
|
|
||||||
|
Services can be injected at construction time or lazily resolved.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
services: dict[str, Any] | None = None,
|
||||||
|
initial_state: dict[str, Any] | None = None,
|
||||||
|
):
|
||||||
|
self.services: dict[str, Any] = services or {}
|
||||||
|
self.state: dict[str, Any] = initial_state or {}
|
||||||
|
self._params: PipelineParams | None = None
|
||||||
|
|
||||||
|
# Lazy resolvers for common services
|
||||||
|
self._lazy_resolvers: dict[str, Callable[[], Any]] = {
|
||||||
|
"config": self._resolve_config,
|
||||||
|
"event_bus": self._resolve_event_bus,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_config(self) -> Any:
|
||||||
|
from engine.config import get_config
|
||||||
|
|
||||||
|
return get_config()
|
||||||
|
|
||||||
|
def _resolve_event_bus(self) -> Any:
|
||||||
|
from engine.eventbus import get_event_bus
|
||||||
|
|
||||||
|
return get_event_bus()
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a service or state value by key.
|
||||||
|
|
||||||
|
First checks services, then state, then lazy resolution.
|
||||||
|
"""
|
||||||
|
if key in self.services:
|
||||||
|
return self.services[key]
|
||||||
|
if key in self.state:
|
||||||
|
return self.state[key]
|
||||||
|
if key in self._lazy_resolvers:
|
||||||
|
try:
|
||||||
|
return self._lazy_resolvers[key]()
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
return default
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any) -> None:
|
||||||
|
"""Set a service or state value."""
|
||||||
|
self.services[key] = value
|
||||||
|
|
||||||
|
def set_state(self, key: str, value: Any) -> None:
|
||||||
|
"""Set a runtime state value."""
|
||||||
|
self.state[key] = value
|
||||||
|
|
||||||
|
def get_state(self, key: str, default: Any = None) -> Any:
|
||||||
|
"""Get a runtime state value."""
|
||||||
|
return self.state.get(key, default)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def params(self) -> "PipelineParams | None":
|
||||||
|
"""Get current pipeline params (for animation)."""
|
||||||
|
return self._params
|
||||||
|
|
||||||
|
@params.setter
|
||||||
|
def params(self, value: "PipelineParams") -> None:
|
||||||
|
"""Set pipeline params (from animation controller)."""
|
||||||
|
self._params = value
|
||||||
|
|
||||||
|
def has_capability(self, capability: str) -> bool:
|
||||||
|
"""Check if a capability is available."""
|
||||||
|
return capability in self.services or capability in self._lazy_resolvers
|
||||||
|
|
||||||
|
|
||||||
|
class StageError(Exception):
|
||||||
|
"""Raised when a stage fails to process."""
|
||||||
|
|
||||||
|
def __init__(self, stage_name: str, message: str, is_optional: bool = False):
|
||||||
|
self.stage_name = stage_name
|
||||||
|
self.message = message
|
||||||
|
self.is_optional = is_optional
|
||||||
|
super().__init__(f"Stage '{stage_name}' failed: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_stage_error(
|
||||||
|
stage_name: str, error: Exception, is_optional: bool = False
|
||||||
|
) -> StageError:
|
||||||
|
"""Helper to create a StageError from an exception."""
|
||||||
|
return StageError(stage_name, str(error), is_optional)
|
||||||
150
engine/pipeline/params.py
Normal file
150
engine/pipeline/params.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Pipeline parameters - Runtime configuration layer for animation control.
|
||||||
|
|
||||||
|
PipelineParams is the target for AnimationController - animation events
|
||||||
|
modify these params, which the pipeline then applies to its stages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from engine.display import BorderMode
|
||||||
|
except ImportError:
|
||||||
|
BorderMode = object # Fallback for type checking
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineParams:
|
||||||
|
"""Runtime configuration for the pipeline.
|
||||||
|
|
||||||
|
This is the canonical config object that AnimationController modifies.
|
||||||
|
Stages read from these params to adjust their behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Source config
|
||||||
|
source: str = "headlines"
|
||||||
|
source_refresh_interval: float = 60.0
|
||||||
|
|
||||||
|
# Display config
|
||||||
|
display: str = "terminal"
|
||||||
|
border: bool | BorderMode = False
|
||||||
|
|
||||||
|
# Camera config
|
||||||
|
camera_mode: str = "vertical"
|
||||||
|
camera_speed: float = 1.0 # Default speed
|
||||||
|
camera_x: int = 0 # For horizontal scrolling
|
||||||
|
|
||||||
|
# Effect config
|
||||||
|
effect_order: list[str] = field(
|
||||||
|
default_factory=lambda: ["noise", "fade", "glitch", "firehose"]
|
||||||
|
)
|
||||||
|
effect_enabled: dict[str, bool] = field(default_factory=dict)
|
||||||
|
effect_intensity: dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
# Animation-driven state (set by AnimationController)
|
||||||
|
pulse: float = 0.0
|
||||||
|
current_effect: str | None = None
|
||||||
|
path_progress: float = 0.0
|
||||||
|
|
||||||
|
# Viewport
|
||||||
|
viewport_width: int = 80
|
||||||
|
viewport_height: int = 24
|
||||||
|
|
||||||
|
# Firehose
|
||||||
|
firehose_enabled: bool = False
|
||||||
|
|
||||||
|
# Runtime state
|
||||||
|
frame_number: int = 0
|
||||||
|
fps: float = 60.0
|
||||||
|
|
||||||
|
def get_effect_config(self, name: str) -> tuple[bool, float]:
|
||||||
|
"""Get (enabled, intensity) for an effect."""
|
||||||
|
enabled = self.effect_enabled.get(name, True)
|
||||||
|
intensity = self.effect_intensity.get(name, 1.0)
|
||||||
|
return enabled, intensity
|
||||||
|
|
||||||
|
def set_effect_config(self, name: str, enabled: bool, intensity: float) -> None:
|
||||||
|
"""Set effect configuration."""
|
||||||
|
self.effect_enabled[name] = enabled
|
||||||
|
self.effect_intensity[name] = intensity
|
||||||
|
|
||||||
|
def is_effect_enabled(self, name: str) -> bool:
|
||||||
|
"""Check if an effect is enabled."""
|
||||||
|
if name not in self.effect_enabled:
|
||||||
|
return True # Default to enabled
|
||||||
|
return self.effect_enabled.get(name, True)
|
||||||
|
|
||||||
|
def get_effect_intensity(self, name: str) -> float:
|
||||||
|
"""Get effect intensity (0.0 to 1.0)."""
|
||||||
|
return self.effect_intensity.get(name, 1.0)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert to dictionary for serialization."""
|
||||||
|
return {
|
||||||
|
"source": self.source,
|
||||||
|
"display": self.display,
|
||||||
|
"camera_mode": self.camera_mode,
|
||||||
|
"camera_speed": self.camera_speed,
|
||||||
|
"effect_order": self.effect_order,
|
||||||
|
"effect_enabled": self.effect_enabled.copy(),
|
||||||
|
"effect_intensity": self.effect_intensity.copy(),
|
||||||
|
"pulse": self.pulse,
|
||||||
|
"current_effect": self.current_effect,
|
||||||
|
"viewport_width": self.viewport_width,
|
||||||
|
"viewport_height": self.viewport_height,
|
||||||
|
"firehose_enabled": self.firehose_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "PipelineParams":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
params = cls()
|
||||||
|
for key, value in data.items():
|
||||||
|
if hasattr(params, key):
|
||||||
|
setattr(params, key, value)
|
||||||
|
return params
|
||||||
|
|
||||||
|
def copy(self) -> "PipelineParams":
|
||||||
|
"""Create a copy of this params object."""
|
||||||
|
params = PipelineParams()
|
||||||
|
params.source = self.source
|
||||||
|
params.display = self.display
|
||||||
|
params.camera_mode = self.camera_mode
|
||||||
|
params.camera_speed = self.camera_speed
|
||||||
|
params.camera_x = self.camera_x
|
||||||
|
params.effect_order = self.effect_order.copy()
|
||||||
|
params.effect_enabled = self.effect_enabled.copy()
|
||||||
|
params.effect_intensity = self.effect_intensity.copy()
|
||||||
|
params.pulse = self.pulse
|
||||||
|
params.current_effect = self.current_effect
|
||||||
|
params.path_progress = self.path_progress
|
||||||
|
params.viewport_width = self.viewport_width
|
||||||
|
params.viewport_height = self.viewport_height
|
||||||
|
params.firehose_enabled = self.firehose_enabled
|
||||||
|
params.frame_number = self.frame_number
|
||||||
|
params.fps = self.fps
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
# Default params for different modes
|
||||||
|
DEFAULT_HEADLINE_PARAMS = PipelineParams(
|
||||||
|
source="headlines",
|
||||||
|
display="terminal",
|
||||||
|
camera_mode="vertical",
|
||||||
|
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_PYGAME_PARAMS = PipelineParams(
|
||||||
|
source="headlines",
|
||||||
|
display="pygame",
|
||||||
|
camera_mode="vertical",
|
||||||
|
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_PIPELINE_PARAMS = PipelineParams(
|
||||||
|
source="pipeline",
|
||||||
|
display="pygame",
|
||||||
|
camera_mode="trace",
|
||||||
|
effect_order=[], # No effects for pipeline viz
|
||||||
|
)
|
||||||
300
engine/pipeline/pipeline_introspection_demo.py
Normal file
300
engine/pipeline/pipeline_introspection_demo.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
Pipeline introspection demo controller - 3-phase animation system.
|
||||||
|
|
||||||
|
Phase 1: Toggle each effect on/off one at a time (3s each, 1s gap)
|
||||||
|
Phase 2: LFO drives intensity default → max → min → default for each effect
|
||||||
|
Phase 3: All effects with shared LFO driving full waveform
|
||||||
|
|
||||||
|
This controller manages the animation and updates the pipeline accordingly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.effects import get_registry
|
||||||
|
from engine.sensors.oscillator import OscillatorSensor
|
||||||
|
|
||||||
|
|
||||||
|
class DemoPhase(Enum):
|
||||||
|
"""The three phases of the pipeline introspection demo."""
|
||||||
|
|
||||||
|
PHASE_1_TOGGLE = auto() # Toggle each effect on/off
|
||||||
|
PHASE_2_LFO = auto() # LFO drives intensity up/down
|
||||||
|
PHASE_3_SHARED_LFO = auto() # All effects with shared LFO
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PhaseState:
|
||||||
|
"""State for a single phase of the demo."""
|
||||||
|
|
||||||
|
phase: DemoPhase
|
||||||
|
start_time: float
|
||||||
|
current_effect_index: int = 0
|
||||||
|
effect_start_time: float = 0.0
|
||||||
|
lfo_phase: float = 0.0 # 0.0 to 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DemoConfig:
|
||||||
|
"""Configuration for the demo animation."""
|
||||||
|
|
||||||
|
effect_cycle_duration: float = 3.0 # seconds per effect
|
||||||
|
gap_duration: float = 1.0 # seconds between effects
|
||||||
|
lfo_duration: float = (
|
||||||
|
4.0 # seconds for full LFO cycle (default → max → min → default)
|
||||||
|
)
|
||||||
|
phase_2_effect_duration: float = 4.0 # seconds per effect in phase 2
|
||||||
|
phase_3_lfo_duration: float = 6.0 # seconds for full waveform in phase 3
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineIntrospectionDemo:
|
||||||
|
"""Controller for the 3-phase pipeline introspection demo.
|
||||||
|
|
||||||
|
Manages effect toggling and LFO modulation across the pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
pipeline: Any,
|
||||||
|
effect_names: list[str] | None = None,
|
||||||
|
config: DemoConfig | None = None,
|
||||||
|
):
|
||||||
|
self._pipeline = pipeline
|
||||||
|
self._config = config or DemoConfig()
|
||||||
|
self._effect_names = effect_names or ["noise", "fade", "glitch", "firehose"]
|
||||||
|
self._phase = DemoPhase.PHASE_1_TOGGLE
|
||||||
|
self._phase_state = PhaseState(
|
||||||
|
phase=DemoPhase.PHASE_1_TOGGLE,
|
||||||
|
start_time=time.time(),
|
||||||
|
)
|
||||||
|
self._shared_oscillator: OscillatorSensor | None = None
|
||||||
|
self._frame = 0
|
||||||
|
|
||||||
|
# Register shared oscillator for phase 3
|
||||||
|
self._shared_oscillator = OscillatorSensor(
|
||||||
|
name="demo-lfo",
|
||||||
|
waveform="sine",
|
||||||
|
frequency=1.0 / self._config.phase_3_lfo_duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def phase(self) -> DemoPhase:
|
||||||
|
return self._phase
|
||||||
|
|
||||||
|
@property
|
||||||
|
def phase_display(self) -> str:
|
||||||
|
"""Get a human-readable phase description."""
|
||||||
|
phase_num = {
|
||||||
|
DemoPhase.PHASE_1_TOGGLE: 1,
|
||||||
|
DemoPhase.PHASE_2_LFO: 2,
|
||||||
|
DemoPhase.PHASE_3_SHARED_LFO: 3,
|
||||||
|
}
|
||||||
|
return f"Phase {phase_num[self._phase]}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effect_names(self) -> list[str]:
|
||||||
|
return self._effect_names
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shared_oscillator(self) -> OscillatorSensor | None:
|
||||||
|
return self._shared_oscillator
|
||||||
|
|
||||||
|
def update(self) -> dict[str, Any]:
|
||||||
|
"""Update the demo state and return current parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict with current effect settings for the pipeline
|
||||||
|
"""
|
||||||
|
self._frame += 1
|
||||||
|
current_time = time.time()
|
||||||
|
elapsed = current_time - self._phase_state.start_time
|
||||||
|
|
||||||
|
# Phase transition logic
|
||||||
|
phase_duration = self._get_phase_duration()
|
||||||
|
if elapsed >= phase_duration:
|
||||||
|
self._advance_phase()
|
||||||
|
|
||||||
|
# Update based on current phase
|
||||||
|
if self._phase == DemoPhase.PHASE_1_TOGGLE:
|
||||||
|
return self._update_phase_1(current_time)
|
||||||
|
elif self._phase == DemoPhase.PHASE_2_LFO:
|
||||||
|
return self._update_phase_2(current_time)
|
||||||
|
else:
|
||||||
|
return self._update_phase_3(current_time)
|
||||||
|
|
||||||
|
def _get_phase_duration(self) -> float:
|
||||||
|
"""Get duration of current phase in seconds."""
|
||||||
|
if self._phase == DemoPhase.PHASE_1_TOGGLE:
|
||||||
|
# Duration = (effect_time + gap) * num_effects + final_gap
|
||||||
|
return (
|
||||||
|
self._config.effect_cycle_duration + self._config.gap_duration
|
||||||
|
) * len(self._effect_names) + self._config.gap_duration
|
||||||
|
elif self._phase == DemoPhase.PHASE_2_LFO:
|
||||||
|
return self._config.phase_2_effect_duration * len(self._effect_names)
|
||||||
|
else:
|
||||||
|
# Phase 3 runs indefinitely
|
||||||
|
return float("inf")
|
||||||
|
|
||||||
|
def _advance_phase(self) -> None:
|
||||||
|
"""Advance to the next phase."""
|
||||||
|
if self._phase == DemoPhase.PHASE_1_TOGGLE:
|
||||||
|
self._phase = DemoPhase.PHASE_2_LFO
|
||||||
|
elif self._phase == DemoPhase.PHASE_2_LFO:
|
||||||
|
self._phase = DemoPhase.PHASE_3_SHARED_LFO
|
||||||
|
# Start the shared oscillator
|
||||||
|
if self._shared_oscillator:
|
||||||
|
self._shared_oscillator.start()
|
||||||
|
else:
|
||||||
|
# Phase 3 loops indefinitely - reset for demo replay after long time
|
||||||
|
self._phase = DemoPhase.PHASE_1_TOGGLE
|
||||||
|
|
||||||
|
self._phase_state = PhaseState(
|
||||||
|
phase=self._phase,
|
||||||
|
start_time=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_phase_1(self, current_time: float) -> dict[str, Any]:
|
||||||
|
"""Phase 1: Toggle each effect on/off one at a time."""
|
||||||
|
effect_time = current_time - self._phase_state.effect_start_time
|
||||||
|
|
||||||
|
# Check if we should move to next effect
|
||||||
|
cycle_time = self._config.effect_cycle_duration + self._config.gap_duration
|
||||||
|
effect_index = int((current_time - self._phase_state.start_time) / cycle_time)
|
||||||
|
|
||||||
|
# Clamp to valid range
|
||||||
|
if effect_index >= len(self._effect_names):
|
||||||
|
effect_index = len(self._effect_names) - 1
|
||||||
|
|
||||||
|
# Calculate current effect state
|
||||||
|
in_gap = effect_time >= self._config.effect_cycle_duration
|
||||||
|
|
||||||
|
# Build effect states
|
||||||
|
effect_states: dict[str, dict[str, Any]] = {}
|
||||||
|
for i, name in enumerate(self._effect_names):
|
||||||
|
if i < effect_index:
|
||||||
|
# Past effects - leave at default
|
||||||
|
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||||
|
elif i == effect_index:
|
||||||
|
# Current effect - toggle on/off
|
||||||
|
if in_gap:
|
||||||
|
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||||
|
else:
|
||||||
|
effect_states[name] = {"enabled": True, "intensity": 1.0}
|
||||||
|
else:
|
||||||
|
# Future effects - off
|
||||||
|
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||||
|
|
||||||
|
# Apply to effect registry
|
||||||
|
self._apply_effect_states(effect_states)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"phase": "PHASE_1_TOGGLE",
|
||||||
|
"phase_display": self.phase_display,
|
||||||
|
"current_effect": self._effect_names[effect_index]
|
||||||
|
if effect_index < len(self._effect_names)
|
||||||
|
else None,
|
||||||
|
"effect_states": effect_states,
|
||||||
|
"frame": self._frame,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _update_phase_2(self, current_time: float) -> dict[str, Any]:
|
||||||
|
"""Phase 2: LFO drives intensity default → max → min → default."""
|
||||||
|
elapsed = current_time - self._phase_state.start_time
|
||||||
|
effect_index = int(elapsed / self._config.phase_2_effect_duration)
|
||||||
|
effect_index = min(effect_index, len(self._effect_names) - 1)
|
||||||
|
|
||||||
|
# Calculate LFO position (0 → 1 → 0)
|
||||||
|
effect_elapsed = elapsed % self._config.phase_2_effect_duration
|
||||||
|
lfo_position = effect_elapsed / self._config.phase_2_effect_duration
|
||||||
|
|
||||||
|
# LFO: 0 → 1 → 0 (triangle wave)
|
||||||
|
if lfo_position < 0.5:
|
||||||
|
lfo_value = lfo_position * 2 # 0 → 1
|
||||||
|
else:
|
||||||
|
lfo_value = 2 - lfo_position * 2 # 1 → 0
|
||||||
|
|
||||||
|
# Map to intensity: 0.3 (default) → 1.0 (max) → 0.0 (min) → 0.3 (default)
|
||||||
|
if lfo_position < 0.25:
|
||||||
|
# 0.3 → 1.0
|
||||||
|
intensity = 0.3 + (lfo_position / 0.25) * 0.7
|
||||||
|
elif lfo_position < 0.75:
|
||||||
|
# 1.0 → 0.0
|
||||||
|
intensity = 1.0 - ((lfo_position - 0.25) / 0.5) * 1.0
|
||||||
|
else:
|
||||||
|
# 0.0 → 0.3
|
||||||
|
intensity = ((lfo_position - 0.75) / 0.25) * 0.3
|
||||||
|
|
||||||
|
# Build effect states
|
||||||
|
effect_states: dict[str, dict[str, Any]] = {}
|
||||||
|
for i, name in enumerate(self._effect_names):
|
||||||
|
if i < effect_index:
|
||||||
|
# Past effects - default
|
||||||
|
effect_states[name] = {"enabled": True, "intensity": 0.5}
|
||||||
|
elif i == effect_index:
|
||||||
|
# Current effect - LFO modulated
|
||||||
|
effect_states[name] = {"enabled": True, "intensity": intensity}
|
||||||
|
else:
|
||||||
|
# Future effects - off
|
||||||
|
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||||
|
|
||||||
|
# Apply to effect registry
|
||||||
|
self._apply_effect_states(effect_states)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"phase": "PHASE_2_LFO",
|
||||||
|
"phase_display": self.phase_display,
|
||||||
|
"current_effect": self._effect_names[effect_index],
|
||||||
|
"lfo_value": lfo_value,
|
||||||
|
"intensity": intensity,
|
||||||
|
"effect_states": effect_states,
|
||||||
|
"frame": self._frame,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _update_phase_3(self, current_time: float) -> dict[str, Any]:
|
||||||
|
"""Phase 3: All effects with shared LFO driving full waveform."""
|
||||||
|
# Read shared oscillator
|
||||||
|
lfo_value = 0.5 # Default
|
||||||
|
if self._shared_oscillator:
|
||||||
|
sensor_val = self._shared_oscillator.read()
|
||||||
|
if sensor_val:
|
||||||
|
lfo_value = sensor_val.value
|
||||||
|
|
||||||
|
# All effects enabled with shared LFO
|
||||||
|
effect_states: dict[str, dict[str, Any]] = {}
|
||||||
|
for name in self._effect_names:
|
||||||
|
effect_states[name] = {"enabled": True, "intensity": lfo_value}
|
||||||
|
|
||||||
|
# Apply to effect registry
|
||||||
|
self._apply_effect_states(effect_states)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"phase": "PHASE_3_SHARED_LFO",
|
||||||
|
"phase_display": self.phase_display,
|
||||||
|
"lfo_value": lfo_value,
|
||||||
|
"effect_states": effect_states,
|
||||||
|
"frame": self._frame,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _apply_effect_states(self, effect_states: dict[str, dict[str, Any]]) -> None:
|
||||||
|
"""Apply effect states to the effect registry."""
|
||||||
|
try:
|
||||||
|
registry = get_registry()
|
||||||
|
for name, state in effect_states.items():
|
||||||
|
effect = registry.get(name)
|
||||||
|
if effect:
|
||||||
|
effect.config.enabled = state["enabled"]
|
||||||
|
effect.config.intensity = state["intensity"]
|
||||||
|
except Exception:
|
||||||
|
pass # Silently fail if registry not available
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Clean up resources."""
|
||||||
|
if self._shared_oscillator:
|
||||||
|
self._shared_oscillator.stop()
|
||||||
|
|
||||||
|
# Reset all effects to default
|
||||||
|
self._apply_effect_states(
|
||||||
|
{name: {"enabled": False, "intensity": 0.5} for name in self._effect_names}
|
||||||
|
)
|
||||||
280
engine/pipeline/preset_loader.py
Normal file
280
engine/pipeline/preset_loader.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
Preset loader - Loads presets from TOML files.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Built-in presets.toml in the package
|
||||||
|
- User overrides in ~/.config/mainline/presets.toml
|
||||||
|
- Local override in ./presets.toml
|
||||||
|
- Fallback DEFAULT_PRESET when loading fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
DEFAULT_PRESET: dict[str, Any] = {
|
||||||
|
"description": "Default fallback preset",
|
||||||
|
"source": "headlines",
|
||||||
|
"display": "terminal",
|
||||||
|
"camera": "vertical",
|
||||||
|
"effects": [],
|
||||||
|
"viewport": {"width": 80, "height": 24},
|
||||||
|
"camera_speed": 1.0,
|
||||||
|
"firehose_enabled": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset_paths() -> list[Path]:
|
||||||
|
"""Get list of preset file paths in load order (later overrides earlier)."""
|
||||||
|
paths = []
|
||||||
|
|
||||||
|
builtin = Path(__file__).parent.parent / "presets.toml"
|
||||||
|
if builtin.exists():
|
||||||
|
paths.append(builtin)
|
||||||
|
|
||||||
|
user_config = Path(os.path.expanduser("~/.config/mainline/presets.toml"))
|
||||||
|
if user_config.exists():
|
||||||
|
paths.append(user_config)
|
||||||
|
|
||||||
|
local = Path("presets.toml")
|
||||||
|
if local.exists():
|
||||||
|
paths.append(local)
|
||||||
|
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def load_presets() -> dict[str, Any]:
|
||||||
|
"""Load all presets, merging from multiple sources."""
|
||||||
|
merged: dict[str, Any] = {"presets": {}, "sensors": {}, "effect_configs": {}}
|
||||||
|
|
||||||
|
for path in get_preset_paths():
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
if "presets" in data:
|
||||||
|
merged["presets"].update(data["presets"])
|
||||||
|
|
||||||
|
if "sensors" in data:
|
||||||
|
merged["sensors"].update(data["sensors"])
|
||||||
|
|
||||||
|
if "effect_configs" in data:
|
||||||
|
merged["effect_configs"].update(data["effect_configs"])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to load presets from {path}: {e}")
|
||||||
|
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset(name: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a preset by name."""
|
||||||
|
presets = load_presets()
|
||||||
|
return presets.get("presets", {}).get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def list_preset_names() -> list[str]:
|
||||||
|
"""List all available preset names."""
|
||||||
|
presets = load_presets()
|
||||||
|
return list(presets.get("presets", {}).keys())
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_config(name: str) -> dict[str, Any] | None:
|
||||||
|
"""Get sensor configuration by name."""
|
||||||
|
sensors = load_presets()
|
||||||
|
return sensors.get("sensors", {}).get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effect_config(name: str) -> dict[str, Any] | None:
|
||||||
|
"""Get effect configuration by name."""
|
||||||
|
configs = load_presets()
|
||||||
|
return configs.get("effect_configs", {}).get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_effect_configs() -> dict[str, Any]:
|
||||||
|
"""Get all effect configurations."""
|
||||||
|
configs = load_presets()
|
||||||
|
return configs.get("effect_configs", {})
|
||||||
|
|
||||||
|
|
||||||
|
def get_preset_or_default(name: str) -> dict[str, Any]:
|
||||||
|
"""Get a preset by name, or return DEFAULT_PRESET if not found."""
|
||||||
|
preset = get_preset(name)
|
||||||
|
if preset is not None:
|
||||||
|
return preset
|
||||||
|
return DEFAULT_PRESET.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_preset_available(name: str | None) -> dict[str, Any]:
|
||||||
|
"""Ensure a preset is available, falling back to DEFAULT_PRESET."""
|
||||||
|
if name is None:
|
||||||
|
return DEFAULT_PRESET.copy()
|
||||||
|
return get_preset_or_default(name)
|
||||||
|
|
||||||
|
|
||||||
|
class PresetValidationError(Exception):
|
||||||
|
"""Raised when preset validation fails."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_preset(preset: dict[str, Any]) -> list[str]:
|
||||||
|
"""Validate a preset and return list of errors (empty if valid)."""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
required_fields = ["source", "display", "effects"]
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in preset:
|
||||||
|
errors.append(f"Missing required field: {field}")
|
||||||
|
|
||||||
|
if "effects" in preset:
|
||||||
|
if not isinstance(preset["effects"], list):
|
||||||
|
errors.append("'effects' must be a list")
|
||||||
|
else:
|
||||||
|
for effect in preset["effects"]:
|
||||||
|
if not isinstance(effect, str):
|
||||||
|
errors.append(
|
||||||
|
f"Effect must be string, got {type(effect)}: {effect}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "viewport" in preset:
|
||||||
|
viewport = preset["viewport"]
|
||||||
|
if not isinstance(viewport, dict):
|
||||||
|
errors.append("'viewport' must be a dict")
|
||||||
|
else:
|
||||||
|
if "width" in viewport and not isinstance(viewport["width"], int):
|
||||||
|
errors.append("'viewport.width' must be an int")
|
||||||
|
if "height" in viewport and not isinstance(viewport["height"], int):
|
||||||
|
errors.append("'viewport.height' must be an int")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_signal_flow(stages: list[dict]) -> list[str]:
|
||||||
|
"""Validate signal flow based on inlet/outlet types.
|
||||||
|
|
||||||
|
This validates that the preset's stage configuration produces valid
|
||||||
|
data flow using the PureData-style type system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stages: List of stage configs with 'name', 'category', 'inlet_types', 'outlet_types'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of errors (empty if valid)
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if not stages:
|
||||||
|
errors.append("Signal flow is empty")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Define expected types for each category
|
||||||
|
type_map = {
|
||||||
|
"source": {"inlet": "NONE", "outlet": "SOURCE_ITEMS"},
|
||||||
|
"data": {"inlet": "ANY", "outlet": "SOURCE_ITEMS"},
|
||||||
|
"transform": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
|
||||||
|
"effect": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||||
|
"overlay": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||||
|
"camera": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||||
|
"display": {"inlet": "TEXT_BUFFER", "outlet": "NONE"},
|
||||||
|
"render": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check stage order and type compatibility
|
||||||
|
for i, stage in enumerate(stages):
|
||||||
|
category = stage.get("category", "unknown")
|
||||||
|
name = stage.get("name", f"stage_{i}")
|
||||||
|
|
||||||
|
if category not in type_map:
|
||||||
|
continue # Skip unknown categories
|
||||||
|
|
||||||
|
expected = type_map[category]
|
||||||
|
|
||||||
|
# Check against previous stage
|
||||||
|
if i > 0:
|
||||||
|
prev = stages[i - 1]
|
||||||
|
prev_category = prev.get("category", "unknown")
|
||||||
|
if prev_category in type_map:
|
||||||
|
prev_outlet = type_map[prev_category]["outlet"]
|
||||||
|
inlet = expected["inlet"]
|
||||||
|
|
||||||
|
# Validate type compatibility
|
||||||
|
if inlet != "ANY" and prev_outlet != "ANY" and inlet != prev_outlet:
|
||||||
|
errors.append(
|
||||||
|
f"Type mismatch at '{name}': "
|
||||||
|
f"expects {inlet} but previous stage outputs {prev_outlet}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def validate_signal_path(stages: list[str]) -> list[str]:
|
||||||
|
"""Validate signal path for circular dependencies and connectivity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stages: List of stage names in execution order
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of errors (empty if valid)
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
if not stages:
|
||||||
|
errors.append("Signal path is empty")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
for i, stage in enumerate(stages):
|
||||||
|
if stage in seen:
|
||||||
|
errors.append(
|
||||||
|
f"Circular dependency: '{stage}' appears multiple times at index {i}"
|
||||||
|
)
|
||||||
|
seen.add(stage)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def generate_preset_toml(
|
||||||
|
name: str,
|
||||||
|
source: str = "headlines",
|
||||||
|
display: str = "terminal",
|
||||||
|
effects: list[str] | None = None,
|
||||||
|
viewport_width: int = 80,
|
||||||
|
viewport_height: int = 24,
|
||||||
|
camera: str = "vertical",
|
||||||
|
camera_speed: float = 1.0,
|
||||||
|
firehose_enabled: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a TOML preset skeleton with default values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Preset name
|
||||||
|
source: Data source name
|
||||||
|
display: Display backend
|
||||||
|
effects: List of effect names
|
||||||
|
viewport_width: Viewport width in columns
|
||||||
|
viewport_height: Viewport height in rows
|
||||||
|
camera: Camera mode
|
||||||
|
camera_speed: Camera scroll speed
|
||||||
|
firehose_enabled: Enable firehose mode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TOML string for the preset
|
||||||
|
"""
|
||||||
|
|
||||||
|
if effects is None:
|
||||||
|
effects = ["fade"]
|
||||||
|
|
||||||
|
output = []
|
||||||
|
output.append(f"[presets.{name}]")
|
||||||
|
output.append(f'description = "Auto-generated preset: {name}"')
|
||||||
|
output.append(f'source = "{source}"')
|
||||||
|
output.append(f'display = "{display}"')
|
||||||
|
output.append(f'camera = "{camera}"')
|
||||||
|
output.append(f"effects = {effects}")
|
||||||
|
output.append(f"viewport_width = {viewport_width}")
|
||||||
|
output.append(f"viewport_height = {viewport_height}")
|
||||||
|
output.append(f"camera_speed = {camera_speed}")
|
||||||
|
output.append(f"firehose_enabled = {str(firehose_enabled).lower()}")
|
||||||
|
|
||||||
|
return "\n".join(output)
|
||||||
237
engine/pipeline/presets.py
Normal file
237
engine/pipeline/presets.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"""
|
||||||
|
Pipeline presets - Pre-configured pipeline configurations.
|
||||||
|
|
||||||
|
Provides PipelinePreset as a unified preset system.
|
||||||
|
Presets can be loaded from TOML files (presets.toml) or defined in code.
|
||||||
|
|
||||||
|
Loading order:
|
||||||
|
1. Built-in presets.toml in the package
|
||||||
|
2. User config ~/.config/mainline/presets.toml
|
||||||
|
3. Local ./presets.toml (overrides earlier)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from engine.display import BorderMode
|
||||||
|
from engine.pipeline.params import PipelineParams
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from engine.pipeline.controller import PipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
A PipelinePreset packages:
|
||||||
|
- Initial params: Starting configuration
|
||||||
|
- Stages: List of stage configurations to create
|
||||||
|
|
||||||
|
This is the new unified preset that works with the Pipeline class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str = ""
|
||||||
|
source: str = "headlines"
|
||||||
|
display: str = "terminal"
|
||||||
|
camera: str = "scroll"
|
||||||
|
effects: list[str] = field(default_factory=list)
|
||||||
|
border: bool | BorderMode = (
|
||||||
|
False # Border mode: False=off, True=simple, BorderMode.UI for panel
|
||||||
|
)
|
||||||
|
# Extended fields for fine-tuning
|
||||||
|
camera_speed: float = 1.0 # Camera movement speed
|
||||||
|
viewport_width: int = 80 # Viewport width in columns
|
||||||
|
viewport_height: int = 24 # Viewport height in rows
|
||||||
|
source_items: list[dict[str, Any]] | None = None # For ListDataSource
|
||||||
|
enable_metrics: bool = True # Enable performance metrics collection
|
||||||
|
|
||||||
|
def to_params(self) -> PipelineParams:
|
||||||
|
"""Convert to PipelineParams (runtime configuration)."""
|
||||||
|
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()
|
||||||
|
params.camera_speed = self.camera_speed
|
||||||
|
# Note: viewport_width/height are read from PipelinePreset directly
|
||||||
|
# in pipeline_runner.py, not from PipelineParams
|
||||||
|
return params
|
||||||
|
|
||||||
|
def to_config(self) -> "PipelineConfig":
|
||||||
|
"""Convert to PipelineConfig (static pipeline construction config).
|
||||||
|
|
||||||
|
PipelineConfig is used once at pipeline initialization and contains
|
||||||
|
the core settings that don't change during execution.
|
||||||
|
"""
|
||||||
|
from engine.pipeline.controller import PipelineConfig
|
||||||
|
|
||||||
|
return PipelineConfig(
|
||||||
|
source=self.source,
|
||||||
|
display=self.display,
|
||||||
|
camera=self.camera,
|
||||||
|
effects=self.effects.copy(),
|
||||||
|
enable_metrics=self.enable_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_yaml(cls, name: str, data: dict[str, Any]) -> "PipelinePreset":
|
||||||
|
"""Create a PipelinePreset from YAML data."""
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
description=data.get("description", ""),
|
||||||
|
source=data.get("source", "headlines"),
|
||||||
|
display=data.get("display", "terminal"),
|
||||||
|
camera=data.get("camera", "vertical"),
|
||||||
|
effects=data.get("effects", []),
|
||||||
|
border=data.get("border", False),
|
||||||
|
camera_speed=data.get("camera_speed", 1.0),
|
||||||
|
viewport_width=data.get("viewport_width", 80),
|
||||||
|
viewport_height=data.get("viewport_height", 24),
|
||||||
|
source_items=data.get("source_items"),
|
||||||
|
enable_metrics=data.get("enable_metrics", True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Built-in presets
|
||||||
|
DEMO_PRESET = PipelinePreset(
|
||||||
|
name="demo",
|
||||||
|
description="Demo mode with effect cycling and camera modes",
|
||||||
|
source="headlines",
|
||||||
|
display="pygame",
|
||||||
|
camera="scroll",
|
||||||
|
effects=["noise", "fade", "glitch", "firehose"],
|
||||||
|
)
|
||||||
|
|
||||||
|
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="pygame",
|
||||||
|
camera="scroll",
|
||||||
|
effects=["fade"],
|
||||||
|
)
|
||||||
|
|
||||||
|
PIPELINE_VIZ_PRESET = PipelinePreset(
|
||||||
|
name="pipeline",
|
||||||
|
description="Pipeline visualization mode",
|
||||||
|
source="pipeline",
|
||||||
|
display="terminal",
|
||||||
|
camera="trace",
|
||||||
|
effects=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
WEBSOCKET_PRESET = PipelinePreset(
|
||||||
|
name="websocket",
|
||||||
|
description="WebSocket display mode",
|
||||||
|
source="headlines",
|
||||||
|
display="websocket",
|
||||||
|
camera="scroll",
|
||||||
|
effects=["noise", "fade", "glitch"],
|
||||||
|
)
|
||||||
|
|
||||||
|
FIREHOSE_PRESET = PipelinePreset(
|
||||||
|
name="firehose",
|
||||||
|
description="High-speed firehose mode",
|
||||||
|
source="headlines",
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
"""Get a preset by name."""
|
||||||
|
return PRESETS.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def list_presets() -> list[str]:
|
||||||
|
"""List all available preset names."""
|
||||||
|
return list(PRESETS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def create_preset_from_params(
|
||||||
|
params: PipelineParams, name: str = "custom"
|
||||||
|
) -> PipelinePreset:
|
||||||
|
"""Create a preset from PipelineParams."""
|
||||||
|
return PipelinePreset(
|
||||||
|
name=name,
|
||||||
|
source=params.source,
|
||||||
|
display=params.display,
|
||||||
|
camera=params.camera_mode,
|
||||||
|
effects=params.effect_order.copy() if hasattr(params, "effect_order") else [],
|
||||||
|
)
|
||||||
189
engine/pipeline/registry.py
Normal file
189
engine/pipeline/registry.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""
|
||||||
|
Stage registry - Unified registration for all pipeline stages.
|
||||||
|
|
||||||
|
Provides a single registry for sources, effects, displays, and cameras.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any, TypeVar
|
||||||
|
|
||||||
|
from engine.pipeline.core import Stage
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from engine.pipeline.core import Stage
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class StageRegistry:
|
||||||
|
"""Unified registry for all pipeline stage types."""
|
||||||
|
|
||||||
|
_categories: dict[str, dict[str, type[Any]]] = {}
|
||||||
|
_discovered: bool = False
|
||||||
|
_instances: dict[str, Stage] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, category: str, stage_class: type[Any]) -> None:
|
||||||
|
"""Register a stage class in a category.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Category name (source, effect, display, camera)
|
||||||
|
stage_class: Stage subclass to register
|
||||||
|
"""
|
||||||
|
if category not in cls._categories:
|
||||||
|
cls._categories[category] = {}
|
||||||
|
|
||||||
|
key = getattr(stage_class, "__name__", stage_class.__class__.__name__)
|
||||||
|
cls._categories[category][key] = stage_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, category: str, name: str) -> type[Any] | None:
|
||||||
|
"""Get a stage class by category and name."""
|
||||||
|
return cls._categories.get(category, {}).get(name)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list(cls, category: str) -> list[str]:
|
||||||
|
"""List all stage names in a category."""
|
||||||
|
return list(cls._categories.get(category, {}).keys())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_categories(cls) -> list[str]:
|
||||||
|
"""List all registered categories."""
|
||||||
|
return list(cls._categories.keys())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, category: str, name: str, **kwargs) -> Stage | None:
|
||||||
|
"""Create a stage instance by category and name."""
|
||||||
|
stage_class = cls.get(category, name)
|
||||||
|
if stage_class:
|
||||||
|
return stage_class(**kwargs)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_instance(cls, stage: Stage | type[Stage], **kwargs) -> Stage:
|
||||||
|
"""Create an instance from a stage class or return as-is."""
|
||||||
|
if isinstance(stage, Stage):
|
||||||
|
return stage
|
||||||
|
if isinstance(stage, type) and issubclass(stage, Stage):
|
||||||
|
return stage(**kwargs)
|
||||||
|
raise TypeError(f"Expected Stage class or instance, got {type(stage)}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register_instance(cls, name: str, stage: Stage) -> None:
|
||||||
|
"""Register a stage instance by name."""
|
||||||
|
cls._instances[name] = stage
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls, name: str) -> Stage | None:
|
||||||
|
"""Get a registered stage instance by name."""
|
||||||
|
return cls._instances.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_stages() -> None:
|
||||||
|
"""Auto-discover and register all stage implementations."""
|
||||||
|
if StageRegistry._discovered:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Import and register all stage implementations
|
||||||
|
try:
|
||||||
|
from engine.data_sources.sources import (
|
||||||
|
HeadlinesDataSource,
|
||||||
|
PoetryDataSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
StageRegistry.register("source", HeadlinesDataSource)
|
||||||
|
StageRegistry.register("source", PoetryDataSource)
|
||||||
|
|
||||||
|
StageRegistry._categories["source"]["headlines"] = HeadlinesDataSource
|
||||||
|
StageRegistry._categories["source"]["poetry"] = PoetryDataSource
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Register pipeline introspection source
|
||||||
|
try:
|
||||||
|
from engine.data_sources.pipeline_introspection import (
|
||||||
|
PipelineIntrospectionSource,
|
||||||
|
)
|
||||||
|
|
||||||
|
StageRegistry.register("source", PipelineIntrospectionSource)
|
||||||
|
StageRegistry._categories["source"]["pipeline-inspect"] = (
|
||||||
|
PipelineIntrospectionSource
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from engine.effects.types import EffectPlugin # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Register 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()
|
||||||
|
|
||||||
|
StageRegistry._discovered = True
|
||||||
|
|
||||||
|
|
||||||
|
def _register_display_stages() -> None:
|
||||||
|
"""Register display backends as stages."""
|
||||||
|
try:
|
||||||
|
from engine.display import DisplayRegistry
|
||||||
|
except ImportError:
|
||||||
|
return
|
||||||
|
|
||||||
|
DisplayRegistry.initialize()
|
||||||
|
|
||||||
|
for backend_name in DisplayRegistry.list_backends():
|
||||||
|
factory = _DisplayStageFactory(backend_name)
|
||||||
|
StageRegistry._categories.setdefault("display", {})[backend_name] = factory
|
||||||
|
|
||||||
|
|
||||||
|
class _DisplayStageFactory:
|
||||||
|
"""Factory that creates DisplayStage instances for a specific backend."""
|
||||||
|
|
||||||
|
def __init__(self, backend_name: str):
|
||||||
|
self._backend_name = backend_name
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
from engine.display import DisplayRegistry
|
||||||
|
from engine.pipeline.adapters import DisplayStage
|
||||||
|
|
||||||
|
display = DisplayRegistry.create(self._backend_name)
|
||||||
|
if display is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to create display backend: {self._backend_name}"
|
||||||
|
)
|
||||||
|
return DisplayStage(display, name=self._backend_name)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def __name__(self) -> str:
|
||||||
|
return self._backend_name.capitalize() + "Stage"
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience functions
|
||||||
|
def register_source(stage_class: type[Stage]) -> None:
|
||||||
|
"""Register a source stage."""
|
||||||
|
StageRegistry.register("source", stage_class)
|
||||||
|
|
||||||
|
|
||||||
|
def register_effect(stage_class: type[Stage]) -> None:
|
||||||
|
"""Register an effect stage."""
|
||||||
|
StageRegistry.register("effect", stage_class)
|
||||||
|
|
||||||
|
|
||||||
|
def register_display(stage_class: type[Stage]) -> None:
|
||||||
|
"""Register a display stage."""
|
||||||
|
StageRegistry.register("display", stage_class)
|
||||||
|
|
||||||
|
|
||||||
|
def register_camera(stage_class: type[Stage]) -> None:
|
||||||
|
"""Register a camera stage."""
|
||||||
|
StageRegistry.register("camera", stage_class)
|
||||||
174
engine/pipeline/stages/framebuffer.py
Normal file
174
engine/pipeline/stages/framebuffer.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Frame buffer stage - stores previous frames for temporal effects.
|
||||||
|
|
||||||
|
Provides (per-instance, using instance name):
|
||||||
|
- framebuffer.{name}.history: list of previous buffers (most recent first)
|
||||||
|
- framebuffer.{name}.intensity_history: list of corresponding intensity maps
|
||||||
|
- framebuffer.{name}.current_intensity: intensity map for current frame
|
||||||
|
|
||||||
|
Capability: "framebuffer.history.{name}"
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
name: str = "default" # Unique instance name for capability and context keys
|
||||||
|
|
||||||
|
|
||||||
|
class FrameBufferStage(Stage):
|
||||||
|
"""Stores frame history and computes intensity maps.
|
||||||
|
|
||||||
|
Supports multiple instances with unique capabilities and context keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
name: str = "default",
|
||||||
|
):
|
||||||
|
self.config = config or FrameBufferConfig(
|
||||||
|
history_depth=history_depth, name=name
|
||||||
|
)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {f"framebuffer.history.{self.config.name}"}
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
prefix = f"framebuffer.{self.config.name}"
|
||||||
|
ctx.set(f"{prefix}.history", [])
|
||||||
|
ctx.set(f"{prefix}.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
|
||||||
|
|
||||||
|
prefix = f"framebuffer.{self.config.name}"
|
||||||
|
|
||||||
|
# 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(f"{prefix}.current_intensity", intensity_map)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Get existing histories
|
||||||
|
history = ctx.get(f"{prefix}.history", [])
|
||||||
|
intensity_hist = ctx.get(f"{prefix}.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(f"{prefix}.history", history[:max_depth])
|
||||||
|
ctx.set(f"{prefix}.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
|
||||||
|
prefix = f"framebuffer.{self.config.name}"
|
||||||
|
history = ctx.get(f"{prefix}.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
|
||||||
|
prefix = f"framebuffer.{self.config.name}"
|
||||||
|
intensity_hist = ctx.get(f"{prefix}.intensity_history", [])
|
||||||
|
if 0 <= index < len(intensity_hist):
|
||||||
|
return intensity_hist[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Cleanup resources."""
|
||||||
|
pass
|
||||||
674
engine/pipeline/ui.py
Normal file
674
engine/pipeline/ui.py
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
"""
|
||||||
|
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 save_state(self) -> dict[str, Any]:
|
||||||
|
"""Save UI panel state for restoration after pipeline rebuild.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing UI panel state that can be restored
|
||||||
|
"""
|
||||||
|
# Save stage control states (enabled, params, etc.)
|
||||||
|
stage_states = {}
|
||||||
|
for name, ctrl in self.stages.items():
|
||||||
|
stage_states[name] = {
|
||||||
|
"enabled": ctrl.enabled,
|
||||||
|
"selected": ctrl.selected,
|
||||||
|
"params": dict(ctrl.params), # Copy params dict
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stage_states": stage_states,
|
||||||
|
"scroll_offset": self.scroll_offset,
|
||||||
|
"selected_stage": self.selected_stage,
|
||||||
|
"_focused_param": self._focused_param,
|
||||||
|
"_show_panel": self._show_panel,
|
||||||
|
"_show_preset_picker": self._show_preset_picker,
|
||||||
|
"_preset_scroll_offset": self._preset_scroll_offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
def restore_state(self, state: dict[str, Any]) -> None:
|
||||||
|
"""Restore UI panel state from saved state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dictionary containing UI panel state from save_state()
|
||||||
|
"""
|
||||||
|
# Restore stage control states
|
||||||
|
stage_states = state.get("stage_states", {})
|
||||||
|
for name, stage_state in stage_states.items():
|
||||||
|
if name in self.stages:
|
||||||
|
ctrl = self.stages[name]
|
||||||
|
ctrl.enabled = stage_state.get("enabled", True)
|
||||||
|
ctrl.selected = stage_state.get("selected", False)
|
||||||
|
# Restore params
|
||||||
|
saved_params = stage_state.get("params", {})
|
||||||
|
for param_name, param_value in saved_params.items():
|
||||||
|
if param_name in ctrl.params:
|
||||||
|
ctrl.params[param_name] = param_value
|
||||||
|
|
||||||
|
# Restore UI panel state
|
||||||
|
self.scroll_offset = state.get("scroll_offset", 0)
|
||||||
|
self.selected_stage = state.get("selected_stage")
|
||||||
|
self._focused_param = state.get("_focused_param")
|
||||||
|
self._show_panel = state.get("_show_panel", True)
|
||||||
|
self._show_preset_picker = state.get("_show_preset_picker", False)
|
||||||
|
self._preset_scroll_offset = state.get("_preset_scroll_offset", 0)
|
||||||
|
|
||||||
|
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 execute_command(self, command: dict) -> bool:
|
||||||
|
"""Execute a command from external control (e.g., WebSocket).
|
||||||
|
|
||||||
|
Supported UI commands:
|
||||||
|
- {"action": "toggle_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "select_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "adjust_param", "stage": "stage_name", "param": "param_name", "delta": 0.1}
|
||||||
|
- {"action": "change_preset", "preset": "preset_name"}
|
||||||
|
- {"action": "cycle_preset", "direction": 1}
|
||||||
|
|
||||||
|
Pipeline Mutation commands are handled by the WebSocket/runner handler:
|
||||||
|
- {"action": "add_stage", "stage": "stage_name", "type": "source|display|camera|effect"}
|
||||||
|
- {"action": "remove_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "replace_stage", "stage": "old_stage_name", "with": "new_stage_type"}
|
||||||
|
- {"action": "swap_stages", "stage1": "name1", "stage2": "name2"}
|
||||||
|
- {"action": "move_stage", "stage": "stage_name", "after": "other_stage"|"before": "other_stage"}
|
||||||
|
- {"action": "enable_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "disable_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "cleanup_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "can_hot_swap", "stage": "stage_name"}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if command was handled, False if not
|
||||||
|
"""
|
||||||
|
action = command.get("action")
|
||||||
|
|
||||||
|
if action == "toggle_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name in self.stages:
|
||||||
|
self.toggle_stage(stage_name)
|
||||||
|
self._emit_event(
|
||||||
|
"stage_toggled",
|
||||||
|
stage_name=stage_name,
|
||||||
|
enabled=self.stages[stage_name].enabled,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "select_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name in self.stages:
|
||||||
|
self.select_stage(stage_name)
|
||||||
|
self._emit_event("stage_selected", stage_name=stage_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "adjust_param":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
param_name = command.get("param")
|
||||||
|
delta = command.get("delta", 0.1)
|
||||||
|
if stage_name == self.selected_stage and param_name:
|
||||||
|
self._focused_param = param_name
|
||||||
|
self.adjust_selected_param(delta)
|
||||||
|
self._emit_event(
|
||||||
|
"param_changed",
|
||||||
|
stage_name=stage_name,
|
||||||
|
param_name=param_name,
|
||||||
|
value=self.stages[stage_name].params.get(param_name),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "change_preset":
|
||||||
|
preset_name = command.get("preset")
|
||||||
|
if preset_name in self._presets:
|
||||||
|
self._current_preset = preset_name
|
||||||
|
self._emit_event("preset_changed", preset_name=preset_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "cycle_preset":
|
||||||
|
direction = command.get("direction", 1)
|
||||||
|
self.cycle_preset(direction)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
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
|
||||||
221
engine/pipeline/validation.py
Normal file
221
engine/pipeline/validation.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
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",
|
||||||
|
"radial",
|
||||||
|
"static",
|
||||||
|
"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": "static", # Static camera provides camera_y=0 for viewport filtering
|
||||||
|
"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}"
|
||||||
|
)
|
||||||
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 @@
|
|||||||
"""
|
"""Block rendering core - Font loading, text rasterization, word-wrap, and headline assembly.
|
||||||
OTF → terminal half-block rendering pipeline.
|
|
||||||
Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly.
|
Provides PIL font-based rendering to terminal half-block characters.
|
||||||
Depends on: config, terminal, sources, translate.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
@@ -12,74 +11,50 @@ from PIL import Image, ImageDraw, ImageFont
|
|||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
||||||
from engine.terminal import RST
|
|
||||||
from engine.translate import detect_location_language, translate_headline
|
from engine.translate import detect_location_language, translate_headline
|
||||||
|
|
||||||
|
|
||||||
# ─── GRADIENT ─────────────────────────────────────────────
|
def estimate_block_height(title: str, width: int, fnt=None) -> int:
|
||||||
def _color_codes_to_ansi(color_codes):
|
"""Estimate rendered block height without full PIL rendering.
|
||||||
"""Convert a list of 256-color codes to ANSI escape code strings.
|
|
||||||
|
|
||||||
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
|
Uses font bbox measurement to count wrapped lines, then computes:
|
||||||
|
height = num_lines * RENDER_H + (num_lines - 1) + 2
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
color_codes: List of 12 integers (256-color palette codes)
|
title: Headline text to measure
|
||||||
|
width: Terminal width in characters
|
||||||
|
fnt: Optional PIL font (uses default if None)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of ANSI escape code strings
|
Estimated height in terminal rows
|
||||||
"""
|
"""
|
||||||
if not color_codes or len(color_codes) != 12:
|
if fnt is None:
|
||||||
# Fallback to default green if invalid
|
fnt = font()
|
||||||
return _default_green_gradient()
|
text = re.sub(r"\s+", " ", title.upper())
|
||||||
|
words = text.split()
|
||||||
result = []
|
lines = 0
|
||||||
for i, code in enumerate(color_codes):
|
cur = ""
|
||||||
if i < 2:
|
for word in words:
|
||||||
# Bold for first 2 (bright leading edge)
|
test = f"{cur} {word}".strip() if cur else word
|
||||||
result.append(f"\033[1;38;5;{code}m")
|
bbox = fnt.getbbox(test)
|
||||||
elif i < 10:
|
if bbox:
|
||||||
# Normal for middle 8
|
img_h = bbox[3] - bbox[1] + 8
|
||||||
result.append(f"\033[38;5;{code}m")
|
pix_h = config.RENDER_H * 2
|
||||||
|
scale = pix_h / max(img_h, 1)
|
||||||
|
term_w = int((bbox[2] - bbox[0] + 8) * scale)
|
||||||
else:
|
else:
|
||||||
# Dim for last 2 (dark trailing edge)
|
term_w = 0
|
||||||
result.append(f"\033[2;38;5;{code}m")
|
max_term_w = width - 4 - 4
|
||||||
return result
|
if term_w > max_term_w and cur:
|
||||||
|
lines += 1
|
||||||
|
cur = word
|
||||||
def _default_green_gradient():
|
else:
|
||||||
"""Return the default 12-color green gradient for fallback when no theme is active."""
|
cur = test
|
||||||
return [
|
if cur:
|
||||||
"\033[1;38;5;231m", # white
|
lines += 1
|
||||||
"\033[1;38;5;195m", # pale cyan-white
|
if lines == 0:
|
||||||
"\033[38;5;123m", # bright cyan
|
lines = 1
|
||||||
"\033[38;5;118m", # bright lime
|
return lines * config.RENDER_H + max(0, lines - 1) + 2
|
||||||
"\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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _default_magenta_gradient():
|
|
||||||
"""Return the default 12-color magenta gradient for fallback when no theme is active."""
|
|
||||||
return [
|
|
||||||
"\033[1;38;5;231m", # white
|
|
||||||
"\033[1;38;5;225m", # pale pink-white
|
|
||||||
"\033[38;5;219m", # bright pink
|
|
||||||
"\033[38;5;213m", # hot pink
|
|
||||||
"\033[38;5;207m", # magenta
|
|
||||||
"\033[38;5;201m", # bright magenta
|
|
||||||
"\033[38;5;165m", # orchid-red
|
|
||||||
"\033[38;5;161m", # ruby-magenta
|
|
||||||
"\033[38;5;125m", # dark magenta
|
|
||||||
"\033[38;5;89m", # deep maroon-magenta
|
|
||||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
|
||||||
"\033[2;38;5;235m", # near black
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# ─── FONT LOADING ─────────────────────────────────────────
|
# ─── FONT LOADING ─────────────────────────────────────────
|
||||||
@@ -223,65 +198,22 @@ def big_wrap(text, max_w, fnt=None):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def lr_gradient(rows, offset=0.0, cols=None):
|
|
||||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
|
||||||
if cols is None:
|
|
||||||
from engine import config
|
|
||||||
|
|
||||||
if config.ACTIVE_THEME:
|
|
||||||
cols = _color_codes_to_ansi(config.ACTIVE_THEME.main_gradient)
|
|
||||||
else:
|
|
||||||
cols = _default_green_gradient()
|
|
||||||
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, _default_magenta_gradient())
|
|
||||||
|
|
||||||
|
|
||||||
def msg_gradient(rows, offset):
|
|
||||||
"""Apply message (ntfy) gradient using theme complementary colors.
|
|
||||||
|
|
||||||
Returns colored rows using ACTIVE_THEME.message_gradient if available,
|
|
||||||
falling back to default magenta if no theme is set.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
rows: List of text strings to colorize
|
|
||||||
offset: Gradient offset (0.0-1.0) for animation
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of rows with ANSI color codes applied
|
|
||||||
"""
|
|
||||||
from engine import config
|
|
||||||
|
|
||||||
cols = (
|
|
||||||
_color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
|
|
||||||
if config.ACTIVE_THEME
|
|
||||||
else _default_magenta_gradient()
|
|
||||||
)
|
|
||||||
return lr_gradient(rows, offset, cols)
|
|
||||||
|
|
||||||
|
|
||||||
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||||
def make_block(title, src, ts, w):
|
def make_block(title, src, ts, w):
|
||||||
"""Render a headline into a content block with color."""
|
"""Render a headline into a content block with color.
|
||||||
|
|
||||||
|
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 = (
|
target_lang = (
|
||||||
(SOURCE_LANGS.get(src) or detect_location_language(title))
|
(SOURCE_LANGS.get(src) or detect_location_language(title))
|
||||||
if config.MODE == "news"
|
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)
|
||||||
161
engine/scroll.py
161
engine/scroll.py
@@ -1,161 +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.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_figment_overlay,
|
|
||||||
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):
|
|
||||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
|
||||||
if display is None:
|
|
||||||
display = TerminalDisplay()
|
|
||||||
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 = []
|
|
||||||
scroll_cam = 0
|
|
||||||
ticker_next_y = ticker_view_h
|
|
||||||
noise_cache = {}
|
|
||||||
scroll_motion_accum = 0.0
|
|
||||||
msg_cache = (None, None)
|
|
||||||
frame_number = 0
|
|
||||||
|
|
||||||
# Figment overlay (optional — requires cairosvg)
|
|
||||||
figment = None
|
|
||||||
if config.FIGMENT:
|
|
||||||
try:
|
|
||||||
from effects_plugins.figment import FigmentEffect
|
|
||||||
|
|
||||||
figment = FigmentEffect()
|
|
||||||
figment.config.enabled = True
|
|
||||||
figment.config.params["interval_secs"] = config.FIGMENT_INTERVAL
|
|
||||||
except (ImportError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
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
|
|
||||||
scroll_cam += 1
|
|
||||||
|
|
||||||
while (
|
|
||||||
ticker_next_y < scroll_cam + 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) > scroll_cam
|
|
||||||
]
|
|
||||||
for k in list(noise_cache):
|
|
||||||
if k < scroll_cam:
|
|
||||||
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, scroll_cam, 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,
|
|
||||||
scroll_cam,
|
|
||||||
ticker_h,
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Figment overlay (between effects and ntfy message)
|
|
||||||
if figment and figment.config.enabled:
|
|
||||||
figment_state = figment.get_figment_state(frame_number, w, h)
|
|
||||||
if figment_state is not None:
|
|
||||||
figment_buf = render_figment_overlay(figment_state, w, h)
|
|
||||||
buf.extend(figment_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()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user