diff --git a/engine/layers.py b/engine/layers.py index aa8fd59..4997c82 100644 --- a/engine/layers.py +++ b/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 diff --git a/tests/test_figment_overlay.py b/tests/test_figment_overlay.py new file mode 100644 index 0000000..2e5b728 --- /dev/null +++ b/tests/test_figment_overlay.py @@ -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 == []