From 05d261273ec7f81716482c48523cccbd12444c2c Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Tue, 17 Mar 2026 01:24:15 -0700 Subject: [PATCH] feat: Add gallery presets, MultiDisplay support, and viewport tests - Add ~20 gallery presets covering sources, effects, cameras, displays - Add MultiDisplay support with --display multi:terminal,pygame syntax - Fix ViewportFilterStage to recompute layout on viewport_width change - Add benchmark.py module for hook-based performance testing - Add viewport resize tests to test_viewport_filter_performance.py --- engine/app.py | 14 ++ engine/benchmark.py | 73 ++++++ engine/display/__init__.py | 25 ++ engine/pipeline/adapters.py | 6 +- mise.toml | 73 +----- presets.toml | 289 +++++++++++++++++----- tests/test_app.py | 8 +- tests/test_viewport_filter_performance.py | 93 +++++++ 8 files changed, 453 insertions(+), 128 deletions(-) create mode 100644 engine/benchmark.py diff --git a/engine/app.py b/engine/app.py index a9597f0..78a6cd7 100644 --- a/engine/app.py +++ b/engine/app.py @@ -116,6 +116,20 @@ def run_pipeline_mode(preset_name: str = "demo"): 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) diff --git a/engine/benchmark.py b/engine/benchmark.py new file mode 100644 index 0000000..14788ca --- /dev/null +++ b/engine/benchmark.py @@ -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() diff --git a/engine/display/__init__.py b/engine/display/__init__.py index e63fe82..e7d09ec 100644 --- a/engine/display/__init__.py +++ b/engine/display/__init__.py @@ -147,6 +147,31 @@ class DisplayRegistry: cls._initialized = True + @classmethod + def create_multi(cls, names: list[str]) -> "Display | None": + """Create a MultiDisplay from a list of backend names. + + Args: + names: List of display backend names (e.g., ["terminal", "pygame"]) + + Returns: + MultiDisplay instance or None if any backend fails + """ + from engine.display.backends.multi import MultiDisplay + + displays = [] + for name in names: + backend = cls.create(name) + if backend: + displays.append(backend) + else: + return None + + if not displays: + return None + + return MultiDisplay(displays) + def get_monitor(): """Get the performance monitor.""" diff --git a/engine/pipeline/adapters.py b/engine/pipeline/adapters.py index 363ccae..5d6784a 100644 --- a/engine/pipeline/adapters.py +++ b/engine/pipeline/adapters.py @@ -437,8 +437,9 @@ class ViewportFilterStage(Stage): viewport_width = ctx.params.viewport_width if ctx.params else 80 camera_y = ctx.get("camera_y", 0) - # Recompute layout only when item count changes - if len(data) != self._cached_count: + # Recompute layout when item count OR viewport width changes + cached_width = getattr(self, "_cached_width", None) + if len(data) != self._cached_count or cached_width != viewport_width: self._layout = [] y = 0 from engine.render.blocks import estimate_block_height @@ -454,6 +455,7 @@ class ViewportFilterStage(Stage): self._layout.append((y, h)) y += h self._cached_count = len(data) + self._cached_width = viewport_width # Find items visible in [camera_y - buffer, camera_y + viewport_height + buffer] buffer_zone = viewport_height diff --git a/mise.toml b/mise.toml index a8b153a..85011e4 100644 --- a/mise.toml +++ b/mise.toml @@ -5,75 +5,27 @@ pkl = "latest" [tasks] # ===================== -# Testing +# Core # ===================== test = "uv run pytest" -test-v = { run = "uv run pytest -v", depends = ["sync-all"] } -test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html", depends = ["sync-all"] } -test-cov-open = { run = "mise run test-cov && open htmlcov/index.html", depends = ["sync-all"] } - -test-browser-install = { run = "uv run playwright install chromium", depends = ["sync-all"] } -test-browser = { run = "uv run pytest tests/e2e/", depends = ["test-browser-install"] } - -# ===================== -# Linting & Formatting -# ===================== - +test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing", depends = ["sync-all"] } lint = "uv run ruff check engine/ mainline.py" -lint-fix = "uv run ruff check --fix engine/ mainline.py" format = "uv run ruff format engine/ mainline.py" # ===================== -# Runtime Modes +# Run # ===================== run = "uv run mainline.py" -run-poetry = "uv run mainline.py --poetry" -run-firehose = "uv run mainline.py --firehose" - -run-websocket = { run = "uv run mainline.py --display websocket", depends = ["sync-all"] } -run-sixel = { run = "uv run mainline.py --display sixel", depends = ["sync-all"] } -run-kitty = { run = "uv run mainline.py --display kitty", depends = ["sync-all"] } run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] } -run-both = { run = "uv run mainline.py --display both", depends = ["sync-all"] } -run-client = { run = "mise run run-both & sleep 2 && $(open http://localhost:8766 2>/dev/null || xdg-open http://localhost:8766 2>/dev/null || echo 'Open http://localhost:8766 manually'); wait", depends = ["sync-all"] } +run-terminal = { run = "uv run mainline.py --display terminal", depends = ["sync-all"] } # ===================== -# Pipeline Architecture (unified Stage-based) +# Presets # ===================== -run-pipeline = { run = "uv run mainline.py --pipeline --display pygame", depends = ["sync-all"] } -run-pipeline-demo = { run = "uv run mainline.py --pipeline --pipeline-preset demo --display pygame", depends = ["sync-all"] } -run-pipeline-poetry = { run = "uv run mainline.py --pipeline --pipeline-preset poetry --display pygame", depends = ["sync-all"] } -run-pipeline-websocket = { run = "uv run mainline.py --pipeline --pipeline-preset websocket", depends = ["sync-all"] } -run-pipeline-firehose = { run = "uv run mainline.py --pipeline --pipeline-preset firehose --display pygame", depends = ["sync-all"] } - -# ===================== -# Presets (Animation-controlled modes) -# ===================== - -run-preset-demo = { run = "uv run mainline.py --preset demo --display pygame", depends = ["sync-all"] } -run-preset-border-test = { run = "uv run mainline.py --preset border-test --display terminal", depends = ["sync-all"] } -run-preset-pipeline-inspect = { run = "uv run mainline.py --preset pipeline-inspect --display terminal", depends = ["sync-all"] } - -# ===================== -# Command & Control -# ===================== - -cmd = "uv run cmdline.py" -cmd-stats = { run = "uv run cmdline.py -w \"/effects stats\"", depends = ["sync-all"] } - -# ===================== -# Benchmark -# ===================== - -benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] } -benchmark-json = { run = "uv run python -m engine.benchmark --format json --output benchmark.json", depends = ["sync-all"] } -benchmark-report = { run = "uv run python -m engine.benchmark --output BENCHMARK.md", depends = ["sync-all"] } - -# Initialize ntfy topics (warm up before first use) -topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null" +run-demo = { run = "uv run mainline.py --preset demo --display pygame", depends = ["sync-all"] } # ===================== # Daemon @@ -90,20 +42,21 @@ daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon" sync = "uv sync" sync-all = "uv sync --all-extras" install = "mise run sync" -install-dev = { run = "mise run sync-all && uv sync --group dev", depends = ["sync-all"] } -bootstrap = { run = "mise run sync-all && uv run mainline.py --help", depends = ["sync-all"] } - clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out" clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out" # ===================== -# CI/CD +# CI # ===================== ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] } +topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null" # ===================== -# Git Hooks (via hk) +# Hooks # ===================== -pre-commit = "hk run pre-commit" \ No newline at end of file +pre-commit = "hk run pre-commit" + +[env] +KAGI_API_KEY = "lOp6AGyX6TUB0kGzAli1BlAx5-VjlIN1OPCPYEXDdQc.FOKLieOa7NgWUUZi4mTZvHmrW2uNnOr8hfgv7jMvRQM" diff --git a/presets.toml b/presets.toml index 4830ceb..26604e9 100644 --- a/presets.toml +++ b/presets.toml @@ -8,84 +8,246 @@ # - ~/.config/mainline/presets.toml # - ./presets.toml (local override) -[presets.demo] -description = "Demo mode with effect cycling and camera modes" +# ============================================ +# DATA SOURCE GALLERY +# ============================================ + +[presets.gallery-sources] +description = "Gallery: Headlines data source" source = "headlines" display = "pygame" -camera = "scroll" -effects = ["noise", "fade", "glitch", "firehose"] +camera = "feed" +effects = [] +camera_speed = 0.1 viewport_width = 80 viewport_height = 24 -camera_speed = 1.0 -firehose_enabled = true -[presets.poetry] -description = "Poetry feed with subtle effects" +[presets.gallery-sources-poetry] +description = "Gallery: Poetry data source" source = "poetry" display = "pygame" -camera = "scroll" +camera = "feed" effects = ["fade"] +camera_speed = 0.1 viewport_width = 80 viewport_height = 24 -camera_speed = 0.5 -[presets.border-test] -description = "Test border rendering with empty buffer" -source = "empty" -display = "terminal" -camera = "scroll" -effects = ["border"] -viewport_width = 80 -viewport_height = 24 -camera_speed = 1.0 -firehose_enabled = false -border = false - -[presets.websocket] -description = "WebSocket display mode" -source = "headlines" -display = "websocket" -camera = "scroll" -effects = ["noise", "fade", "glitch"] -viewport_width = 80 -viewport_height = 24 -camera_speed = 1.0 -firehose_enabled = false - -[presets.sixel] -description = "Sixel graphics display mode" -source = "headlines" -display = "sixel" -camera = "scroll" -effects = ["noise", "fade", "glitch"] -viewport_width = 80 -viewport_height = 24 -camera_speed = 1.0 -firehose_enabled = false - -[presets.firehose] -description = "High-speed firehose mode" -source = "headlines" -display = "pygame" -camera = "scroll" -effects = ["noise", "fade", "glitch", "firehose"] -viewport_width = 80 -viewport_height = 24 -camera_speed = 2.0 -firehose_enabled = true - -[presets.pipeline-inspect] -description = "Live pipeline introspection with DAG and performance metrics" +[presets.gallery-sources-pipeline] +description = "Gallery: Pipeline introspection" source = "pipeline-inspect" display = "pygame" camera = "scroll" -effects = ["crop"] +effects = [] +camera_speed = 0.3 viewport_width = 100 viewport_height = 35 -camera_speed = 0.3 -firehose_enabled = false -# Sensor configuration (for future use with param bindings) +[presets.gallery-sources-empty] +description = "Gallery: Empty source (for border tests)" +source = "empty" +display = "terminal" +camera = "feed" +effects = ["border"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +# ============================================ +# EFFECT GALLERY +# ============================================ + +[presets.gallery-effect-noise] +description = "Gallery: Noise effect" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["noise"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-effect-fade] +description = "Gallery: Fade effect" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["fade"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-effect-glitch] +description = "Gallery: Glitch effect" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["glitch"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-effect-firehose] +description = "Gallery: Firehose effect" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["firehose"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-effect-hud] +description = "Gallery: HUD effect" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["hud"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-effect-tint] +description = "Gallery: Tint effect" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["tint"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-effect-border] +description = "Gallery: Border effect" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["border"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-effect-crop] +description = "Gallery: Crop effect" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["crop"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +# ============================================ +# CAMERA GALLERY +# ============================================ + +[presets.gallery-camera-feed] +description = "Gallery: Feed camera (rapid single-item)" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["noise"] +camera_speed = 1.0 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-camera-scroll] +description = "Gallery: Scroll camera (smooth)" +source = "headlines" +display = "pygame" +camera = "scroll" +effects = ["noise"] +camera_speed = 0.3 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-camera-horizontal] +description = "Gallery: Horizontal camera" +source = "headlines" +display = "pygame" +camera = "horizontal" +effects = ["noise"] +camera_speed = 0.5 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-camera-omni] +description = "Gallery: Omni camera" +source = "headlines" +display = "pygame" +camera = "omni" +effects = ["noise"] +camera_speed = 0.5 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-camera-floating] +description = "Gallery: Floating camera" +source = "headlines" +display = "pygame" +camera = "floating" +effects = ["noise"] +camera_speed = 1.0 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-camera-bounce] +description = "Gallery: Bounce camera" +source = "headlines" +display = "pygame" +camera = "bounce" +effects = ["noise"] +camera_speed = 1.0 +viewport_width = 80 +viewport_height = 24 + +# ============================================ +# DISPLAY GALLERY +# ============================================ + +[presets.gallery-display-terminal] +description = "Gallery: Terminal display" +source = "headlines" +display = "terminal" +camera = "feed" +effects = ["noise"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-display-pygame] +description = "Gallery: Pygame display" +source = "headlines" +display = "pygame" +camera = "feed" +effects = ["noise"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-display-websocket] +description = "Gallery: WebSocket display" +source = "headlines" +display = "websocket" +camera = "feed" +effects = ["noise"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +[presets.gallery-display-multi] +description = "Gallery: MultiDisplay (terminal + pygame)" +source = "headlines" +display = "multi:terminal,pygame" +camera = "feed" +effects = ["noise"] +camera_speed = 0.1 +viewport_width = 80 +viewport_height = 24 + +# ============================================ +# SENSOR CONFIGURATION +# ============================================ + [sensors.mic] enabled = false threshold_db = 50.0 @@ -95,7 +257,10 @@ enabled = false waveform = "sine" frequency = 1.0 -# Effect configurations +# ============================================ +# EFFECT CONFIGURATIONS +# ============================================ + [effect_configs.noise] enabled = true intensity = 1.0 diff --git a/tests/test_app.py b/tests/test_app.py index b50d811..f5f8cd4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -30,11 +30,11 @@ class TestMain: patch("engine.app.run_pipeline_mode") as mock_run, ): mock_config.PIPELINE_DIAGRAM = False - mock_config.PRESET = "border-test" + mock_config.PRESET = "gallery-sources" mock_config.PIPELINE_MODE = False sys.argv = ["mainline.py"] main() - mock_run.assert_called_once_with("border-test") + mock_run.assert_called_once_with("gallery-sources") def test_main_exits_on_unknown_preset(self): """main() exits with error for unknown preset.""" @@ -120,11 +120,11 @@ class TestRunPipelineMode: mock_create.return_value = mock_display try: - run_pipeline_mode("border-test") + run_pipeline_mode("gallery-display-terminal") except (KeyboardInterrupt, SystemExit): pass - # Verify display was created with 'terminal' (preset display for border-test) + # Verify display was created with 'terminal' (preset display) mock_create.assert_called_once_with("terminal") def test_run_pipeline_mode_respects_display_cli_flag(self): diff --git a/tests/test_viewport_filter_performance.py b/tests/test_viewport_filter_performance.py index 9957aa8..42d4f82 100644 --- a/tests/test_viewport_filter_performance.py +++ b/tests/test_viewport_filter_performance.py @@ -158,3 +158,96 @@ class TestViewportFilterIntegration: # Verify we kept the first N items in order for i, item in enumerate(filtered): assert item.content == f"Headline {i}" + + +class TestViewportResize: + """Test ViewportFilterStage handles viewport resize correctly.""" + + def test_layout_recomputes_on_width_change(self): + """Test that layout is recomputed when viewport_width changes.""" + stage = ViewportFilterStage() + # Use long headlines that will wrap differently at different widths + items = [ + SourceItem( + f"This is a very long headline number {i} that will definitely wrap at narrow widths", + "test", + str(i), + ) + for i in range(50) + ] + + # Initial render at 80 cols + ctx = PipelineContext() + ctx.params = MockParams(viewport_width=80, viewport_height=24) + ctx.set("camera_y", 0) + + stage.process(items, ctx) + cached_layout_80 = stage._layout.copy() + + # Resize to 40 cols - layout should recompute + ctx.params.viewport_width = 40 + stage.process(items, ctx) + cached_layout_40 = stage._layout.copy() + + # With narrower viewport, items wrap to more lines + # So the cumulative heights should be different + assert cached_layout_40 != cached_layout_80, ( + "Layout should recompute when viewport_width changes" + ) + + def test_layout_recomputes_on_height_change(self): + """Test that visible items change when viewport_height changes.""" + stage = ViewportFilterStage() + items = [SourceItem(f"Headline {i}", "test", str(i)) for i in range(100)] + + ctx = PipelineContext() + ctx.set("camera_y", 0) + + # Small viewport - fewer items visible + ctx.params = MockParams(viewport_width=80, viewport_height=12) + result_small = stage.process(items, ctx) + + # Larger viewport - more items visible + ctx.params.viewport_height = 48 + result_large = stage.process(items, ctx) + + # With larger viewport, more items should be visible + assert len(result_large) >= len(result_small) + + def test_camera_y_propagates_to_filter(self): + """Test that camera_y is read from context.""" + stage = ViewportFilterStage() + items = [SourceItem(f"Headline {i}", "test", str(i)) for i in range(100)] + + ctx = PipelineContext() + ctx.params = MockParams(viewport_width=80, viewport_height=24) + + # Camera at y=0 + ctx.set("camera_y", 0) + result_at_0 = stage.process(items, ctx) + + # Camera at y=100 + ctx.set("camera_y", 100) + result_at_100 = stage.process(items, ctx) + + # With different camera positions, different items should be visible + # (unless items are very short) + first_item_at_0 = result_at_0[0].content if result_at_0 else None + first_item_at_100 = result_at_100[0].content if result_at_100 else None + + # The items at different positions should be different + assert first_item_at_0 != first_item_at_100 or first_item_at_0 is None + + def test_resize_handles_edge_case_small_width(self): + """Test that very narrow viewport doesn't crash.""" + stage = ViewportFilterStage() + items = [SourceItem("Short", "test", "1")] + + ctx = PipelineContext() + ctx.params = MockParams(viewport_width=10, viewport_height=5) + ctx.set("camera_y", 0) + + # Should not crash with very narrow viewport + result = stage.process(items, ctx) + assert result is not None + assert len(result) > 0