feat(effects): add plugin architecture for visual effects
- Extract effects as fully decoupled plugins in engine/effects/ - Add EffectConfig, EffectContext dataclasses and EffectPlugin protocol - Add EffectRegistry for plugin discovery and management - Add EffectChain for ordered pipeline execution - Move built-in effects to effects_plugins/ directory - Add interactive effects config picker during startup - Add NTFY command handler for /effects commands - Add tests for effects system (24 new tests) - Update AGENTS.md with effects plugin documentation - Add conventional commits section to AGENTS.md chore: add coverage.xml to .gitignore
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ htmlcov/
|
|||||||
.coverage
|
.coverage
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
coverage.xml
|
||||||
|
|||||||
75
AGENTS.md
75
AGENTS.md
@@ -108,3 +108,78 @@ The project uses pytest with strict marker enforcement. Test configuration is in
|
|||||||
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||||
- **controller.py** coordinates ntfy/mic monitoring
|
- **controller.py** coordinates ntfy/mic monitoring
|
||||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||||
|
|
||||||
|
## Effects Plugin System
|
||||||
|
|
||||||
|
The effects system is implemented as a plugin architecture in `engine/effects/`.
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
| Module | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `effects/types.py` | `EffectConfig`, `EffectContext` dataclasses and `EffectPlugin` protocol |
|
||||||
|
| `effects/registry.py` | Plugin discovery and management (`EffectRegistry`) |
|
||||||
|
| `effects/chain.py` | Ordered pipeline execution (`EffectChain`) |
|
||||||
|
| `effects_plugins/*.py` | Externalized effect plugins |
|
||||||
|
|
||||||
|
### Creating a New Effect
|
||||||
|
|
||||||
|
Create a file in `effects_plugins/` with a class ending in `Effect`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext
|
||||||
|
|
||||||
|
class MyEffect:
|
||||||
|
name = "myeffect"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
# Process buffer and return modified buffer
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
```
|
||||||
|
|
||||||
|
### NTFY Commands
|
||||||
|
|
||||||
|
Send messages to the ntfy topic to control effects:
|
||||||
|
|
||||||
|
```
|
||||||
|
/effects list
|
||||||
|
/effects noise on
|
||||||
|
/effects noise off
|
||||||
|
/effects noise intensity 0.5
|
||||||
|
/effects reorder noise,glitch,fade,firehose
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventional Commits
|
||||||
|
|
||||||
|
Commit messages follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat`: A new feature
|
||||||
|
- `fix`: A bug fix
|
||||||
|
- `docs`: Documentation only changes
|
||||||
|
- `style`: Changes that don't affect code meaning (formatting)
|
||||||
|
- `refactor`: Code change that neither fixes a bug nor adds a feature
|
||||||
|
- `test`: Adding or updating tests
|
||||||
|
- `chore`: Changes to build process, dependencies, etc.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(effects): add plugin architecture for visual effects
|
||||||
|
fix(layers): resolve glitch effect not applying on empty buffer
|
||||||
|
docs(AGENTS.md): add effects plugin system documentation
|
||||||
|
test(effects): add tests for EffectChain pipeline ordering
|
||||||
|
```
|
||||||
|
|||||||
35
effects_plugins/__init__.py
Normal file
35
effects_plugins/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLUGIN_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def discover_plugins():
|
||||||
|
from engine.effects.registry import get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
imported = {}
|
||||||
|
|
||||||
|
for file_path in PLUGIN_DIR.glob("*.py"):
|
||||||
|
if file_path.name.startswith("_"):
|
||||||
|
continue
|
||||||
|
module_name = file_path.stem
|
||||||
|
if module_name in ("base", "types"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = __import__(f"effects_plugins.{module_name}", fromlist=[""])
|
||||||
|
for attr_name in dir(module):
|
||||||
|
attr = getattr(module, attr_name)
|
||||||
|
if (
|
||||||
|
isinstance(attr, type)
|
||||||
|
and hasattr(attr, "name")
|
||||||
|
and hasattr(attr, "process")
|
||||||
|
and attr_name.endswith("Effect")
|
||||||
|
):
|
||||||
|
plugin = attr()
|
||||||
|
registry.register(plugin)
|
||||||
|
imported[plugin.name] = plugin
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return imported
|
||||||
58
effects_plugins/fade.py
Normal file
58
effects_plugins/fade.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class FadeEffect:
|
||||||
|
name = "fade"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not ctx.ticker_height:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
|
||||||
|
top_zone = max(1, int(ctx.ticker_height * 0.25))
|
||||||
|
bot_zone = max(1, int(ctx.ticker_height * 0.10))
|
||||||
|
|
||||||
|
for r in range(len(result)):
|
||||||
|
if r >= ctx.ticker_height:
|
||||||
|
continue
|
||||||
|
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||||
|
bot_f = (
|
||||||
|
min(1.0, (ctx.ticker_height - 1 - r) / bot_zone)
|
||||||
|
if bot_zone > 0
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
row_fade = min(top_f, bot_f) * intensity
|
||||||
|
|
||||||
|
if row_fade < 1.0 and result[r].strip():
|
||||||
|
result[r] = self._fade_line(result[r], row_fade)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _fade_line(self, s: str, fade: float) -> str:
|
||||||
|
if fade >= 1.0:
|
||||||
|
return s
|
||||||
|
if fade <= 0.0:
|
||||||
|
return ""
|
||||||
|
result = []
|
||||||
|
i = 0
|
||||||
|
while i < len(s):
|
||||||
|
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||||
|
j = i + 2
|
||||||
|
while j < len(s) and not s[j].isalpha():
|
||||||
|
j += 1
|
||||||
|
result.append(s[i : j + 1])
|
||||||
|
i = j + 1
|
||||||
|
elif s[i] == " ":
|
||||||
|
result.append(" ")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
result.append(s[i] if random.random() < fade else " ")
|
||||||
|
i += 1
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
72
effects_plugins/firehose.py
Normal file
72
effects_plugins/firehose.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
|
class FirehoseEffect:
|
||||||
|
name = "firehose"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
if firehose_h <= 0 or not ctx.items:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
h = ctx.terminal_height
|
||||||
|
|
||||||
|
for fr in range(firehose_h):
|
||||||
|
scr_row = h - firehose_h + fr + 1
|
||||||
|
fline = self._firehose_line(ctx.items, ctx.terminal_width, intensity)
|
||||||
|
result.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _firehose_line(self, items: list, w: int, intensity: float) -> str:
|
||||||
|
r = random.random()
|
||||||
|
if r < 0.35 * intensity:
|
||||||
|
title, src, ts = random.choice(items)
|
||||||
|
text = title[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST, C_DIM])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
elif r < 0.55 * intensity:
|
||||||
|
d = random.choice([0.45, 0.55, 0.65, 0.75])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
elif r < 0.78 * intensity:
|
||||||
|
sources = FEEDS if config.MODE == "news" else POETRY_SOURCES
|
||||||
|
src = random.choice(list(sources.keys()))
|
||||||
|
msgs = [
|
||||||
|
f" SIGNAL :: {src} :: {datetime.now().strftime('%H:%M:%S.%f')[:-3]}",
|
||||||
|
f" ░░ FEED ACTIVE :: {src}",
|
||||||
|
f" >> DECODE 0x{random.randint(0x1000, 0xFFFF):04X} :: {src[:24]}",
|
||||||
|
f" ▒▒ ACQUIRE :: {random.choice(['TCP', 'UDP', 'RSS', 'ATOM', 'XML'])} :: {src}",
|
||||||
|
f" {''.join(random.choice(config.KATA) for _ in range(3))} STRM "
|
||||||
|
f"{random.randint(0, 255):02X}:{random.randint(0, 255):02X}",
|
||||||
|
]
|
||||||
|
text = random.choice(msgs)[: w - 1]
|
||||||
|
color = random.choice([G_LO, G_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
else:
|
||||||
|
title, _, _ = random.choice(items)
|
||||||
|
start = random.randint(0, max(0, len(title) - 20))
|
||||||
|
frag = title[start : start + random.randint(10, 35)]
|
||||||
|
pad = random.randint(0, max(0, w - len(frag) - 8))
|
||||||
|
gp = "".join(
|
||||||
|
random.choice(config.GLITCH) for _ in range(random.randint(1, 3))
|
||||||
|
)
|
||||||
|
text = (" " * pad + gp + " " + frag)[: w - 1]
|
||||||
|
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||||
|
return f"{color}{text}{RST}"
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
37
effects_plugins/glitch.py
Normal file
37
effects_plugins/glitch.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||||
|
|
||||||
|
|
||||||
|
class GlitchEffect:
|
||||||
|
name = "glitch"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not buf:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
|
||||||
|
glitch_prob = 0.32 + min(0.9, ctx.mic_excess * 0.16)
|
||||||
|
glitch_prob = glitch_prob * intensity
|
||||||
|
n_hits = 4 + int(ctx.mic_excess / 2)
|
||||||
|
n_hits = int(n_hits * intensity)
|
||||||
|
|
||||||
|
if random.random() < glitch_prob:
|
||||||
|
for _ in range(min(n_hits, len(result))):
|
||||||
|
gi = random.randint(0, len(result) - 1)
|
||||||
|
scr_row = gi + 1
|
||||||
|
result[gi] = f"\033[{scr_row};1H{self._glitch_bar(ctx.terminal_width)}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _glitch_bar(self, w: int) -> str:
|
||||||
|
c = random.choice(["░", "▒", "─", "\xc2"])
|
||||||
|
n = random.randint(3, w // 2)
|
||||||
|
o = random.randint(0, w - n)
|
||||||
|
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
36
effects_plugins/noise.py
Normal file
36
effects_plugins/noise.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||||
|
|
||||||
|
|
||||||
|
class NoiseEffect:
|
||||||
|
name = "noise"
|
||||||
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
if not ctx.ticker_height:
|
||||||
|
return buf
|
||||||
|
result = list(buf)
|
||||||
|
intensity = self.config.intensity
|
||||||
|
probability = intensity * 0.15
|
||||||
|
|
||||||
|
for r in range(len(result)):
|
||||||
|
cy = ctx.scroll_cam + r
|
||||||
|
if random.random() < probability:
|
||||||
|
result[r] = self._generate_noise(ctx.terminal_width, cy)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _generate_noise(self, w: int, cy: int) -> str:
|
||||||
|
d = random.choice([0.15, 0.25, 0.35, 0.12])
|
||||||
|
return "".join(
|
||||||
|
f"{random.choice([G_LO, G_DIM, C_DIM, W_GHOST])}"
|
||||||
|
f"{random.choice(config.GLITCH + config.KATA)}{RST}"
|
||||||
|
if random.random() < d
|
||||||
|
else " "
|
||||||
|
for _ in range(w)
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure(self, cfg: EffectConfig) -> None:
|
||||||
|
self.config = cfg
|
||||||
105
engine/app.py
105
engine/app.py
@@ -249,6 +249,110 @@ def pick_font_face():
|
|||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def pick_effects_config():
|
||||||
|
"""Interactive picker for configuring effects pipeline."""
|
||||||
|
import effects_plugins
|
||||||
|
from engine.effects import get_effect_chain, get_registry
|
||||||
|
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
chain = get_effect_chain()
|
||||||
|
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||||
|
|
||||||
|
effects = list(registry.list_all().values())
|
||||||
|
if not effects:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected = 0
|
||||||
|
editing_intensity = False
|
||||||
|
intensity_value = 1.0
|
||||||
|
|
||||||
|
def _draw_effects_picker():
|
||||||
|
w = tw()
|
||||||
|
print(CLR, end="")
|
||||||
|
print("\033[1;1H", end="")
|
||||||
|
print(" \033[1;38;5;231mEFFECTS CONFIG\033[0m")
|
||||||
|
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for i, effect in enumerate(effects):
|
||||||
|
prefix = " > " if i == selected else " "
|
||||||
|
marker = "[*]" if effect.config.enabled else "[ ]"
|
||||||
|
if editing_intensity and i == selected:
|
||||||
|
print(
|
||||||
|
f"{prefix}{marker} \033[1;38;5;82m{effect.name}\033[0m: intensity={intensity_value:.2f} (use +/- to adjust, Enter to confirm)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"{prefix}{marker} {effect.name}: intensity={effect.config.intensity:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" \033[2;38;5;37m{'─' * (w - 4)}\033[0m")
|
||||||
|
print(
|
||||||
|
" \033[38;5;245mControls: space=toggle on/off | +/-=adjust intensity | arrows=move | Enter=next effect | q=done\033[0m"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_effects_key():
|
||||||
|
ch = sys.stdin.read(1)
|
||||||
|
if ch == "\x03":
|
||||||
|
return "interrupt"
|
||||||
|
if ch in ("\r", "\n"):
|
||||||
|
return "enter"
|
||||||
|
if ch == " ":
|
||||||
|
return "toggle"
|
||||||
|
if ch == "q":
|
||||||
|
return "quit"
|
||||||
|
if ch == "+" or ch == "=":
|
||||||
|
return "up"
|
||||||
|
if ch == "-" or ch == "_":
|
||||||
|
return "down"
|
||||||
|
if ch == "\x1b":
|
||||||
|
c1 = sys.stdin.read(1)
|
||||||
|
if c1 != "[":
|
||||||
|
return None
|
||||||
|
c2 = sys.stdin.read(1)
|
||||||
|
if c2 == "A":
|
||||||
|
return "up"
|
||||||
|
if c2 == "B":
|
||||||
|
return "down"
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not sys.stdin.isatty():
|
||||||
|
return
|
||||||
|
|
||||||
|
fd = sys.stdin.fileno()
|
||||||
|
old_settings = termios.tcgetattr(fd)
|
||||||
|
try:
|
||||||
|
tty.setcbreak(fd)
|
||||||
|
while True:
|
||||||
|
_draw_effects_picker()
|
||||||
|
key = _read_effects_key()
|
||||||
|
|
||||||
|
if key == "quit" or key == "enter":
|
||||||
|
break
|
||||||
|
elif key == "up" and editing_intensity:
|
||||||
|
intensity_value = min(1.0, intensity_value + 0.1)
|
||||||
|
effects[selected].config.intensity = intensity_value
|
||||||
|
elif key == "down" and editing_intensity:
|
||||||
|
intensity_value = max(0.0, intensity_value - 0.1)
|
||||||
|
effects[selected].config.intensity = intensity_value
|
||||||
|
elif key == "up":
|
||||||
|
selected = max(0, selected - 1)
|
||||||
|
intensity_value = effects[selected].config.intensity
|
||||||
|
elif key == "down":
|
||||||
|
selected = min(len(effects) - 1, selected + 1)
|
||||||
|
intensity_value = effects[selected].config.intensity
|
||||||
|
elif key == "toggle":
|
||||||
|
effects[selected].config.enabled = not effects[selected].config.enabled
|
||||||
|
elif key == "interrupt":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
finally:
|
||||||
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||||
|
|
||||||
@@ -263,6 +367,7 @@ def main():
|
|||||||
print(CLR, end="")
|
print(CLR, end="")
|
||||||
print(CURSOR_OFF, end="")
|
print(CURSOR_OFF, end="")
|
||||||
pick_font_face()
|
pick_font_face()
|
||||||
|
pick_effects_config()
|
||||||
w = tw()
|
w = tw()
|
||||||
print()
|
print()
|
||||||
time.sleep(0.4)
|
time.sleep(0.4)
|
||||||
|
|||||||
38
engine/effects/__init__.py
Normal file
38
engine/effects/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from engine.effects.chain import EffectChain
|
||||||
|
from engine.effects.controller import handle_effects_command, show_effects_menu
|
||||||
|
from engine.effects.legacy import (
|
||||||
|
fade_line,
|
||||||
|
firehose_line,
|
||||||
|
glitch_bar,
|
||||||
|
next_headline,
|
||||||
|
noise,
|
||||||
|
vis_trunc,
|
||||||
|
)
|
||||||
|
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, PipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
def get_effect_chain():
|
||||||
|
from engine.layers import get_effect_chain as _chain
|
||||||
|
|
||||||
|
return _chain()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EffectChain",
|
||||||
|
"EffectRegistry",
|
||||||
|
"EffectConfig",
|
||||||
|
"EffectContext",
|
||||||
|
"PipelineConfig",
|
||||||
|
"get_registry",
|
||||||
|
"set_registry",
|
||||||
|
"get_effect_chain",
|
||||||
|
"handle_effects_command",
|
||||||
|
"show_effects_menu",
|
||||||
|
"fade_line",
|
||||||
|
"firehose_line",
|
||||||
|
"glitch_bar",
|
||||||
|
"noise",
|
||||||
|
"next_headline",
|
||||||
|
"vis_trunc",
|
||||||
|
]
|
||||||
47
engine/effects/chain.py
Normal file
47
engine/effects/chain.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from engine.effects.registry import EffectRegistry
|
||||||
|
from engine.effects.types import EffectContext
|
||||||
|
|
||||||
|
|
||||||
|
class EffectChain:
|
||||||
|
def __init__(self, registry: EffectRegistry):
|
||||||
|
self._registry = registry
|
||||||
|
self._order: list[str] = []
|
||||||
|
|
||||||
|
def set_order(self, names: list[str]) -> None:
|
||||||
|
self._order = list(names)
|
||||||
|
|
||||||
|
def get_order(self) -> list[str]:
|
||||||
|
return self._order.copy()
|
||||||
|
|
||||||
|
def add_effect(self, name: str, position: int | None = None) -> bool:
|
||||||
|
if name not in self._registry.list_all():
|
||||||
|
return False
|
||||||
|
if position is None:
|
||||||
|
self._order.append(name)
|
||||||
|
else:
|
||||||
|
self._order.insert(position, name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def remove_effect(self, name: str) -> bool:
|
||||||
|
if name in self._order:
|
||||||
|
self._order.remove(name)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def reorder(self, new_order: list[str]) -> bool:
|
||||||
|
all_plugins = set(self._registry.list_all().keys())
|
||||||
|
if not all(name in all_plugins for name in new_order):
|
||||||
|
return False
|
||||||
|
self._order = list(new_order)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
result = list(buf)
|
||||||
|
for name in self._order:
|
||||||
|
plugin = self._registry.get(name)
|
||||||
|
if plugin and plugin.config.enabled:
|
||||||
|
try:
|
||||||
|
result = plugin.process(result, ctx)
|
||||||
|
except Exception:
|
||||||
|
plugin.config.enabled = False
|
||||||
|
return result
|
||||||
102
engine/effects/controller.py
Normal file
102
engine/effects/controller.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from engine.effects.registry import get_registry
|
||||||
|
|
||||||
|
|
||||||
|
def _get_effect_chain():
|
||||||
|
from engine.layers import get_effect_chain as _chain
|
||||||
|
|
||||||
|
return _chain()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_effects_command(cmd: str) -> str:
|
||||||
|
"""Handle /effects command from NTFY message.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
/effects list - list all effects and their status
|
||||||
|
/effects <name> on - enable an effect
|
||||||
|
/effects <name> off - disable an effect
|
||||||
|
/effects <name> intensity <0.0-1.0> - set intensity
|
||||||
|
/effects reorder <name1>,<name2>,... - reorder pipeline
|
||||||
|
"""
|
||||||
|
parts = cmd.strip().split()
|
||||||
|
if not parts or parts[0] != "/effects":
|
||||||
|
return "Unknown command"
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
chain = _get_effect_chain()
|
||||||
|
|
||||||
|
if len(parts) == 1 or parts[1] == "list":
|
||||||
|
result = ["Effects:"]
|
||||||
|
for name, plugin in registry.list_all().items():
|
||||||
|
status = "ON" if plugin.config.enabled else "OFF"
|
||||||
|
intensity = plugin.config.intensity
|
||||||
|
result.append(f" {name}: {status} (intensity={intensity})")
|
||||||
|
if chain:
|
||||||
|
result.append(f"Order: {chain.get_order()}")
|
||||||
|
return "\n".join(result)
|
||||||
|
|
||||||
|
if len(parts) < 3:
|
||||||
|
return "Usage: /effects <name> on|off|intensity <value>"
|
||||||
|
|
||||||
|
effect_name = parts[1]
|
||||||
|
action = parts[2]
|
||||||
|
|
||||||
|
if effect_name not in registry.list_all():
|
||||||
|
return f"Unknown effect: {effect_name}"
|
||||||
|
|
||||||
|
if action == "on":
|
||||||
|
registry.enable(effect_name)
|
||||||
|
return f"Enabled: {effect_name}"
|
||||||
|
|
||||||
|
if action == "off":
|
||||||
|
registry.disable(effect_name)
|
||||||
|
return f"Disabled: {effect_name}"
|
||||||
|
|
||||||
|
if action == "intensity" and len(parts) >= 4:
|
||||||
|
try:
|
||||||
|
value = float(parts[3])
|
||||||
|
if not 0.0 <= value <= 1.0:
|
||||||
|
return "Intensity must be between 0.0 and 1.0"
|
||||||
|
plugin = registry.get(effect_name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.intensity = value
|
||||||
|
return f"Set {effect_name} intensity to {value}"
|
||||||
|
except ValueError:
|
||||||
|
return "Invalid intensity value"
|
||||||
|
|
||||||
|
if action == "reorder" and len(parts) >= 3:
|
||||||
|
new_order = parts[2].split(",")
|
||||||
|
if chain and chain.reorder(new_order):
|
||||||
|
return f"Reordered pipeline: {new_order}"
|
||||||
|
return "Failed to reorder pipeline"
|
||||||
|
|
||||||
|
return f"Unknown action: {action}"
|
||||||
|
|
||||||
|
|
||||||
|
def show_effects_menu() -> str:
|
||||||
|
"""Generate effects menu text for display."""
|
||||||
|
registry = get_registry()
|
||||||
|
chain = _get_effect_chain()
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"\033[1;38;5;231m=== EFFECTS MENU ===\033[0m",
|
||||||
|
"",
|
||||||
|
"Effects:",
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, plugin in registry.list_all().items():
|
||||||
|
status = "ON" if plugin.config.enabled else "OFF"
|
||||||
|
intensity = plugin.config.intensity
|
||||||
|
lines.append(f" [{status:3}] {name}: intensity={intensity:.2f}")
|
||||||
|
|
||||||
|
if chain:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"Pipeline order: {' -> '.join(chain.get_order())}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Controls:")
|
||||||
|
lines.append(" /effects <name> on|off")
|
||||||
|
lines.append(" /effects <name> intensity <0.0-1.0>")
|
||||||
|
lines.append(" /effects reorder name1,name2,...")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
59
engine/effects/registry.py
Normal file
59
engine/effects/registry.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from engine.effects.types import EffectConfig, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class EffectRegistry:
|
||||||
|
def __init__(self):
|
||||||
|
self._plugins: dict[str, EffectPlugin] = {}
|
||||||
|
self._discovered: bool = False
|
||||||
|
|
||||||
|
def register(self, plugin: EffectPlugin) -> None:
|
||||||
|
self._plugins[plugin.name] = plugin
|
||||||
|
|
||||||
|
def get(self, name: str) -> EffectPlugin | None:
|
||||||
|
return self._plugins.get(name)
|
||||||
|
|
||||||
|
def list_all(self) -> dict[str, EffectPlugin]:
|
||||||
|
return self._plugins.copy()
|
||||||
|
|
||||||
|
def list_enabled(self) -> list[EffectPlugin]:
|
||||||
|
return [p for p in self._plugins.values() if p.config.enabled]
|
||||||
|
|
||||||
|
def enable(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.enabled = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disable(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.config.enabled = False
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def configure(self, name: str, config: EffectConfig) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
if plugin:
|
||||||
|
plugin.configure(config)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_enabled(self, name: str) -> bool:
|
||||||
|
plugin = self._plugins.get(name)
|
||||||
|
return plugin.config.enabled if plugin else False
|
||||||
|
|
||||||
|
|
||||||
|
_registry: EffectRegistry | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry() -> EffectRegistry:
|
||||||
|
global _registry
|
||||||
|
if _registry is None:
|
||||||
|
_registry = EffectRegistry()
|
||||||
|
return _registry
|
||||||
|
|
||||||
|
|
||||||
|
def set_registry(registry: EffectRegistry) -> None:
|
||||||
|
global _registry
|
||||||
|
_registry = registry
|
||||||
39
engine/effects/types.py
Normal file
39
engine/effects/types.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectContext:
|
||||||
|
terminal_width: int
|
||||||
|
terminal_height: int
|
||||||
|
scroll_cam: int
|
||||||
|
ticker_height: int
|
||||||
|
mic_excess: float
|
||||||
|
grad_offset: float
|
||||||
|
frame_number: int
|
||||||
|
has_message: bool
|
||||||
|
items: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectConfig:
|
||||||
|
enabled: bool = True
|
||||||
|
intensity: float = 1.0
|
||||||
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPlugin:
|
||||||
|
name: str
|
||||||
|
config: EffectConfig
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineConfig:
|
||||||
|
order: list[str] = field(default_factory=list)
|
||||||
|
effects: dict[str, EffectConfig] = field(default_factory=dict)
|
||||||
@@ -10,6 +10,8 @@ from datetime import datetime
|
|||||||
|
|
||||||
from engine import config
|
from engine import config
|
||||||
from engine.effects import (
|
from engine.effects import (
|
||||||
|
EffectChain,
|
||||||
|
EffectContext,
|
||||||
fade_line,
|
fade_line,
|
||||||
firehose_line,
|
firehose_line,
|
||||||
glitch_bar,
|
glitch_bar,
|
||||||
@@ -199,3 +201,60 @@ def render_firehose(items: list, w: int, fh: int, h: int) -> list[str]:
|
|||||||
fline = firehose_line(items, w)
|
fline = firehose_line(items, w)
|
||||||
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||||
return buf
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
_effect_chain = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_effects() -> None:
|
||||||
|
"""Initialize effect plugins and chain."""
|
||||||
|
global _effect_chain
|
||||||
|
from engine.effects import EffectChain, get_registry
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
import effects_plugins
|
||||||
|
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||||
|
_effect_chain = chain
|
||||||
|
|
||||||
|
|
||||||
|
def process_effects(
|
||||||
|
buf: list[str],
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
scroll_cam: int,
|
||||||
|
ticker_h: int,
|
||||||
|
mic_excess: float,
|
||||||
|
grad_offset: float,
|
||||||
|
frame_number: int,
|
||||||
|
has_message: bool,
|
||||||
|
items: list,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Process buffer through effect chain."""
|
||||||
|
if _effect_chain is None:
|
||||||
|
init_effects()
|
||||||
|
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=w,
|
||||||
|
terminal_height=h,
|
||||||
|
scroll_cam=scroll_cam,
|
||||||
|
ticker_height=ticker_h,
|
||||||
|
mic_excess=mic_excess,
|
||||||
|
grad_offset=grad_offset,
|
||||||
|
frame_number=frame_number,
|
||||||
|
has_message=has_message,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
return _effect_chain.process(buf, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def get_effect_chain() -> EffectChain | None:
|
||||||
|
"""Get the effect chain instance."""
|
||||||
|
global _effect_chain
|
||||||
|
if _effect_chain is None:
|
||||||
|
init_effects()
|
||||||
|
return _effect_chain
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from engine import config
|
|||||||
from engine.frame import calculate_scroll_step
|
from engine.frame import calculate_scroll_step
|
||||||
from engine.layers import (
|
from engine.layers import (
|
||||||
apply_glitch,
|
apply_glitch,
|
||||||
|
process_effects,
|
||||||
render_firehose,
|
render_firehose,
|
||||||
render_message_overlay,
|
render_message_overlay,
|
||||||
render_ticker_zone,
|
render_ticker_zone,
|
||||||
@@ -18,6 +19,8 @@ from engine.layers import (
|
|||||||
from engine.terminal import CLR
|
from engine.terminal import CLR
|
||||||
from engine.viewport import th, tw
|
from engine.viewport import th, tw
|
||||||
|
|
||||||
|
USE_EFFECT_CHAIN = True
|
||||||
|
|
||||||
|
|
||||||
def stream(items, ntfy_poller, mic_monitor):
|
def stream(items, ntfy_poller, mic_monitor):
|
||||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||||
@@ -42,6 +45,7 @@ def stream(items, ntfy_poller, mic_monitor):
|
|||||||
noise_cache = {}
|
noise_cache = {}
|
||||||
scroll_motion_accum = 0.0
|
scroll_motion_accum = 0.0
|
||||||
msg_cache = (None, None)
|
msg_cache = (None, None)
|
||||||
|
frame_number = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if queued >= config.HEADLINE_LIMIT and not active:
|
if queued >= config.HEADLINE_LIMIT and not active:
|
||||||
@@ -93,10 +97,24 @@ def stream(items, ntfy_poller, mic_monitor):
|
|||||||
buf.extend(ticker_buf)
|
buf.extend(ticker_buf)
|
||||||
|
|
||||||
mic_excess = mic_monitor.excess
|
mic_excess = mic_monitor.excess
|
||||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
|
||||||
|
|
||||||
firehose_buf = render_firehose(items, w, fh, h)
|
if USE_EFFECT_CHAIN:
|
||||||
buf.extend(firehose_buf)
|
buf = process_effects(
|
||||||
|
buf,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
scroll_cam,
|
||||||
|
ticker_h,
|
||||||
|
mic_excess,
|
||||||
|
grad_offset,
|
||||||
|
frame_number,
|
||||||
|
msg is not None,
|
||||||
|
items,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||||
|
firehose_buf = render_firehose(items, w, fh, h)
|
||||||
|
buf.extend(firehose_buf)
|
||||||
|
|
||||||
if msg_overlay:
|
if msg_overlay:
|
||||||
buf.extend(msg_overlay)
|
buf.extend(msg_overlay)
|
||||||
@@ -106,6 +124,7 @@ def stream(items, ntfy_poller, mic_monitor):
|
|||||||
|
|
||||||
elapsed = time.monotonic() - t0
|
elapsed = time.monotonic() - t0
|
||||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||||
|
frame_number += 1
|
||||||
|
|
||||||
sys.stdout.write(CLR)
|
sys.stdout.write(CLR)
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|||||||
284
tests/test_effects.py
Normal file
284
tests/test_effects.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.effects module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.effects import EffectChain, EffectConfig, EffectContext, EffectRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class MockEffect:
|
||||||
|
name = "mock"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.processed = False
|
||||||
|
self.last_ctx = None
|
||||||
|
|
||||||
|
def process(self, buf, ctx):
|
||||||
|
self.processed = True
|
||||||
|
self.last_ctx = ctx
|
||||||
|
return buf + ["processed"]
|
||||||
|
|
||||||
|
def configure(self, config):
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectConfig:
|
||||||
|
def test_defaults(self):
|
||||||
|
cfg = EffectConfig()
|
||||||
|
assert cfg.enabled is True
|
||||||
|
assert cfg.intensity == 1.0
|
||||||
|
assert cfg.params == {}
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
cfg = EffectConfig(enabled=False, intensity=0.5, params={"key": "value"})
|
||||||
|
assert cfg.enabled is False
|
||||||
|
assert cfg.intensity == 0.5
|
||||||
|
assert cfg.params == {"key": "value"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectContext:
|
||||||
|
def test_defaults(self):
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
assert ctx.terminal_width == 80
|
||||||
|
assert ctx.terminal_height == 24
|
||||||
|
assert ctx.ticker_height == 20
|
||||||
|
assert ctx.items == []
|
||||||
|
|
||||||
|
def test_with_items(self):
|
||||||
|
items = [("Title", "Source", "12:00")]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
assert ctx.items == items
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectRegistry:
|
||||||
|
def test_init_empty(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
assert len(registry.list_all()) == 0
|
||||||
|
|
||||||
|
def test_register(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
assert "mock" in registry.list_all()
|
||||||
|
|
||||||
|
def test_get(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
retrieved = registry.get("mock")
|
||||||
|
assert retrieved is effect
|
||||||
|
|
||||||
|
def test_get_nonexistent(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
assert registry.get("nonexistent") is None
|
||||||
|
|
||||||
|
def test_enable(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = False
|
||||||
|
registry.register(effect)
|
||||||
|
registry.enable("mock")
|
||||||
|
assert effect.config.enabled is True
|
||||||
|
|
||||||
|
def test_disable(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = True
|
||||||
|
registry.register(effect)
|
||||||
|
registry.disable("mock")
|
||||||
|
assert effect.config.enabled is False
|
||||||
|
|
||||||
|
def test_list_enabled(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
|
||||||
|
class EnabledEffect:
|
||||||
|
name = "enabled_effect"
|
||||||
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
|
class DisabledEffect:
|
||||||
|
name = "disabled_effect"
|
||||||
|
config = EffectConfig(enabled=False, intensity=1.0)
|
||||||
|
|
||||||
|
registry.register(EnabledEffect())
|
||||||
|
registry.register(DisabledEffect())
|
||||||
|
enabled = registry.list_enabled()
|
||||||
|
assert len(enabled) == 1
|
||||||
|
assert enabled[0].name == "enabled_effect"
|
||||||
|
|
||||||
|
def test_configure(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
registry.register(effect)
|
||||||
|
new_config = EffectConfig(enabled=False, intensity=0.3)
|
||||||
|
registry.configure("mock", new_config)
|
||||||
|
assert effect.config.enabled is False
|
||||||
|
assert effect.config.intensity == 0.3
|
||||||
|
|
||||||
|
def test_is_enabled(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.config.enabled = True
|
||||||
|
registry.register(effect)
|
||||||
|
assert registry.is_enabled("mock") is True
|
||||||
|
assert registry.is_enabled("nonexistent") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectChain:
|
||||||
|
def test_init(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
assert chain.get_order() == []
|
||||||
|
|
||||||
|
def test_set_order(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect1 = MockEffect()
|
||||||
|
effect1.name = "effect1"
|
||||||
|
effect2 = MockEffect()
|
||||||
|
effect2.name = "effect2"
|
||||||
|
registry.register(effect1)
|
||||||
|
registry.register(effect2)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["effect1", "effect2"])
|
||||||
|
assert chain.get_order() == ["effect1", "effect2"]
|
||||||
|
|
||||||
|
def test_add_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.add_effect("test_effect")
|
||||||
|
assert "test_effect" in chain.get_order()
|
||||||
|
|
||||||
|
def test_add_effect_invalid(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
result = chain.add_effect("nonexistent")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_remove_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
chain.remove_effect("test_effect")
|
||||||
|
assert "test_effect" not in chain.get_order()
|
||||||
|
|
||||||
|
def test_reorder(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect1 = MockEffect()
|
||||||
|
effect1.name = "effect1"
|
||||||
|
effect2 = MockEffect()
|
||||||
|
effect2.name = "effect2"
|
||||||
|
effect3 = MockEffect()
|
||||||
|
effect3.name = "effect3"
|
||||||
|
registry.register(effect1)
|
||||||
|
registry.register(effect2)
|
||||||
|
registry.register(effect3)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["effect1", "effect2", "effect3"])
|
||||||
|
result = chain.reorder(["effect3", "effect1", "effect2"])
|
||||||
|
assert result is True
|
||||||
|
assert chain.get_order() == ["effect3", "effect1", "effect2"]
|
||||||
|
|
||||||
|
def test_reorder_invalid(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "effect1"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
result = chain.reorder(["effect1", "nonexistent"])
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_process_empty_chain(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
buf = ["line1", "line2"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == buf
|
||||||
|
|
||||||
|
def test_process_with_effects(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
buf = ["line1", "line2"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == ["line1", "line2", "processed"]
|
||||||
|
assert effect.processed is True
|
||||||
|
assert effect.last_ctx is ctx
|
||||||
|
|
||||||
|
def test_process_disabled_effect(self):
|
||||||
|
registry = EffectRegistry()
|
||||||
|
effect = MockEffect()
|
||||||
|
effect.name = "test_effect"
|
||||||
|
effect.config.enabled = False
|
||||||
|
registry.register(effect)
|
||||||
|
chain = EffectChain(registry)
|
||||||
|
chain.set_order(["test_effect"])
|
||||||
|
buf = ["line1"]
|
||||||
|
ctx = EffectContext(
|
||||||
|
terminal_width=80,
|
||||||
|
terminal_height=24,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=20,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=0.0,
|
||||||
|
frame_number=0,
|
||||||
|
has_message=False,
|
||||||
|
)
|
||||||
|
result = chain.process(buf, ctx)
|
||||||
|
assert result == ["line1"]
|
||||||
|
assert effect.processed is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestEffectsExports:
|
||||||
|
def test_all_exports_are_importable(self):
|
||||||
|
"""Verify all exports in __all__ can actually be imported."""
|
||||||
|
import engine.effects as effects_module
|
||||||
|
|
||||||
|
for name in effects_module.__all__:
|
||||||
|
getattr(effects_module, name)
|
||||||
Reference in New Issue
Block a user