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:
2026-03-18 03:34:36 -07:00
parent a65fb50464
commit b926b346ad
30 changed files with 1472 additions and 40 deletions

View File

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

View File

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