fix: resolve terminal display wobble and effect dimension stability
- Fix TerminalDisplay: add screen clear each frame (cursor home + erase down) - Fix CameraStage: use set_canvas_size instead of read-only viewport properties - Fix Glitch effect: preserve visible line lengths, remove cursor positioning - Fix Fade effect: return original line when fade=0 instead of empty string - Fix Noise effect: use input line length instead of terminal_width - Remove HUD effect from all presets (redundant with border FPS display) - Add regression tests for effect dimension stability - Add docs/ARCHITECTURE.md with Mermaid diagrams - Add mise tasks: diagram-ascii, diagram-validate, diagram-check - Move markdown docs to docs/ (ARCHITECTURE, Refactor, hardware specs) - Remove redundant requirements files (use pyproject.toml) - Add *.dot and *.png to .gitignore Closes #25
This commit is contained in:
@@ -9,11 +9,16 @@ class NullDisplay:
|
||||
"""Headless/null display - discards all output.
|
||||
|
||||
This display does nothing - useful for headless benchmarking
|
||||
or when no display output is needed.
|
||||
or when no display output is needed. Captures last buffer
|
||||
for testing purposes.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
_last_buffer: list[str] | None = None
|
||||
|
||||
def __init__(self):
|
||||
self._last_buffer = None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
@@ -25,10 +30,12 @@ class NullDisplay:
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._last_buffer = None
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
from engine.display import get_monitor
|
||||
|
||||
self._last_buffer = buffer
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
t0 = time.perf_counter()
|
||||
@@ -49,3 +56,11 @@ class NullDisplay:
|
||||
(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
|
||||
|
||||
@@ -22,6 +22,7 @@ class TerminalDisplay:
|
||||
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.
|
||||
@@ -62,14 +63,26 @@ class TerminalDisplay:
|
||||
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()
|
||||
return (term_size.columns, term_size.lines)
|
||||
new_dims = (term_size.columns, term_size.lines)
|
||||
except OSError:
|
||||
return (self.width, self.height)
|
||||
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
|
||||
@@ -103,10 +116,9 @@ class TerminalDisplay:
|
||||
if border:
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
# Clear screen and home cursor before each frame
|
||||
from engine.terminal import CLR
|
||||
|
||||
output = CLR + "".join(buffer)
|
||||
# Write buffer with cursor home + erase down to avoid flicker
|
||||
# \033[H = cursor home, \033[J = erase from cursor to end of screen
|
||||
output = "\033[H\033[J" + "".join(buffer)
|
||||
sys.stdout.buffer.write(output.encode())
|
||||
sys.stdout.flush()
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
@@ -124,3 +136,11 @@ class TerminalDisplay:
|
||||
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
|
||||
|
||||
@@ -347,9 +347,12 @@ class CameraStage(Stage):
|
||||
)
|
||||
|
||||
# Update camera's viewport dimensions so it knows its actual bounds
|
||||
if hasattr(self._camera, "viewport_width"):
|
||||
self._camera.viewport_width = viewport_width
|
||||
self._camera.viewport_height = viewport_height
|
||||
# Set canvas size to achieve desired viewport (viewport = canvas / zoom)
|
||||
if hasattr(self._camera, "set_canvas_size"):
|
||||
self._camera.set_canvas_size(
|
||||
width=int(viewport_width * self._camera.zoom),
|
||||
height=int(viewport_height * self._camera.zoom),
|
||||
)
|
||||
|
||||
# Set canvas to full layout height so camera can scroll through all content
|
||||
self._camera.set_canvas_size(width=canvas_width, height=total_layout_height)
|
||||
|
||||
@@ -32,7 +32,7 @@ class PipelineParams:
|
||||
|
||||
# Effect config
|
||||
effect_order: list[str] = field(
|
||||
default_factory=lambda: ["noise", "fade", "glitch", "firehose", "hud"]
|
||||
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)
|
||||
@@ -127,19 +127,19 @@ DEFAULT_HEADLINE_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="terminal",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
DEFAULT_PYGAME_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
DEFAULT_PIPELINE_PARAMS = PipelineParams(
|
||||
source="pipeline",
|
||||
display="pygame",
|
||||
camera_mode="trace",
|
||||
effect_order=["hud"], # Just HUD for pipeline viz
|
||||
effect_order=[], # No effects for pipeline viz
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ DEFAULT_PRESET: dict[str, Any] = {
|
||||
"source": "headlines",
|
||||
"display": "terminal",
|
||||
"camera": "vertical",
|
||||
"effects": ["hud"],
|
||||
"effects": [],
|
||||
"viewport": {"width": 80, "height": 24},
|
||||
"camera_speed": 1.0,
|
||||
"firehose_enabled": False,
|
||||
@@ -263,7 +263,7 @@ def generate_preset_toml(
|
||||
"""
|
||||
|
||||
if effects is None:
|
||||
effects = ["fade", "hud"]
|
||||
effects = ["fade"]
|
||||
|
||||
output = []
|
||||
output.append(f"[presets.{name}]")
|
||||
|
||||
@@ -80,7 +80,7 @@ DEMO_PRESET = PipelinePreset(
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
effects=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
POETRY_PRESET = PipelinePreset(
|
||||
@@ -89,7 +89,7 @@ POETRY_PRESET = PipelinePreset(
|
||||
source="poetry",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["fade", "hud"],
|
||||
effects=["fade"],
|
||||
)
|
||||
|
||||
PIPELINE_VIZ_PRESET = PipelinePreset(
|
||||
@@ -98,7 +98,7 @@ PIPELINE_VIZ_PRESET = PipelinePreset(
|
||||
source="pipeline",
|
||||
display="terminal",
|
||||
camera="trace",
|
||||
effects=["hud"],
|
||||
effects=[],
|
||||
)
|
||||
|
||||
WEBSOCKET_PRESET = PipelinePreset(
|
||||
@@ -107,7 +107,7 @@ WEBSOCKET_PRESET = PipelinePreset(
|
||||
source="headlines",
|
||||
display="websocket",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch", "hud"],
|
||||
effects=["noise", "fade", "glitch"],
|
||||
)
|
||||
|
||||
SIXEL_PRESET = PipelinePreset(
|
||||
@@ -116,7 +116,7 @@ SIXEL_PRESET = PipelinePreset(
|
||||
source="headlines",
|
||||
display="sixel",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch", "hud"],
|
||||
effects=["noise", "fade", "glitch"],
|
||||
)
|
||||
|
||||
FIREHOSE_PRESET = PipelinePreset(
|
||||
@@ -125,7 +125,7 @@ FIREHOSE_PRESET = PipelinePreset(
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch", "firehose", "hud"],
|
||||
effects=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user