Update docs, fix Pygame window, and improve camera stage timing

This commit is contained in:
2026-03-18 22:33:57 -07:00
parent c57617bb3d
commit bb0f1b85bf
12 changed files with 338 additions and 57 deletions

View File

@@ -19,7 +19,14 @@ All backends implement a common Display protocol (in `engine/display/__init__.py
```python ```python
class Display(Protocol): class Display(Protocol):
def show(self, buf: list[str]) -> None: 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""" """Display the buffer"""
... ...
@@ -27,7 +34,11 @@ class Display(Protocol):
"""Clear the display""" """Clear the display"""
... ...
def size(self) -> tuple[int, int]: def cleanup(self) -> None:
"""Clean up resources"""
...
def get_dimensions(self) -> tuple[int, int]:
"""Return (width, height)""" """Return (width, height)"""
... ...
``` ```
@@ -37,8 +48,8 @@ class Display(Protocol):
Discovers and manages backends: Discovers and manages backends:
```python ```python
from engine.display import get_monitor from engine.display import DisplayRegistry
display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi" display = DisplayRegistry.create("terminal") # or "websocket", "null", "multi"
``` ```
### Available Backends ### Available Backends
@@ -47,9 +58,9 @@ display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi"
|---------|------|-------------| |---------|------|-------------|
| terminal | backends/terminal.py | ANSI terminal output | | terminal | backends/terminal.py | ANSI terminal output |
| websocket | backends/websocket.py | Web browser via WebSocket | | websocket | backends/websocket.py | Web browser via WebSocket |
| sixel | backends/sixel.py | Sixel graphics (pure Python) |
| null | backends/null.py | Headless for testing | | null | backends/null.py | Headless for testing |
| multi | backends/multi.py | Forwards to multiple displays | | multi | backends/multi.py | Forwards to multiple displays |
| moderngl | backends/moderngl.py | GPU-accelerated OpenGL rendering (optional) |
### WebSocket Backend ### WebSocket Backend
@@ -68,9 +79,11 @@ Forwards to multiple displays simultaneously - useful for `terminal + websocket`
3. Register in `engine/display/__init__.py`'s `DisplayRegistry` 3. Register in `engine/display/__init__.py`'s `DisplayRegistry`
Required methods: Required methods:
- `show(buf: list[str])` - Display buffer - `init(width: int, height: int, reuse: bool = False)` - Initialize display
- `show(buf: list[str], border: bool = False)` - Display buffer
- `clear()` - Clear screen - `clear()` - Clear screen
- `size() -> tuple[int, int]` - Terminal dimensions - `cleanup()` - Clean up resources
- `get_dimensions() -> tuple[int, int]` - Get terminal dimensions
Optional methods: Optional methods:
- `title(text: str)` - Set window title - `title(text: str)` - Set window title
@@ -81,6 +94,5 @@ Optional methods:
```bash ```bash
python mainline.py --display terminal # default python mainline.py --display terminal # default
python mainline.py --display websocket python mainline.py --display websocket
python mainline.py --display sixel python mainline.py --display moderngl # GPU-accelerated (requires moderngl)
python mainline.py --display both # terminal + websocket
``` ```

View File

@@ -86,8 +86,8 @@ Edit `engine/presets.toml` (requires PR to repository).
- `terminal` - ANSI terminal - `terminal` - ANSI terminal
- `websocket` - Web browser - `websocket` - Web browser
- `sixel` - Sixel graphics
- `null` - Headless - `null` - Headless
- `moderngl` - GPU-accelerated (optional)
## Available Effects ## Available Effects

View File

@@ -12,7 +12,7 @@ This project uses:
```bash ```bash
mise run install # Install dependencies mise run install # Install dependencies
# Or: uv sync --all-extras # includes mic, websocket, sixel support # Or: uv sync --all-extras # includes mic, websocket support
``` ```
### Available Commands ### Available Commands
@@ -206,20 +206,6 @@ class TestEventBusSubscribe:
**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.
## Architecture Overview
- **Pipeline**: source → render → effects → display
- **EffectPlugin**: ABC with `process()` and `configure()` methods
- **Display backends**: terminal, websocket, sixel, null (for testing)
- **EventBus**: thread-safe pub/sub messaging
- **Presets**: TOML format in `engine/presets.toml`
Key files:
- `engine/pipeline/core.py` - Stage base class
- `engine/effects/types.py` - EffectPlugin ABC and dataclasses
- `engine/display/backends/` - Display backend implementations
- `engine/eventbus.py` - Thread-safe event system
=======
## Testing ## Testing
Tests live in `tests/` and follow the pattern `test_*.py`. Tests live in `tests/` and follow the pattern `test_*.py`.
@@ -336,9 +322,9 @@ Functions:
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol - **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
- `display/backends/terminal.py` - ANSI terminal output - `display/backends/terminal.py` - ANSI terminal output
- `display/backends/websocket.py` - broadcasts to web clients via WebSocket - `display/backends/websocket.py` - broadcasts to web clients via WebSocket
- `display/backends/sixel.py` - renders to Sixel graphics (pure Python, no C dependency)
- `display/backends/null.py` - headless display for testing - `display/backends/null.py` - headless display for testing
- `display/backends/multi.py` - forwards to multiple displays simultaneously - `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 - `display/__init__.py` - DisplayRegistry for backend discovery
- **WebSocket display** (`engine/display/backends/websocket.py`): real-time frame broadcasting to web browsers - **WebSocket display** (`engine/display/backends/websocket.py`): real-time frame broadcasting to web browsers
@@ -349,8 +335,7 @@ Functions:
- **Display modes** (`--display` flag): - **Display modes** (`--display` flag):
- `terminal` - Default ANSI terminal output - `terminal` - Default ANSI terminal output
- `websocket` - Web browser display (requires websockets package) - `websocket` - Web browser display (requires websockets package)
- `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.) - `moderngl` - GPU-accelerated rendering (requires moderngl package)
- `both` - Terminal + WebSocket simultaneously
### Effect Plugin System ### Effect Plugin System

View File

@@ -16,7 +16,6 @@ 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 --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 --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
@@ -75,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.
@@ -160,9 +158,9 @@ engine/
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
``` ```
@@ -194,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

11
TODO.md
View File

@@ -1,5 +1,16 @@
# Tasks # 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)
## Code & Features
- [ ] Check if luminance implementation exists for shade/tint effects (see #26 related: need to verify render/blocks.py has luminance calculation)
- [ ] Add entropy/chaos score metadata to effects for auto-categorization and intensity control - [ ] Add entropy/chaos score metadata to effects for auto-categorization and intensity control
- [ ] Finish ModernGL display backend: integrate window system, implement glyph caching, add event handling, and support border modes. - [ ] Finish ModernGL display backend: integrate window system, implement glyph caching, add event handling, and support border modes.
- [x] Integrate UIPanel with pipeline: register stages, link parameter schemas, handle events, implement hot-reload. - [x] Integrate UIPanel with pipeline: register stages, link parameter schemas, handle events, implement hot-reload.

View File

@@ -54,7 +54,6 @@ classDiagram
Display <|.. NullDisplay Display <|.. NullDisplay
Display <|.. PygameDisplay Display <|.. PygameDisplay
Display <|.. WebSocketDisplay Display <|.. WebSocketDisplay
Display <|.. SixelDisplay
class Camera { class Camera {
+int viewport_width +int viewport_width
@@ -139,8 +138,6 @@ Display(Protocol)
├── NullDisplay ├── NullDisplay
├── PygameDisplay ├── PygameDisplay
├── WebSocketDisplay ├── WebSocketDisplay
├── SixelDisplay
├── KittyDisplay
└── MultiDisplay └── MultiDisplay
``` ```

View File

@@ -99,9 +99,6 @@ class PygameDisplay:
self.width = width self.width = width
self.height = height self.height = height
import os
os.environ["SDL_VIDEODRIVER"] = "dummy"
try: try:
import pygame import pygame

View File

@@ -1,5 +1,6 @@
"""Adapter for camera stage.""" """Adapter for camera stage."""
import time
from typing import Any from typing import Any
from engine.pipeline.core import DataType, PipelineContext, Stage from engine.pipeline.core import DataType, PipelineContext, Stage
@@ -13,6 +14,7 @@ class CameraStage(Stage):
self.name = name self.name = name
self.category = "camera" self.category = "camera"
self.optional = True self.optional = True
self._last_frame_time: float | None = None
@property @property
def stage_type(self) -> str: def stage_type(self) -> str:
@@ -39,9 +41,17 @@ class CameraStage(Stage):
if data is None: if data is None:
return data return data
# Apply camera offset to items 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
ctx.set_state("camera_y", self._camera.y)
ctx.set_state("camera_x", self._camera.x)
if hasattr(self._camera, "apply"): if hasattr(self._camera, "apply"):
# Extract viewport dimensions from context params
viewport_width = ctx.params.viewport_width if ctx.params else 80 viewport_width = ctx.params.viewport_width if ctx.params else 80
viewport_height = ctx.params.viewport_height if ctx.params else 24 viewport_height = ctx.params.viewport_height if ctx.params else 24
return self._camera.apply(data, viewport_width, viewport_height) return self._camera.apply(data, viewport_width, viewport_height)

View File

@@ -34,9 +34,6 @@ mic = [
websocket = [ websocket = [
"websockets>=12.0", "websockets>=12.0",
] ]
sixel = [
"Pillow>=10.0.0",
]
pygame = [ pygame = [
"pygame>=2.0.0", "pygame>=2.0.0",
] ]

View File

@@ -2,11 +2,52 @@
Tests for engine.benchmark module - performance regression tests. Tests for engine.benchmark module - performance regression tests.
""" """
import os
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from engine.display import NullDisplay from engine.display import MultiDisplay, NullDisplay, TerminalDisplay
from engine.effects import EffectContext, get_registry
from engine.effects.plugins import discover_plugins
def _is_coverage_active():
"""Check if coverage is active."""
# Check if coverage module is loaded
import sys
return "coverage" in sys.modules or "cov" in sys.modules
def _get_min_fps_threshold(base_threshold: int) -> int:
"""
Get minimum FPS threshold adjusted for coverage mode.
Coverage instrumentation typically slows execution by 2-5x.
We adjust thresholds accordingly to avoid false positives.
"""
if _is_coverage_active():
# Coverage typically slows execution by 2-5x
# Use a more conservative threshold (25% of original to account for higher overhead)
return max(500, int(base_threshold * 0.25))
return base_threshold
def _get_iterations() -> int:
"""Get number of iterations for benchmarks."""
# Check for environment variable override
env_iterations = os.environ.get("BENCHMARK_ITERATIONS")
if env_iterations:
try:
return int(env_iterations)
except ValueError:
pass
# Default based on coverage mode
if _is_coverage_active():
return 100 # Fewer iterations when coverage is active
return 500 # Default iterations
class TestBenchmarkNullDisplay: class TestBenchmarkNullDisplay:
@@ -21,14 +62,14 @@ class TestBenchmarkNullDisplay:
display.init(80, 24) display.init(80, 24)
buffer = ["x" * 80 for _ in range(24)] buffer = ["x" * 80 for _ in range(24)]
iterations = 1000 iterations = _get_iterations()
start = time.perf_counter() start = time.perf_counter()
for _ in range(iterations): for _ in range(iterations):
display.show(buffer) display.show(buffer)
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
fps = iterations / elapsed fps = iterations / elapsed
min_fps = 20000 min_fps = _get_min_fps_threshold(20000)
assert fps >= min_fps, f"NullDisplay FPS {fps:.0f} below minimum {min_fps}" assert fps >= min_fps, f"NullDisplay FPS {fps:.0f} below minimum {min_fps}"
@@ -57,14 +98,14 @@ class TestBenchmarkNullDisplay:
has_message=False, has_message=False,
) )
iterations = 500 iterations = _get_iterations()
start = time.perf_counter() start = time.perf_counter()
for _ in range(iterations): for _ in range(iterations):
effect.process(buffer, ctx) effect.process(buffer, ctx)
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
fps = iterations / elapsed fps = iterations / elapsed
min_fps = 10000 min_fps = _get_min_fps_threshold(10000)
assert fps >= min_fps, ( assert fps >= min_fps, (
f"Effect processing FPS {fps:.0f} below minimum {min_fps}" f"Effect processing FPS {fps:.0f} below minimum {min_fps}"
@@ -86,15 +127,254 @@ class TestBenchmarkWebSocketDisplay:
display.init(80, 24) display.init(80, 24)
buffer = ["x" * 80 for _ in range(24)] buffer = ["x" * 80 for _ in range(24)]
iterations = 500 iterations = _get_iterations()
start = time.perf_counter() start = time.perf_counter()
for _ in range(iterations): for _ in range(iterations):
display.show(buffer) display.show(buffer)
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
fps = iterations / elapsed fps = iterations / elapsed
min_fps = 10000 min_fps = _get_min_fps_threshold(10000)
assert fps >= min_fps, ( assert fps >= min_fps, (
f"WebSocketDisplay FPS {fps:.0f} below minimum {min_fps}" f"WebSocketDisplay FPS {fps:.0f} below minimum {min_fps}"
) )
class TestBenchmarkTerminalDisplay:
"""Performance tests for TerminalDisplay."""
@pytest.mark.benchmark
def test_terminal_display_minimum_fps(self):
"""TerminalDisplay should meet minimum performance threshold."""
import time
display = TerminalDisplay()
display.init(80, 24)
buffer = ["x" * 80 for _ in range(24)]
iterations = _get_iterations()
start = time.perf_counter()
for _ in range(iterations):
display.show(buffer)
elapsed = time.perf_counter() - start
fps = iterations / elapsed
min_fps = _get_min_fps_threshold(10000)
assert fps >= min_fps, f"TerminalDisplay FPS {fps:.0f} below minimum {min_fps}"
class TestBenchmarkMultiDisplay:
"""Performance tests for MultiDisplay."""
@pytest.mark.benchmark
def test_multi_display_minimum_fps(self):
"""MultiDisplay should meet minimum performance threshold."""
import time
with patch("engine.display.backends.websocket.websockets", None):
from engine.display import WebSocketDisplay
null_display = NullDisplay()
null_display.init(80, 24)
ws_display = WebSocketDisplay()
ws_display.init(80, 24)
display = MultiDisplay([null_display, ws_display])
display.init(80, 24)
buffer = ["x" * 80 for _ in range(24)]
iterations = _get_iterations()
start = time.perf_counter()
for _ in range(iterations):
display.show(buffer)
elapsed = time.perf_counter() - start
fps = iterations / elapsed
min_fps = _get_min_fps_threshold(5000)
assert fps >= min_fps, f"MultiDisplay FPS {fps:.0f} below minimum {min_fps}"
class TestBenchmarkEffects:
"""Performance tests for various effects."""
@pytest.mark.benchmark
def test_fade_effect_minimum_fps(self):
"""Fade effect should meet minimum performance threshold."""
import time
discover_plugins()
registry = get_registry()
effect = registry.get("fade")
assert effect is not None, "Fade effect should be registered"
buffer = ["x" * 80 for _ in range(24)]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=20,
mic_excess=0.0,
grad_offset=0.0,
frame_number=0,
has_message=False,
)
iterations = _get_iterations()
start = time.perf_counter()
for _ in range(iterations):
effect.process(buffer, ctx)
elapsed = time.perf_counter() - start
fps = iterations / elapsed
min_fps = _get_min_fps_threshold(7000)
assert fps >= min_fps, f"Fade effect FPS {fps:.0f} below minimum {min_fps}"
@pytest.mark.benchmark
def test_glitch_effect_minimum_fps(self):
"""Glitch effect should meet minimum performance threshold."""
import time
discover_plugins()
registry = get_registry()
effect = registry.get("glitch")
assert effect is not None, "Glitch effect should be registered"
buffer = ["x" * 80 for _ in range(24)]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=20,
mic_excess=0.0,
grad_offset=0.0,
frame_number=0,
has_message=False,
)
iterations = _get_iterations()
start = time.perf_counter()
for _ in range(iterations):
effect.process(buffer, ctx)
elapsed = time.perf_counter() - start
fps = iterations / elapsed
min_fps = _get_min_fps_threshold(5000)
assert fps >= min_fps, f"Glitch effect FPS {fps:.0f} below minimum {min_fps}"
@pytest.mark.benchmark
def test_border_effect_minimum_fps(self):
"""Border effect should meet minimum performance threshold."""
import time
discover_plugins()
registry = get_registry()
effect = registry.get("border")
assert effect is not None, "Border effect should be registered"
buffer = ["x" * 80 for _ in range(24)]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=20,
mic_excess=0.0,
grad_offset=0.0,
frame_number=0,
has_message=False,
)
iterations = _get_iterations()
start = time.perf_counter()
for _ in range(iterations):
effect.process(buffer, ctx)
elapsed = time.perf_counter() - start
fps = iterations / elapsed
min_fps = _get_min_fps_threshold(5000)
assert fps >= min_fps, f"Border effect FPS {fps:.0f} below minimum {min_fps}"
@pytest.mark.benchmark
def test_tint_effect_minimum_fps(self):
"""Tint effect should meet minimum performance threshold."""
import time
discover_plugins()
registry = get_registry()
effect = registry.get("tint")
assert effect is not None, "Tint effect should be registered"
buffer = ["x" * 80 for _ in range(24)]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=20,
mic_excess=0.0,
grad_offset=0.0,
frame_number=0,
has_message=False,
)
iterations = _get_iterations()
start = time.perf_counter()
for _ in range(iterations):
effect.process(buffer, ctx)
elapsed = time.perf_counter() - start
fps = iterations / elapsed
min_fps = _get_min_fps_threshold(8000)
assert fps >= min_fps, f"Tint effect FPS {fps:.0f} below minimum {min_fps}"
class TestBenchmarkPipeline:
"""Performance tests for pipeline execution."""
@pytest.mark.benchmark
def test_pipeline_execution_minimum_fps(self):
"""Pipeline execution should meet minimum performance threshold."""
import time
from engine.data_sources.sources import EmptyDataSource
from engine.pipeline import Pipeline, StageRegistry, discover_stages
from engine.pipeline.adapters import DataSourceStage, SourceItemsToBufferStage
discover_stages()
# Create a minimal pipeline with empty source to avoid network calls
pipeline = Pipeline()
# Create empty source directly (not registered in stage registry)
empty_source = EmptyDataSource(width=80, height=24)
source_stage = DataSourceStage(empty_source, name="empty")
# Add render stage to convert items to text buffer
render_stage = SourceItemsToBufferStage(name="items-to-buffer")
# Get null display from registry
null_display = StageRegistry.create("display", "null")
assert null_display is not None, "null display should be registered"
pipeline.add_stage("source", source_stage)
pipeline.add_stage("render", render_stage)
pipeline.add_stage("display", null_display)
pipeline.build()
iterations = _get_iterations()
start = time.perf_counter()
for _ in range(iterations):
pipeline.execute()
elapsed = time.perf_counter() - start
fps = iterations / elapsed
min_fps = _get_min_fps_threshold(1000)
assert fps >= min_fps, (
f"Pipeline execution FPS {fps:.0f} below minimum {min_fps}"
)

View File

@@ -82,8 +82,6 @@ class TestDisplayRegistry:
assert DisplayRegistry.get("websocket") == WebSocketDisplay assert DisplayRegistry.get("websocket") == WebSocketDisplay
assert DisplayRegistry.get("pygame") == PygameDisplay assert DisplayRegistry.get("pygame") == PygameDisplay
# Removed backends (sixel, kitty) should not be present
assert DisplayRegistry.get("sixel") is None
def test_initialize_idempotent(self): def test_initialize_idempotent(self):
"""initialize can be called multiple times safely.""" """initialize can be called multiple times safely."""

View File

@@ -45,8 +45,6 @@ class TestStageRegistry:
assert "pygame" in displays assert "pygame" in displays
assert "websocket" in displays assert "websocket" in displays
assert "null" in displays assert "null" in displays
# sixel and kitty removed; should not be present
assert "sixel" not in displays
def test_create_source_stage(self): def test_create_source_stage(self):
"""StageRegistry.create creates source stages.""" """StageRegistry.create creates source stages."""