feat(figment): add render_figment_overlay() to layers.py

Implements phase-aware (REVEAL/HOLD/DISSOLVE) ANSI cursor-positioning overlay
renderer for figment glyphs, with deterministic shuffle seeding and gradient
coloring via _color_codes_to_ansi(). Includes 6 TDD tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 08:43:48 -07:00
parent 525af4bc46
commit 79d271c42b
2 changed files with 160 additions and 0 deletions

View File

@@ -258,3 +258,103 @@ def get_effect_chain() -> EffectChain | None:
if _effect_chain is None: if _effect_chain is None:
init_effects() init_effects()
return _effect_chain return _effect_chain
def render_figment_overlay(
figment_state,
w: int,
h: int,
) -> list[str]:
"""Render figment overlay as ANSI cursor-positioning commands.
Args:
figment_state: FigmentState with phase, progress, rows, gradient, centering.
w: terminal width
h: terminal height
Returns:
List of ANSI strings to append to display buffer.
"""
from engine.render import _color_codes_to_ansi
rows = figment_state.rows
if not rows:
return []
phase = figment_state.phase
progress = figment_state.progress
gradient = figment_state.gradient
center_row = figment_state.center_row
center_col = figment_state.center_col
cols = _color_codes_to_ansi(gradient)
# Build a list of non-space cell positions
cell_positions = []
for r_idx, row in enumerate(rows):
for c_idx, ch in enumerate(row):
if ch != " ":
cell_positions.append((r_idx, c_idx))
n_cells = len(cell_positions)
if n_cells == 0:
return []
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
shuffled = list(cell_positions)
rng.shuffle(shuffled)
# Phase-dependent visibility
from effects_plugins.figment import FigmentPhase
if phase == FigmentPhase.REVEAL:
visible_count = int(n_cells * progress)
visible = set(shuffled[:visible_count])
elif phase == FigmentPhase.HOLD:
visible = set(cell_positions)
# Strobe: dim some cells periodically
if int(progress * 20) % 3 == 0:
dim_count = int(n_cells * 0.3)
visible -= set(shuffled[:dim_count])
elif phase == FigmentPhase.DISSOLVE:
remaining_count = int(n_cells * (1.0 - progress))
visible = set(shuffled[:remaining_count])
else:
visible = set(cell_positions)
# Build overlay commands
overlay: list[str] = []
n_cols = len(cols)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
for r_idx, row in enumerate(rows):
scr_row = center_row + r_idx + 1 # 1-indexed
if scr_row < 1 or scr_row > h:
continue
line_buf: list[str] = []
has_content = False
for c_idx, ch in enumerate(row):
scr_col = center_col + c_idx + 1
if scr_col < 1 or scr_col > w:
continue
if ch != " " and (r_idx, c_idx) in visible:
# Apply gradient color
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
line_buf.append(f"{cols[idx]}{ch}{RST}")
has_content = True
else:
line_buf.append(" ")
if has_content:
line_str = "".join(line_buf).rstrip()
if line_str.strip():
overlay.append(
f"\033[{scr_row};{center_col + 1}H{line_str}{RST}"
)
return overlay

View File

@@ -0,0 +1,60 @@
"""Tests for render_figment_overlay in engine.layers."""
from effects_plugins.figment import FigmentPhase, FigmentState
from engine.layers import render_figment_overlay
def _make_state(phase=FigmentPhase.HOLD, progress=0.5):
return FigmentState(
phase=phase,
progress=progress,
rows=["█▀▄ █", " ▄█▀ ", "█ █"],
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
center_row=10,
center_col=37,
)
class TestRenderFigmentOverlay:
def test_returns_list_of_strings(self):
state = _make_state()
result = render_figment_overlay(state, 80, 24)
assert isinstance(result, list)
assert all(isinstance(s, str) for s in result)
def test_contains_ansi_positioning(self):
state = _make_state()
result = render_figment_overlay(state, 80, 24)
# Should contain cursor positioning escape codes
assert any("\033[" in s for s in result)
def test_reveal_phase_partial(self):
state = _make_state(phase=FigmentPhase.REVEAL, progress=0.0)
result = render_figment_overlay(state, 80, 24)
# At progress 0.0, very few cells should be visible
# Result should still be a valid list
assert isinstance(result, list)
def test_hold_phase_full(self):
state = _make_state(phase=FigmentPhase.HOLD, progress=0.5)
result = render_figment_overlay(state, 80, 24)
# During hold, content should be present
assert len(result) > 0
def test_dissolve_phase(self):
state = _make_state(phase=FigmentPhase.DISSOLVE, progress=0.9)
result = render_figment_overlay(state, 80, 24)
# At high dissolve progress, most cells are gone
assert isinstance(result, list)
def test_empty_rows(self):
state = FigmentState(
phase=FigmentPhase.HOLD,
progress=0.5,
rows=[],
gradient=[46] * 12,
center_row=0,
center_col=0,
)
result = render_figment_overlay(state, 80, 24)
assert result == []