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
This commit is contained in:
2026-03-17 01:24:15 -07:00
parent 57de835ae0
commit 05d261273e
8 changed files with 453 additions and 128 deletions

View File

@@ -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)

73
engine/benchmark.py Normal file
View 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()

View File

@@ -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."""

View File

@@ -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

View File

@@ -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"
[env]
KAGI_API_KEY = "lOp6AGyX6TUB0kGzAli1BlAx5-VjlIN1OPCPYEXDdQc.FOKLieOa7NgWUUZi4mTZvHmrW2uNnOr8hfgv7jMvRQM"

View File

@@ -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

View File

@@ -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):

View File

@@ -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