feat/figment: periodic SVG glyph overlays with CLI flag #34
100
engine/layers.py
100
engine/layers.py
@@ -258,3 +258,103 @@ def get_effect_chain() -> EffectChain | None:
|
||||
if _effect_chain is None:
|
||||
init_effects()
|
||||
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
|
||||
|
||||
60
tests/test_figment_overlay.py
Normal file
60
tests/test_figment_overlay.py
Normal 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 == []
|
||||
Reference in New Issue
Block a user