From bb0f1b85bf4857ec689af627a759ad399f3e226f Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Wed, 18 Mar 2026 22:33:57 -0700 Subject: [PATCH] Update docs, fix Pygame window, and improve camera stage timing --- .opencode/skills/mainline-display/SKILL.md | 30 ++- .opencode/skills/mainline-presets/SKILL.md | 2 +- AGENTS.md | 21 +- README.md | 10 +- TODO.md | 11 + docs/ARCHITECTURE.md | 3 - engine/display/backends/pygame.py | 3 - engine/pipeline/adapters/camera.py | 14 +- pyproject.toml | 3 - tests/test_benchmark.py | 294 ++++++++++++++++++++- tests/test_display.py | 2 - tests/test_pipeline.py | 2 - 12 files changed, 338 insertions(+), 57 deletions(-) diff --git a/.opencode/skills/mainline-display/SKILL.md b/.opencode/skills/mainline-display/SKILL.md index edfed1e..9a11c83 100644 --- a/.opencode/skills/mainline-display/SKILL.md +++ b/.opencode/skills/mainline-display/SKILL.md @@ -19,7 +19,14 @@ All backends implement a common Display protocol (in `engine/display/__init__.py ```python 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""" ... @@ -27,7 +34,11 @@ class Display(Protocol): """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)""" ... ``` @@ -37,8 +48,8 @@ class Display(Protocol): Discovers and manages backends: ```python -from engine.display import get_monitor -display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi" +from engine.display import DisplayRegistry +display = DisplayRegistry.create("terminal") # or "websocket", "null", "multi" ``` ### Available Backends @@ -47,9 +58,9 @@ display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi" |---------|------|-------------| | terminal | backends/terminal.py | ANSI terminal output | | websocket | backends/websocket.py | Web browser via WebSocket | -| sixel | backends/sixel.py | Sixel graphics (pure Python) | | null | backends/null.py | Headless for testing | | multi | backends/multi.py | Forwards to multiple displays | +| moderngl | backends/moderngl.py | GPU-accelerated OpenGL rendering (optional) | ### WebSocket Backend @@ -68,9 +79,11 @@ Forwards to multiple displays simultaneously - useful for `terminal + websocket` 3. Register in `engine/display/__init__.py`'s `DisplayRegistry` 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 -- `size() -> tuple[int, int]` - Terminal dimensions +- `cleanup()` - Clean up resources +- `get_dimensions() -> tuple[int, int]` - Get terminal dimensions Optional methods: - `title(text: str)` - Set window title @@ -81,6 +94,5 @@ Optional methods: ```bash python mainline.py --display terminal # default python mainline.py --display websocket -python mainline.py --display sixel -python mainline.py --display both # terminal + websocket +python mainline.py --display moderngl # GPU-accelerated (requires moderngl) ``` diff --git a/.opencode/skills/mainline-presets/SKILL.md b/.opencode/skills/mainline-presets/SKILL.md index 7b94c93..2f882b2 100644 --- a/.opencode/skills/mainline-presets/SKILL.md +++ b/.opencode/skills/mainline-presets/SKILL.md @@ -86,8 +86,8 @@ Edit `engine/presets.toml` (requires PR to repository). - `terminal` - ANSI terminal - `websocket` - Web browser -- `sixel` - Sixel graphics - `null` - Headless +- `moderngl` - GPU-accelerated (optional) ## Available Effects diff --git a/AGENTS.md b/AGENTS.md index 94140b0..030a176 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ This project uses: ```bash 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 @@ -206,20 +206,6 @@ class TestEventBusSubscribe: **Never** modify a test to make it pass without understanding why it failed. -## Architecture Overview - -- **Pipeline**: source → render → effects → display -- **EffectPlugin**: ABC with `process()` and `configure()` methods -- **Display backends**: terminal, websocket, sixel, null (for testing) -- **EventBus**: thread-safe pub/sub messaging -- **Presets**: TOML format in `engine/presets.toml` - -Key files: -- `engine/pipeline/core.py` - Stage base class -- `engine/effects/types.py` - EffectPlugin ABC and dataclasses -- `engine/display/backends/` - Display backend implementations -- `engine/eventbus.py` - Thread-safe event system -======= ## Testing Tests live in `tests/` and follow the pattern `test_*.py`. @@ -336,9 +322,9 @@ Functions: - **Display abstraction** (`engine/display/`): swap display backends via the Display protocol - `display/backends/terminal.py` - ANSI terminal output - `display/backends/websocket.py` - broadcasts to web clients via WebSocket - - `display/backends/sixel.py` - renders to Sixel graphics (pure Python, no C dependency) - `display/backends/null.py` - headless display for testing - `display/backends/multi.py` - forwards to multiple displays simultaneously + - `display/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 @@ -349,8 +335,7 @@ Functions: - **Display modes** (`--display` flag): - `terminal` - Default ANSI terminal output - `websocket` - Web browser display (requires websockets package) - - `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.) - - `both` - Terminal + WebSocket simultaneously + - `moderngl` - GPU-accelerated rendering (requires moderngl package) ### Effect Plugin System diff --git a/README.md b/README.md index cd549ba..e445746 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ python3 mainline.py --poetry # literary consciousness mode python3 mainline.py -p # same python3 mainline.py --firehose # dense rapid-fire headline mode python3 mainline.py --display websocket # web browser display only -python3 mainline.py --display both # terminal + web browser python3 mainline.py --no-font-picker # skip interactive font picker python3 mainline.py --font-file path.otf # use a specific font file python3 mainline.py --font-dir ~/fonts # scan a different font folder @@ -75,8 +74,7 @@ Mainline supports multiple display backends: - **Terminal** (`--display terminal`): ANSI terminal output (default) - **WebSocket** (`--display websocket`): Stream to web browser clients -- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty) -- **Both** (`--display both`): Terminal + WebSocket simultaneously +- **ModernGL** (`--display moderngl`): GPU-accelerated rendering (optional) WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode. @@ -160,9 +158,9 @@ engine/ backends/ terminal.py ANSI terminal display websocket.py WebSocket server for browser clients - sixel.py Sixel graphics (pure Python) null.py headless display for testing multi.py forwards to multiple displays + moderngl.py GPU-accelerated OpenGL rendering benchmark.py performance benchmarking tool ``` @@ -194,9 +192,7 @@ mise run format # ruff format mise run run # terminal display mise run run-websocket # web display only -mise run run-sixel # sixel graphics -mise run run-both # terminal + web -mise run run-client # both + open browser +mise run run-client # terminal + web mise run cmd # C&C command interface mise run cmd-stats # watch effects stats diff --git a/TODO.md b/TODO.md index 4a9b5f4..e96ef1a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,16 @@ # 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 - [ ] 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0fc86b5..ebe71f6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -54,7 +54,6 @@ classDiagram Display <|.. NullDisplay Display <|.. PygameDisplay Display <|.. WebSocketDisplay - Display <|.. SixelDisplay class Camera { +int viewport_width @@ -139,8 +138,6 @@ Display(Protocol) ├── NullDisplay ├── PygameDisplay ├── WebSocketDisplay -├── SixelDisplay -├── KittyDisplay └── MultiDisplay ``` diff --git a/engine/display/backends/pygame.py b/engine/display/backends/pygame.py index 4aae0e9..b41d819 100644 --- a/engine/display/backends/pygame.py +++ b/engine/display/backends/pygame.py @@ -99,9 +99,6 @@ class PygameDisplay: self.width = width self.height = height - import os - - os.environ["SDL_VIDEODRIVER"] = "dummy" try: import pygame diff --git a/engine/pipeline/adapters/camera.py b/engine/pipeline/adapters/camera.py index 02b0366..68068fd 100644 --- a/engine/pipeline/adapters/camera.py +++ b/engine/pipeline/adapters/camera.py @@ -1,5 +1,6 @@ """Adapter for camera stage.""" +import time from typing import Any from engine.pipeline.core import DataType, PipelineContext, Stage @@ -13,6 +14,7 @@ class CameraStage(Stage): self.name = name self.category = "camera" self.optional = True + self._last_frame_time: float | None = None @property def stage_type(self) -> str: @@ -39,9 +41,17 @@ class CameraStage(Stage): if data is None: 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"): - # Extract viewport dimensions from context params viewport_width = ctx.params.viewport_width if ctx.params else 80 viewport_height = ctx.params.viewport_height if ctx.params else 24 return self._camera.apply(data, viewport_width, viewport_height) diff --git a/pyproject.toml b/pyproject.toml index 7929014..c238079 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,9 +34,6 @@ mic = [ websocket = [ "websockets>=12.0", ] -sixel = [ - "Pillow>=10.0.0", -] pygame = [ "pygame>=2.0.0", ] diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index da28e58..ba37f12 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -2,11 +2,52 @@ Tests for engine.benchmark module - performance regression tests. """ +import os from unittest.mock import patch 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: @@ -21,14 +62,14 @@ class TestBenchmarkNullDisplay: display.init(80, 24) buffer = ["x" * 80 for _ in range(24)] - iterations = 1000 + iterations = _get_iterations() start = time.perf_counter() for _ in range(iterations): display.show(buffer) elapsed = time.perf_counter() - start 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}" @@ -57,14 +98,14 @@ class TestBenchmarkNullDisplay: has_message=False, ) - iterations = 500 + 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 = 10000 + min_fps = _get_min_fps_threshold(10000) assert fps >= min_fps, ( f"Effect processing FPS {fps:.0f} below minimum {min_fps}" @@ -86,15 +127,254 @@ class TestBenchmarkWebSocketDisplay: display.init(80, 24) buffer = ["x" * 80 for _ in range(24)] - iterations = 500 + iterations = _get_iterations() start = time.perf_counter() for _ in range(iterations): display.show(buffer) elapsed = time.perf_counter() - start fps = iterations / elapsed - min_fps = 10000 + min_fps = _get_min_fps_threshold(10000) assert fps >= 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}" + ) diff --git a/tests/test_display.py b/tests/test_display.py index 5adc678..e6b1275 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -82,8 +82,6 @@ class TestDisplayRegistry: assert DisplayRegistry.get("websocket") == WebSocketDisplay assert DisplayRegistry.get("pygame") == PygameDisplay - # Removed backends (sixel, kitty) should not be present - assert DisplayRegistry.get("sixel") is None def test_initialize_idempotent(self): """initialize can be called multiple times safely.""" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index f3bb23c..22e86fa 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -45,8 +45,6 @@ class TestStageRegistry: assert "pygame" in displays assert "websocket" 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): """StageRegistry.create creates source stages."""