17 Commits

Author SHA1 Message Date
f40651b34d feat: Enable and configure figment mode via new CLI flags, update documentation, and improve Cairo library detection on macOS. 2026-03-19 13:11:24 -07:00
4bd4fe5aac docs: add table of contents to README 2026-03-19 11:44:00 -07:00
ca1f2718f2 docs: update README for figment mode
- Add figment mode to intro paragraph
- New Figment Mode section: enabling, assets, trigger protocol, deps
- Architecture table updated with engine/figment_render.py,
  figment_trigger.py, and effects_plugins/ directory breakdown
- Dev setup: separate uv sync --extras entries (mic vs figment)
- Testing section: mentions figment test coverage and Cairo skip
- Roadmap: adds figment follow-up items (CLI flag, intensity wiring,
  ntfy trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 11:34:18 -07:00
7a039cdfe0 test: add pytest.importorskip for cairosvg-dependent tests
Gracefully skips figment tests when system Cairo library is unavailable
instead of crashing with opaque OSError during test collection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:23:35 -07:00
733b4dd9ec style: apply ruff lint fixes and formatting to figment modules
Fixes: unused imports, import sorting, unused variable, overly broad
exception type in test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:09:46 -07:00
14ed582454 feat(figment): integrate figment overlay into scroll loop
Wire render_figment_overlay() into stream() between the effects chain
and the ntfy message overlay, with optional cairosvg import guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 10:49:59 -07:00
550eca2b14 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>
2026-03-19 08:43:48 -07:00
5f6cd295e1 feat(figment): add FigmentEffect plugin with state machine and timer
Implements the core figment plugin: timer-driven SVG selection, REVEAL →
HOLD → DISSOLVE state machine, trigger API, and get_figment_state() for
overlay rendering. process() is a deliberate no-op; scroll.py will call
get_figment_state() instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 01:10:05 -07:00
706ee45e27 feat(figment): add SVG to half-block rasterization pipeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 01:03:39 -07:00
202dd6be9c feat(figment): add trigger protocol and command types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 00:59:29 -07:00
451785df04 feat(figment): add test fixture SVG and FIGMENT_TRIGGER event type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 00:55:48 -07:00
473d12c756 build: add cairosvg optional dependency for figment mode
Also adds DYLD_LIBRARY_PATH to mise.toml for macOS Cairo discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:54:54 -07:00
5c038b1e2f Merge branch 'main' into feat/figment 2026-03-19 00:51:45 -07:00
dde5d77f43 docs: add figment mode TDD implementation plan
8-task plan covering SVG rasterization, overlay rendering,
FigmentEffect plugin, trigger protocol, and scroll loop integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:50:12 -07:00
ad45347a75 docs: address spec review feedback for figment mode
- Rename FigmentPlugin to FigmentEffect (discovery convention)
- Define FigmentState dataclass and FigmentPhase enum
- Clarify chain exclusion (no-op process, not in chain order)
- Add isinstance() downcast for type-safe scroll.py access
- Use FigmentAction enum instead of string literals
- Add Error Handling section (missing deps, empty dir, resize)
- Add Goals, Out of Scope sections
- Split tests per module (test_figment_render, test_figment_trigger)
- Add FIGMENT_TRIGGER to modified files (events.py)
- Document timing formula (progress += FRAME_DT / phase_duration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:36:19 -07:00
064eaaee3d docs: add figment mode design spec
Hybrid plugin + overlay architecture for periodic SVG glyph display
with theme-aware coloring and extensible input abstraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:32:52 -07:00
0819f8d160 Merge pull request 'refactor: Make EffectPlugin an abstract base class, update effects to inherit from it, and improve plugin discovery.' (#32) from chore/plugin-migration into main
Reviewed-on: #32
2026-03-19 06:15:41 +00:00
22 changed files with 2514 additions and 28 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ htmlcov/
.coverage
.pytest_cache/
*.egg-info/
.DS_Store

136
README.md
View File

@@ -2,7 +2,37 @@
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.*
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh).
A full-screen terminal news ticker that renders live global headlines in large OTF-font block characters with selectable color gradients (Verdant Green, Molten Orange, or Violet Purple). Headlines auto-translate into the native script of their subject region. Ambient mic input warps the glitch rate in real time. A `--poetry` mode replaces the feed with public-domain literary passages. Live messages can be pushed to the display over [ntfy.sh](https://ntfy.sh). **Figment mode** overlays flickery, theme-colored SVG glyphs on the running stream at timed intervals — controllable from any input source via an extensible trigger protocol.
---
## Contents
- [Using](#using)
- [Run](#run)
- [Config](#config)
- [Feeds](#feeds)
- [Fonts](#fonts)
- [Color Schemes](#color-schemes)
- [ntfy.sh](#ntfysh)
- [Figment Mode](#figment-mode)
- [Internals](#internals)
- [How it works](#how-it-works)
- [Architecture](#architecture)
- [Extending](#extending)
- [NtfyPoller](#ntfypoller)
- [MicMonitor](#micmonitor)
- [Render pipeline](#render-pipeline)
- [Development](#development)
- [Setup](#setup)
- [Tasks](#tasks)
- [Testing](#testing)
- [Linting](#linting)
- [Roadmap](#roadmap)
- [Performance](#performance)
- [Graphics](#graphics)
- [Cyberpunk Vibes](#cyberpunk-vibes)
- [Extensibility](#extensibility)
---
@@ -15,6 +45,8 @@ python3 mainline.py # news stream
python3 mainline.py --poetry # literary consciousness mode
python3 mainline.py -p # same
python3 mainline.py --firehose # dense rapid-fire headline mode
python3 mainline.py --figment # enable periodic SVG glyph overlays
python3 mainline.py --figment-interval 30 # figment every 30 seconds (default: 60)
python3 mainline.py --refresh # force re-fetch (bypass cache)
python3 mainline.py --no-font-picker # skip interactive font picker
python3 mainline.py --font-file path.otf # use a specific font file
@@ -50,6 +82,7 @@ All constants live in `engine/config.py`:
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
@@ -95,6 +128,39 @@ curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
### Figment Mode
Figment mode periodically overlays a full-screen SVG glyph on the running ticker — flickering through a reveal → hold (strobe) → dissolve cycle, colored with a randomly selected theme gradient.
**Enable it** with the `--figment` flag:
```bash
uv run mainline.py --figment # glyph every 60 seconds (default)
uv run mainline.py --figment --figment-interval 30 # every 30 seconds
```
**Figment assets** live in `figments/` — drop any `.svg` file there and it will be picked up automatically. The bundled set contains Mayan and Aztec glyphs. Figments are selected randomly, avoiding immediate repeats, and rasterized into half-block terminal art at display time.
**Triggering manually** — any object with a `poll() -> FigmentCommand | None` method satisfies the `FigmentTrigger` protocol and can be passed to the plugin:
```python
from engine.figment_trigger import FigmentAction, FigmentCommand
class MyTrigger:
def poll(self):
if some_condition:
return FigmentCommand(action=FigmentAction.TRIGGER)
return None
```
Built-in commands: `TRIGGER`, `SET_INTENSITY`, `SET_INTERVAL`, `SET_COLOR`, `STOP`.
**System dependency:** Figment mode requires the Cairo C library (`brew install cairo` on macOS) in addition to the `figment` extras group:
```bash
uv sync --extra figment # adds cairosvg
```
---
## Internals
@@ -109,6 +175,7 @@ Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
- Figment mode rasterizes SVGs via cairosvg → PIL → greyscale → half-block encode, then overlays them with ANSI cursor-positioning commands between the effect chain and the ntfy message layer
### Architecture
@@ -116,27 +183,40 @@ Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
```
engine/
__init__.py package marker
app.py main(), font picker TUI, boot sequence, signal handler
config.py constants, CLI flags, glyph tables
sources.py FEEDS, POETRY_SOURCES, language/script maps
terminal.py ANSI codes, tw/th, type_out, boot_ln
filter.py HTML stripping, content filter
translate.py Google Translate wrapper + region detection
render.py OTF → half-block pipeline (SSAA, gradient)
effects.py noise, glitch_bar, fade, firehose
fetch.py RSS/Gutenberg fetching + cache load/save
ntfy.py NtfyPoller — standalone, zero internal deps
mic.py MicMonitor — standalone, graceful fallback
scroll.py stream() frame loop + message rendering
viewport.py terminal dimension tracking (tw/th)
frame.py scroll step calculation, timing
layers.py ticker zone, firehose, message overlay rendering
eventbus.py thread-safe event publishing for decoupled communication
events.py event types and definitions
controller.py coordinates ntfy/mic monitoring and event publishing
emitters.py background emitters for ntfy and mic
types.py type definitions and dataclasses
__init__.py package marker
app.py main(), font picker TUI, boot sequence, signal handler
config.py constants, CLI flags, glyph tables
sources.py FEEDS, POETRY_SOURCES, language/script maps
terminal.py ANSI codes, tw/th, type_out, boot_ln
filter.py HTML stripping, content filter
translate.py Google Translate wrapper + region detection
render.py OTF → half-block pipeline (SSAA, gradient)
effects.py noise, glitch_bar, fade, firehose
fetch.py RSS/Gutenberg fetching + cache load/save
ntfy.py NtfyPoller — standalone, zero internal deps
mic.py MicMonitor — standalone, graceful fallback
scroll.py stream() frame loop + message rendering
viewport.py terminal dimension tracking (tw/th)
frame.py scroll step calculation, timing
layers.py ticker zone, firehose, message + figment overlay rendering
figment_render.py SVG → cairosvg → PIL → half-block rasterizer with cache
figment_trigger.py FigmentTrigger protocol, FigmentAction enum, FigmentCommand
eventbus.py thread-safe event publishing for decoupled communication
events.py event types and definitions
controller.py coordinates ntfy/mic monitoring and event publishing
emitters.py background emitters for ntfy and mic
types.py type definitions and dataclasses
themes.py THEME_REGISTRY — gradient color definitions
effects_plugins/
__init__.py plugin discovery (ABC issubclass scan)
noise.py NoiseEffect — random character noise
glitch.py GlitchEffect — horizontal glitch bars
fade.py FadeEffect — edge fade zones
firehose.py FirehoseEffect — dense bottom ticker strip
figment.py FigmentEffect — periodic SVG glyph overlay (state machine)
figments/ SVG assets for figment mode
```
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
@@ -204,11 +284,15 @@ See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server +
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
```bash
uv sync # minimal (no mic)
uv sync --all-extras # with mic support (sounddevice + numpy)
uv sync # minimal (no mic, no figment)
uv sync --extras mic # with mic support (sounddevice + numpy)
uv sync --extra figment # with figment mode (cairosvg + system Cairo)
uv sync --all-extras # all optional features
uv sync --all-extras --group dev # full dev environment
```
Figment mode also requires the Cairo C library: `brew install cairo` (macOS).
### Tasks
With [mise](https://mise.jdx.dev/):
@@ -226,7 +310,7 @@ mise run run-firehose # uv run mainline.py --firehose
### Testing
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, `terminal`, and the full figment pipeline (`figment_render`, `figment_trigger`, `figment`, `figment_overlay`). Figment tests are automatically skipped if Cairo is not installed.
```bash
uv run pytest
@@ -258,6 +342,8 @@ Pre-commit hooks run lint automatically via `hk`.
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
### Cyberpunk Vibes
- **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency
- **ntfy figment trigger** — built-in `NtfyFigmentTrigger` that listens on a dedicated topic to fire figments on demand
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
# Figment Mode Design Spec
> Periodic full-screen SVG glyph overlay with flickery animation, theme-aware coloring, and extensible physical device control.
## Overview
Figment mode displays a randomly selected SVG from the `figments/` directory as a flickery, glitchy half-block terminal overlay on top of the running ticker. It appears once per minute (configurable), holds for ~4.5 seconds with a three-phase animation (progressive reveal, strobing hold, dissolve), then fades back to the ticker. Colors are randomly chosen from the existing theme gradients.
The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time.
## Goals
- Display SVG figments as half-block terminal art overlaid on the running ticker
- Three-phase animation: progressive reveal, strobing hold, dissolve
- Random color from existing theme gradients (green, orange, purple)
- Configurable interval and duration via C&C
- Extensible input abstraction for physical device control (MQTT, serial, etc.)
## Out of Scope
- Multi-figment simultaneous display (one at a time)
- SVG animation support (static SVGs only; animation comes from the overlay phases)
- Custom color palettes beyond existing themes
- MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters)
## Architecture: Hybrid Plugin + Overlay
The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management.
**Important**: The plugin class is named `FigmentEffect` (not `FigmentPlugin`) to match the `*Effect` naming convention required by `discover_plugins()` in `effects_plugins/__init__.py`. The plugin is **not** added to the `EffectChain` order list — its `process()` is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in `scroll.py`, outside the chain.
### Component Diagram
```
+-------------------+
| FigmentTrigger | (Protocol)
| - NtfyTrigger | (v1)
| - MqttTrigger | (future)
| - SerialTrigger | (future)
+--------+----------+
|
| FigmentCommand
v
+------------------+ +-----------------+ +----------------------+
| figment_render |<---| FigmentEffect |--->| render_figment_ |
| .py | | (EffectPlugin) | | overlay() in |
| | | | | layers.py |
| SVG -> PIL -> | | Timer, state | | |
| half-block cache | | machine, SVG | | ANSI cursor-position |
| | | selection | | commands for overlay |
+------------------+ +-----------------+ +----------------------+
|
| get_figment_state()
v
+-------------------+
| scroll.py |
+-------------------+
```
## Section 1: SVG Rasterization
**File: `engine/figment_render.py`**
Reuses the same PIL-based half-block encoding that `engine/render.py` uses for OTF fonts.
### Pipeline
1. **Load**: `cairosvg.svg2png()` converts SVG to PNG bytes in memory (no temp files)
2. **Resize**: PIL scales to fit terminal — width = `tw()`, height = `th() * 2` pixels (each terminal row encodes 2 pixel rows via half-blocks)
3. **Threshold**: Convert to greyscale ("L" mode), apply binary threshold to get visible/not-visible
4. **Half-block encode**: Walk pixel pairs top-to-bottom. For each 2-row pair, emit `█` (both lit), `▀` (top only), `▄` (bottom only), or space (neither)
5. **Cache**: Results cached per `(svg_path, terminal_width, terminal_height)` — invalidated on terminal resize
### Dependency
`cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). If `cairosvg` is not installed, the `FigmentEffect` class will fail to import, and `discover_plugins()` will silently skip it (the existing `except Exception: pass` in discovery handles this). The plugin simply won't appear in the registry.
### Key Function
```python
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
"""Convert SVG file to list of half-block terminal rows (uncolored)."""
```
## Section 2: Figment Overlay Rendering
**Integration point: `engine/layers.py`**
New function following the `render_message_overlay()` pattern.
### FigmentState Dataclass
Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer:
```python
@dataclass
class FigmentState:
phase: FigmentPhase # enum: REVEAL, HOLD, DISSOLVE
progress: float # 0.0 to 1.0 within current phase
rows: list[str] # rasterized half-block rows (uncolored)
gradient: list[int] # 12-color ANSI 256 gradient from chosen theme
center_row: int # top row for centering in viewport
center_col: int # left column for centering in viewport
```
### Function Signature
```python
def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
"""Return ANSI cursor-positioning commands for the current figment frame."""
```
### Animation Phases (~4.5 seconds total)
Progress advances each frame as: `progress += config.FRAME_DT / phase_duration`. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete.
| Phase | Duration | Behavior |
|-------|----------|----------|
| **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed. |
| **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency. |
| **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed. |
### Color
A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Applied via `lr_gradient()` — the same function that colors headlines and messages.
### Positioning
Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works.
## Section 3: FigmentEffect (Effect Plugin)
**File: `effects_plugins/figment.py`**
An `EffectPlugin(ABC)` subclass named `FigmentEffect` to match the `*Effect` discovery convention.
### Chain Exclusion
`FigmentEffect` is registered in the `EffectRegistry` (for C&C access and config management) but is **not** added to the `EffectChain` order list. Its `process()` returns the buffer unchanged. The `enabled` flag is checked directly by `scroll.py` when deciding whether to call `get_figment_state()`, not by the chain.
### Responsibilities
- **Timer**: Tracks elapsed time via `config.FRAME_DT` accumulation. At the configured interval (default 60s), triggers a new figment.
- **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown.
- **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0).
- **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time.
- **Rasterization**: Calls `rasterize_svg()` on trigger, caches result for the display duration.
### State Machine
```
idle ──(timer fires or trigger received)──> reveal
reveal ──(progress >= 1.0)──> hold
hold ──(progress >= 1.0)──> dissolve
dissolve ──(progress >= 1.0)──> idle
```
### Interface
The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via:
```python
def get_figment_state(self, frame_number: int) -> FigmentState | None:
"""Tick the state machine and return current state, or None if idle."""
```
This mirrors the `ntfy_poller.get_active_message()` pattern.
### Scroll Loop Access
`scroll.py` imports `FigmentEffect` directly and uses `isinstance()` to safely downcast from the registry:
```python
from effects_plugins.figment import FigmentEffect
plugin = registry.get("figment")
figment = plugin if isinstance(plugin, FigmentEffect) else None
```
This is a one-time setup check, not per-frame. If `cairosvg` is missing, the import is wrapped in a try/except and `figment` stays `None`.
### EffectConfig
- `enabled`: bool (default `False` — opt-in)
- `intensity`: float — scales strobe frequency and reveal/dissolve speed
- `params`:
- `interval_secs`: 60 (time between figments)
- `display_secs`: 4.5 (total animation duration)
- `figment_dir`: "figments" (SVG source directory)
Controllable via C&C: `/effects figment on`, `/effects figment intensity 0.7`.
## Section 4: Input Abstraction (FigmentTrigger)
**File: `engine/figment_trigger.py`**
### Protocol
```python
class FigmentTrigger(Protocol):
def poll(self) -> FigmentCommand | None: ...
```
### FigmentCommand
```python
class FigmentAction(Enum):
TRIGGER = "trigger"
SET_INTENSITY = "set_intensity"
SET_INTERVAL = "set_interval"
SET_COLOR = "set_color"
STOP = "stop"
@dataclass
class FigmentCommand:
action: FigmentAction
value: float | str | None = None
```
Uses an enum for consistency with `EventType` in `engine/events.py`.
### Adapters
| Adapter | Transport | Dependency | Status |
|---------|-----------|------------|--------|
| `NtfyTrigger` | Existing C&C ntfy topic | None (reuses ntfy) | v1 |
| `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future |
| `SerialTrigger` | USB serial | `pyserial` (optional) | Future |
**NtfyTrigger v1**: Subscribes as a callback on the existing `NtfyPoller`. Parses messages with a `/figment` prefix (e.g., `/figment trigger`, `/figment intensity 0.8`). This is separate from the `/effects figment on` C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API.
### Integration
The `FigmentEffect` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone.
### EventBus Bridge
A new `FIGMENT_TRIGGER` variant is added to the `EventType` enum in `engine/events.py`, with a corresponding `FigmentTriggerEvent` dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync).
## Section 5: Scroll Loop Integration
Minimal change to `engine/scroll.py`:
```python
# In stream() setup (with safe import):
try:
from effects_plugins.figment import FigmentEffect
_plugin = registry.get("figment")
figment = _plugin if isinstance(_plugin, FigmentEffect) else None
except ImportError:
figment = None
# In frame loop, after effects processing, before ntfy message overlay:
if figment and figment.config.enabled:
figment_state = figment.get_figment_state(frame_number)
if figment_state is not None:
figment_overlay = render_figment_overlay(figment_state, w, h)
buf.extend(figment_overlay)
```
### Overlay Priority
Figment overlay appends **after** effects processing but **before** the ntfy message overlay. This means:
- Ntfy messages always appear on top of figments (higher priority)
- Existing glitch/noise effects run over the ticker underneath the figment
Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering.
## Section 6: Error Handling
| Scenario | Behavior |
|----------|----------|
| `cairosvg` not installed | `FigmentEffect` fails to import; `discover_plugins()` silently skips it; `scroll.py` import guard sets `figment = None` |
| `figments/` directory missing | Plugin logs warning at startup, stays in permanent `idle` state |
| `figments/` contains zero `.svg` files | Same as above: warning, permanent `idle` |
| Malformed SVG | `cairosvg` raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent `idle` with warning |
| Terminal resize during animation | Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update |
## Section 7: File Summary
### New Files
| File | Purpose |
|------|---------|
| `effects_plugins/figment.py` | FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase |
| `engine/figment_render.py` | SVG to half-block rasterization pipeline |
| `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter |
| `figments/` | SVG source directory (ships with sample SVGs) |
| `tests/test_figment.py` | FigmentEffect lifecycle, state machine transitions, timer |
| `tests/test_figment_render.py` | SVG rasterization, caching, edge cases |
| `tests/test_figment_trigger.py` | FigmentCommand parsing, NtfyTrigger adapter |
| `tests/fixtures/test.svg` | Minimal SVG for deterministic rasterization tests |
### Modified Files
| File | Change |
|------|--------|
| `engine/scroll.py` | Figment overlay integration (setup + per-frame block) |
| `engine/layers.py` | Add `render_figment_overlay()` function |
| `engine/events.py` | Add `FIGMENT_TRIGGER` to `EventType` enum, add `FigmentTriggerEvent` dataclass |
| `pyproject.toml` | Add `cairosvg` as optional dependency |
## Testing Strategy
- **Unit**: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage
- **Integration**: Plugin discovery (verify `FigmentEffect` is found by `discover_plugins()`), overlay rendering with mock terminal dimensions, C&C command handling via `/effects figment on`
- **Edge cases**: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation
- **Fixture**: Minimal `test.svg` (simple rectangle) for deterministic rasterization tests

200
effects_plugins/figment.py Normal file
View File

@@ -0,0 +1,200 @@
"""
Figment effect plugin — periodic SVG glyph overlay.
Owns the figment lifecycle: timer, SVG selection, state machine.
Delegates rendering to render_figment_overlay() in engine/layers.py.
Named FigmentEffect (not FigmentPlugin) to match the *Effect discovery
convention in effects_plugins/__init__.py.
NOT added to the EffectChain order — process() is a no-op. The overlay
rendering is handled by scroll.py calling get_figment_state().
"""
from __future__ import annotations
import random
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from engine import config
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
from engine.figment_render import rasterize_svg
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
from engine.themes import THEME_REGISTRY
class FigmentPhase(Enum):
REVEAL = auto()
HOLD = auto()
DISSOLVE = auto()
@dataclass
class FigmentState:
phase: FigmentPhase
progress: float
rows: list[str]
gradient: list[int]
center_row: int
center_col: int
class FigmentEffect(EffectPlugin):
name = "figment"
config = EffectConfig(
enabled=False,
intensity=1.0,
params={
"interval_secs": 60,
"display_secs": 4.5,
"figment_dir": "figments",
},
)
def __init__(
self,
figment_dir: str | None = None,
triggers: list[FigmentTrigger] | None = None,
):
self.config = EffectConfig(
enabled=False,
intensity=1.0,
params={
"interval_secs": 60,
"display_secs": 4.5,
"figment_dir": figment_dir or "figments",
},
)
self._triggers = triggers or []
self._phase: FigmentPhase | None = None
self._progress: float = 0.0
self._rows: list[str] = []
self._gradient: list[int] = []
self._center_row: int = 0
self._center_col: int = 0
self._timer: float = 0.0
self._last_svg: str | None = None
self._svg_files: list[str] = []
self._scan_svgs()
def _scan_svgs(self) -> None:
figment_dir = Path(self.config.params["figment_dir"])
if figment_dir.is_dir():
self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
return buf
def configure(self, cfg: EffectConfig) -> None:
# Preserve figment_dir if the new config doesn't supply one
figment_dir = cfg.params.get(
"figment_dir", self.config.params.get("figment_dir", "figments")
)
self.config = cfg
if "figment_dir" not in self.config.params:
self.config.params["figment_dir"] = figment_dir
self._scan_svgs()
def trigger(self, w: int, h: int) -> None:
"""Manually trigger a figment display."""
if not self._svg_files:
return
# Pick a random SVG, avoid repeating
candidates = [s for s in self._svg_files if s != self._last_svg]
if not candidates:
candidates = self._svg_files
svg_path = random.choice(candidates)
self._last_svg = svg_path
# Rasterize
try:
self._rows = rasterize_svg(svg_path, w, h)
except Exception:
return
# Pick random theme gradient
theme_key = random.choice(list(THEME_REGISTRY.keys()))
self._gradient = THEME_REGISTRY[theme_key].main_gradient
# Center in viewport
figment_h = len(self._rows)
figment_w = max((len(r) for r in self._rows), default=0)
self._center_row = max(0, (h - figment_h) // 2)
self._center_col = max(0, (w - figment_w) // 2)
# Start reveal phase
self._phase = FigmentPhase.REVEAL
self._progress = 0.0
def get_figment_state(
self, frame_number: int, w: int, h: int
) -> FigmentState | None:
"""Tick the state machine and return current state, or None if idle."""
if not self.config.enabled:
return None
# Poll triggers
for trig in self._triggers:
cmd = trig.poll()
if cmd is not None:
self._handle_command(cmd, w, h)
# Tick timer when idle
if self._phase is None:
self._timer += config.FRAME_DT
interval = self.config.params.get("interval_secs", 60)
if self._timer >= interval:
self._timer = 0.0
self.trigger(w, h)
# Tick animation — snapshot current phase/progress, then advance
if self._phase is not None:
# Capture the state at the start of this frame
current_phase = self._phase
current_progress = self._progress
# Advance for next frame
display_secs = self.config.params.get("display_secs", 4.5)
phase_duration = display_secs / 3.0
self._progress += config.FRAME_DT / phase_duration
if self._progress >= 1.0:
self._progress = 0.0
if self._phase == FigmentPhase.REVEAL:
self._phase = FigmentPhase.HOLD
elif self._phase == FigmentPhase.HOLD:
self._phase = FigmentPhase.DISSOLVE
elif self._phase == FigmentPhase.DISSOLVE:
self._phase = None
return FigmentState(
phase=current_phase,
progress=current_progress,
rows=self._rows,
gradient=self._gradient,
center_row=self._center_row,
center_col=self._center_col,
)
return None
def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
if cmd.action == FigmentAction.TRIGGER:
self.trigger(w, h)
elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(
cmd.value, (int, float)
):
self.config.intensity = float(cmd.value)
elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(
cmd.value, (int, float)
):
self.config.params["interval_secs"] = float(cmd.value)
elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
if cmd.value in THEME_REGISTRY:
self._gradient = THEME_REGISTRY[cmd.value].main_gradient
elif cmd.action == FigmentAction.STOP:
self._phase = None
self._progress = 0.0

View File

@@ -413,6 +413,14 @@ def main():
if config.FIREHOSE:
boot_ln("Firehose", "ENGAGED", True)
if config.FIGMENT:
try:
from effects_plugins.figment import FigmentEffect # noqa: F401
boot_ln("Figment", f"ARMED [{config.FIGMENT_INTERVAL}s interval]", True)
except (ImportError, OSError):
boot_ln("Figment", "UNAVAILABLE — run: brew install cairo", False)
time.sleep(0.4)
slow_print(" > STREAMING...\n")
time.sleep(0.2)

View File

@@ -196,6 +196,8 @@ MODE = (
else "news"
)
FIREHOSE = "--firehose" in sys.argv
FIGMENT = "--figment" in sys.argv
FIGMENT_INTERVAL = _arg_int("--figment-interval", 60) # seconds between appearances
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"

View File

@@ -18,6 +18,7 @@ class EventType(Enum):
NTFY_MESSAGE = auto()
STREAM_START = auto()
STREAM_END = auto()
FIGMENT_TRIGGER = auto()
@dataclass
@@ -65,3 +66,12 @@ class StreamEvent:
event_type: EventType
headline_count: int = 0
timestamp: datetime | None = None
@dataclass
class FigmentTriggerEvent:
"""Event emitted when a figment is triggered."""
action: str
value: float | str | None = None
timestamp: datetime | None = None

90
engine/figment_render.py Normal file
View File

@@ -0,0 +1,90 @@
"""
SVG to half-block terminal art rasterization.
Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
"""
from __future__ import annotations
import os
import sys
from io import BytesIO
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
# /usr/local/lib (Intel), which are not in dyld's default search path.
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
break
import cairosvg
from PIL import Image
_cache: dict[tuple[str, int, int], list[str]] = {}
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
"""Convert SVG file to list of half-block terminal rows (uncolored).
Args:
svg_path: Path to SVG file.
width: Target terminal width in columns.
height: Target terminal height in rows.
Returns:
List of strings, one per terminal row, containing block characters.
"""
cache_key = (svg_path, width, height)
if cache_key in _cache:
return _cache[cache_key]
# SVG -> PNG in memory
png_bytes = cairosvg.svg2png(
url=svg_path,
output_width=width,
output_height=height * 2, # 2 pixel rows per terminal row
)
# PNG -> greyscale PIL image
# Composite RGBA onto white background so transparent areas become white (255)
# and drawn pixels retain their luminance values.
img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA")
img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS)
background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255))
background.paste(img_rgba, mask=img_rgba.split()[3])
img = background.convert("L")
data = img.tobytes()
pix_w = width
pix_h = height * 2
# White (255) = empty space, dark (< threshold) = filled pixel
threshold = 128
# Half-block encode: walk pixel pairs
rows: list[str] = []
for y in range(0, pix_h, 2):
row: list[str] = []
for x in range(pix_w):
top = data[y * pix_w + x] < threshold
bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False
if top and bot:
row.append("")
elif top:
row.append("")
elif bot:
row.append("")
else:
row.append(" ")
rows.append("".join(row))
_cache[cache_key] = rows
return rows
def clear_cache() -> None:
"""Clear the rasterization cache (e.g., on terminal resize)."""
_cache.clear()

36
engine/figment_trigger.py Normal file
View File

@@ -0,0 +1,36 @@
"""
Figment trigger protocol and command types.
Defines the extensible input abstraction for triggering figment displays
from any control surface (ntfy, MQTT, serial, etc.).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
class FigmentAction(Enum):
TRIGGER = "trigger"
SET_INTENSITY = "set_intensity"
SET_INTERVAL = "set_interval"
SET_COLOR = "set_color"
STOP = "stop"
@dataclass
class FigmentCommand:
action: FigmentAction
value: float | str | None = None
class FigmentTrigger(Protocol):
"""Protocol for figment trigger sources.
Any input source (ntfy, MQTT, serial) can implement this
to trigger and control figment displays.
"""
def poll(self) -> FigmentCommand | None: ...

View File

@@ -57,9 +57,7 @@ def render_message_overlay(
else:
msg_rows = msg_cache[1]
msg_rows = msg_gradient(
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
)
msg_rows = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
@@ -258,3 +256,101 @@ 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

View File

@@ -18,6 +18,7 @@ from engine.frame import calculate_scroll_step
from engine.layers import (
apply_glitch,
process_effects,
render_figment_overlay,
render_firehose,
render_message_overlay,
render_ticker_zone,
@@ -53,6 +54,18 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
msg_cache = (None, None)
frame_number = 0
# Figment overlay (optional — requires cairosvg)
figment = None
if config.FIGMENT:
try:
from effects_plugins.figment import FigmentEffect
figment = FigmentEffect()
figment.config.enabled = True
figment.config.params["interval_secs"] = config.FIGMENT_INTERVAL
except (ImportError, OSError):
pass
while True:
if queued >= config.HEADLINE_LIMIT and not active:
break
@@ -123,6 +136,13 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
firehose_buf = render_firehose(items, w, fh, h)
buf.extend(firehose_buf)
# Figment overlay (between effects and ntfy message)
if figment and figment.config.enabled:
figment_state = figment.get_figment_state(frame_number, w, h)
if figment_state is not None:
figment_buf = render_figment_overlay(figment_state, w, h)
buf.extend(figment_buf)
if msg_overlay:
buf.extend(msg_overlay)

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 577.362 577.362"
xml:space="preserve">
<g>
<g id="Layer_2_21_">
<path d="M547.301,156.98c-23.113-2.132-181.832-24.174-314.358,5.718c-37.848-16.734-57.337-21.019-85.269-31.078
c-12.47-4.494-28.209-7.277-41.301-9.458c-26.01-4.322-45.89,1.253-54.697,31.346C36.94,203.846,19.201,253.293,0,311.386
c15.118-0.842,40.487-8.836,40.487-8.836l48.214-7.966l-9.964,66.938l57.777-19.526v57.776l66.938-29.883l19.125,49.41
c0,0,44.647-34.081,57.375-49.41c28.076,83.634,104.595,105.981,175.71,70.122c21.42-10.806,39.914-46.637,48.129-65.255
c23.926-54.229,11.6-93.712-5.891-137.155c20.254-9.562,34.061-13.464,66.344-30.628
C582.365,197.764,585.951,161.904,547.301,156.98z M63.352,196.119c11.924-8.396,18.599,0.889,34.511-10.308
c6.971-5.183,4.581-18.924-4.542-21.908c-3.997-1.31-6.722-2.897-12.049-5.192c-7.449-2.984-0.851-20.082,7.325-18.676
c15.443,2.572,24.575,3.012,32.159,12.125c8.702,10.452,9.008,37.074,4.991,45.843c-9.553,20.885-35.257,19.087-53.923,17.241
C57.624,214.097,56.744,201.034,63.352,196.119z M284.073,346.938c-51.915,6.685-102.921,0.794-142.462-42.313
c-25.331-27.616-57.231-46.187-88.654-68.611c28.84-11.121,64.49-5.078,84.781,25.704
c45.383,68.841,106.344,71.279,176.887,56.247c24.127-5.145,52.9-8.052,76.807-2.983c26.297,5.574,29.279,31.24,12.039,48.118
c-18.227,19.775-39.045-0.794-29.482-6.378c7.967-4.38,12.643-10.997,10.482-19.259c-6.197-9.668-21.707-2.975-31.586-1.425
C324.953,340.437,312.023,343.344,284.073,346.938z M472.188,381.049c-24.176,34.31-54.775,55.969-100.789,47.602
c-27.846-5.059-61.41-30.179-53.789-65.14c34.061,41.836,95.625,35.859,114.75,1.195c16.533-29.969-4.141-62.5-23.793-66.852
c-30.676-6.779-69.891-0.134-101.381,4.408c-58.58,8.444-104.48,7.812-152.579-43.844c-26.067-27.99,15.376-53.493-7.736-107.282
c44.351,8.578,72.121,22.711,89.247,79.292c11.293,37.294,59.096,61.325,110.762,53.387
c38.031-5.842,81.912-22.873,119.703-31.853C499.66,299.786,498.293,343.984,472.188,381.049z M288.195,243.568
c31.805-12.135,64.67-9.151,94.362,0C350.475,273.26,301.467,268.479,288.195,243.568z M528.979,198.959
c-35.459,17.337-60.961,25.102-98.809,37.055c-5.146,1.626-13.895,1.042-18.438-2.17c-47.803-33.813-114.846-27.425-142.338-6.292
c-18.522-11.456-21.038-42.582,8.406-49.304c83.834-19.125,179.45-13.646,248.788,0.793
C540.529,183.42,538.674,194.876,528.979,198.959z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 559.731 559.731"
xml:space="preserve">
<g>
<g id="Layer_2_36_">
<path d="M295.414,162.367l-15.061-39.302l-14.918,39.34c5.049-0.507,10.165-0.774,15.339-0.774
C285.718,161.621,290.595,161.898,295.414,162.367z"/>
<path d="M522.103,244.126c-20.062-0.631-36.71,12.67-55.787,21.937c-25.111,12.192-17.548-7.526-17.548-7.526l56.419-107.186
c-31.346-31.967-127.869-68.324-127.869-68.324l-38.968,85.957L280.774,27.249L221.295,168.84l-38.9-85.804
c0,0-96.533,36.356-127.87,68.324l56.418,107.186c0,0,7.564,19.718-17.547,7.525c-19.077-9.266-35.726-22.567-55.788-21.936
C17.547,244.767,0,275.481,0,305.565c0,30.084,7.525,68.955,39.493,68.955c31.967,0,47.64-16.926,58.924-23.188
c11.284-6.273,20.062,1.252,14.105,12.536S49.524,465.412,49.524,465.412s57.041,40.115,130.375,67.071l33.22-84.083
c-49.601-24.91-83.796-76.127-83.796-135.31c0-61.372,36.758-114.214,89.352-137.986c1.511-0.688,3.002-1.406,4.542-2.037
c9.964-4.112,20.483-7.095,31.384-9.008l25.732-67.836l25.943,67.731c10.576,1.807,20.779,4.657,30.495,8.53
c1.176,0.468,2.391,0.88,3.557,1.377c53.99,23.18,91.925,76.844,91.925,139.229c0,59.795-34.913,111.441-85.346,136.056
l32.924,83.337c73.335-26.956,130.375-67.071,130.375-67.071s-57.04-90.26-62.998-101.544
c-5.957-11.284,2.821-18.81,14.105-12.536c11.283,6.272,26.956,23.188,58.924,23.188s39.493-38.861,39.493-68.955
C559.712,275.472,542.165,244.757,522.103,244.126z"/>
<path d="M256.131,173.478c-1.836,0.325-3.682,0.612-5.499,1.004c-8.912,1.932-17.518,4.676-25.723,8.205
c-4.045,1.74-7.995,3.634-11.839,5.728c-44.159,24.078-74.195,70.925-74.195,124.667c0,55.146,31.681,102.931,77.743,126.396
c19.297,9.831,41.052,15.491,64.146,15.491c22.481,0,43.682-5.393,62.596-14.745c46.895-23.18,79.302-71.394,79.302-127.152
c0-54.851-31.336-102.434-77.007-126.043c-3.557-1.836-7.172-3.576-10.892-5.116c-7.86-3.242-16.056-5.814-24.547-7.622
c-1.808-0.382-3.652-0.622-5.479-0.937c-1.807-0.306-3.614-0.593-5.44-0.832c-6.082-0.793-12.24-1.348-18.532-1.348
c-6.541,0-12.919,0.602-19.221,1.463C259.736,172.895,257.929,173.163,256.131,173.478z M280.783,196.084
c10.433,0,20.493,1.501,30.132,4.074c8.559,2.285,16.754,5.441,24.423,9.496c37.093,19.641,62.443,58.608,62.443,103.418
c0,43.155-23.543,80.832-58.408,101.114c-17.251,10.04-37.227,15.883-58.59,15.883c-22.127,0-42.753-6.282-60.416-16.992
c-33.842-20.531-56.581-57.614-56.581-100.005c0-44.064,24.499-82.486,60.578-102.434c14.889-8.233,31.776-13.196,49.715-14.22
C276.309,196.294,278.518,196.084,280.783,196.084z"/>
<path d="M236.997,354.764c-6.694,0-12.145,5.45-12.145,12.145v4.398c0,6.694,5.441,12.145,12.145,12.145h16.457
c-1.683-11.743-0.717-22.376,0.268-28.688H236.997z"/>
<path d="M327.458,383.452c5.001,0,9.295-3.041,11.15-7.373c0.641-1.473,0.994-3.079,0.994-4.771v-4.398
c0-1.874-0.507-3.605-1.271-5.192c-1.961-4.074-6.054-6.952-10.873-6.952h-17.882c2.592,8.415,3.5,18.303,1.683,28.688H327.458z"
/>
<path d="M173.339,313.082c0,36.949,18.752,69.596,47.239,88.94c14.516,9.859,31.566,16.237,49.945,17.978
c-7.879-8.176-12.527-17.633-15.089-26.985h-18.437c-6.407,0-12.116-2.85-16.084-7.277c-3.461-3.844-5.623-8.874-5.623-14.43
v-4.398c0-5.938,2.41-11.322,6.283-15.243c3.939-3.987,9.39-6.464,15.424-6.464h18.809h49.974h21.697
c3.863,0,7.449,1.1,10.595,2.888c6.579,3.729,11.093,10.72,11.093,18.819v4.398c0,7.765-4.131,14.535-10.279,18.379
c-3.328,2.075-7.22,3.328-11.428,3.328h-18.676c-3.088,9.056-8.463,18.227-16.791,26.909c17.27-1.798,33.296-7.756,47.162-16.772
c29.48-19.173,49.056-52.355,49.056-90.069c0-39.216-21.19-73.498-52.661-92.259c-16.064-9.572-34.75-15.176-54.765-15.176
c-20.798,0-40.172,6.043-56.638,16.313C193.698,240.942,173.339,274.64,173.339,313.082z M306.287,274.583
c4.513-9.027,15.156-14.64,27.778-14.64c0.775,0,1.502,0.201,2.257,0.249c11.026,0.622,21.22,5.499,27.53,13.598l2.238,2.888
l-2.19,2.926c-6.789,9.036-16.667,14.688-26.89,15.597c-0.956,0.086-1.912,0.19-2.878,0.19c-11.284,0-21.362-5.89-27.664-16.16
l-1.387-2.257L306.287,274.583z M268.353,311.484l1.271,3.691c1.501,4.398,6.206,13.493,11.159,13.493
c4.915,0,9.649-9.372,11.055-13.646l1.138-3.48l3.653,0.201c9.658,0.517,12.594-1.454,13.244-2.065
c0.392-0.363,0.641-0.794,0.641-1.722c0-2.639,2.142-4.781,4.781-4.781c2.639,0,4.781,2.143,4.781,4.781
c0,3.414-1.253,6.417-3.624,8.664c-3.396,3.223-8.731,4.666-16.84,4.781c-2.534,5.852-8.635,16.839-18.838,16.839
c-10.06,0-16.19-10.595-18.81-16.428c-5.756,0.315-13.368-0.249-18.216-4.514c-2.716-2.391-4.16-5.623-4.16-9.343
c0-2.639,2.142-4.781,4.781-4.781s4.781,2.143,4.781,4.781c0,0.976,0.258,1.597,0.908,2.171c2.2,1.932,8.004,2.696,14.42,1.855
L268.353,311.484z M257.9,273.789l2.238,2.878l-2.19,2.916c-7.411,9.888-18.532,15.788-29.758,15.788
c-1.875,0-3.701-0.22-5.499-0.535c-9.018-1.598-16.916-7.058-22.166-15.625l-1.396-2.266l1.186-2.372
c3.94-7.87,12.546-13.148,23.055-14.363c1.54-0.182,3.127-0.277,4.733-0.277C240.028,259.942,251.168,265.116,257.9,273.789z"/>
<path d="M301.468,383.452c2.228-10.596,1.08-20.636-1.961-28.688h-36.06c-0.918,5.489-2.171,16.591-0.191,28.688
c0.517,3.146,1.272,6.359,2.295,9.562c2.763,8.664,7.563,17.231,15.73,24.088c8.443-7.707,13.941-15.94,17.26-24.088
C299.86,389.801,300.808,386.607,301.468,383.452z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 589.748 589.748"
xml:space="preserve">
<g>
<g id="Layer_2_2_">
<path d="M498.658,267.846c-9.219-9.744-20.59-14.382-33.211-15.491c-13.914-1.234-26.719,3.098-37.514,12.278
c-4.82,4.093-15.416,2.763-16.916-5.413c-0.795-4.303-0.096-7.602,2.305-11.246c3.854-5.862,6.98-12.202,10.422-18.331
c3.73-6.646,7.508-13.263,11.16-19.947c5.26-9.61,10.375-19.307,15.672-28.898c3.76-6.799,7.785-13.445,11.486-20.273
c0.459-0.851,0.104-3.031-0.594-3.48c-7.898-5.106-15.777-10.28-23.982-14.86c-7.602-4.236-15.502-7.975-23.447-11.542
c-8.348-3.739-16.889-7.076-25.418-10.404c-0.879-0.344-2.869,0.191-3.299,0.928c-5.26,9.008-10.346,18.111-15.443,27.215
c-4.006,7.153-7.918,14.363-11.924,21.516c-2.381,4.255-4.877,8.434-7.297,12.661c-3.193,5.575-6.215,11.255-9.609,16.715
c-1.234,1.989-0.363,2.467,1.07,3.232c5.25,2.812,11.016,5.001,15.586,8.673c7.736,6.225,15.109,13.034,21.879,20.301
c4.629,4.963,8.598,10.796,11.725,16.82c3.824,7.373,6.865,15.233,9.477,23.132c2.094,6.34,4.006,13.024,4.283,19.632
c0.441,10.317,1.473,20.837-1.291,31.04c-2.352,8.645-4.484,17.423-7.764,25.723c-2.41,6.101-6.445,11.58-9.879,17.27
c-6.225,10.309-14.354,18.943-24.115,25.925c-6.428,4.599-13.207,8.701-20.035,13.157c14.621,26.584,29.396,53.436,44.266,80.459
c4.762-1.788,9.256-3.375,13.664-5.154c7.412-2.974,14.918-5.766,22.129-9.189c6.082-2.888,11.857-6.464,17.662-9.906
c7.41-4.399,14.734-8.932,22.012-13.541c0.604-0.382,1.043-2.056,0.717-2.706c-1.768-3.5-3.748-6.904-5.766-10.271
c-4.246-7.085-8.635-14.095-12.812-21.219c-3.5-5.967-6.752-12.077-10.166-18.083c-3.711-6.512-7.525-12.957-11.207-19.488
c-2.611-4.638-4.887-9.477-7.65-14.019c-2.008-3.299-3.91-6.292-3.768-10.528c0.152-4.6,2.18-7.583,5.824-9.668
c3.613-2.056,7.391-1.864,10.814,0.546c2.945,2.074,5.412,5.077,8.615,6.492c5.527,2.438,11.408,4.122,17.232,5.834
c7.602,2.228,15.328,0.927,22.586-1.062c7.268-1.989,14.258-5.394,19.861-10.806c2.85-2.754,5.939-5.441,8.09-8.712
c4.285-6.493,7.432-13.426,8.885-21.324c1.51-8.195,0.688-16.065-1.645-23.61C508.957,280.516,504.404,273.927,498.658,267.846z"
/>
<path d="M183.983,301.85c0.421-46.885,24.174-79.417,64.69-100.846c-1.817-3.471-3.461-6.761-5.24-9.983
c-3.423-6.177-6.99-12.278-10.375-18.475c-5.518-10.117-10.882-20.32-16.438-30.418c-3.577-6.502-7.574-12.766-10.987-19.345
c-1.454-2.802-2.802-3.137-5.613-2.142c-12.642,4.466-25.016,9.543-36.979,15.606c-11.915,6.043-23.418,12.728-34.32,20.492
c-1.778,1.262-1.96,2.104-1.004,3.777c2.792,4.848,5.537,9.725,8.271,14.611c4.973,8.874,9.955,17.739,14.86,26.632
c3.242,5.871,6.282,11.857,9.572,17.7c5.843,10.375,12.02,20.579,17.643,31.078c2.448,4.571,2.247,10.604-2.639,14.009
c-5.011,3.491-9.486,3.596-14.22-0.115c-6.311-4.953-13.167-8.424-20.913-10.509c-11.59-3.127-22.711-1.894-33.564,2.802
c-2.18,0.946-4.112,2.429-6.244,3.48c-6.216,3.079-10.815,7.994-14.755,13.455c-4.447,6.168-7.076,13.158-8.683,20.655
c-1.73,8.071-1.052,16.008,1.167,23.677c2.878,9.955,8.807,18.149,16.677,24.996c5.613,4.887,12.192,8.339,19.096,9.975
c6.666,1.577,13.933,1.367,20.866,0.898c7.621-0.507,14.621-3.528,20.817-8.176c5.699-4.274,11.16-9.209,18.905-3.558
c3.242,2.362,5.431,10.375,3.414,13.751c-7.937,13.272-15.816,26.584-23.524,39.99c-4.169,7.249-7.851,14.774-11.915,22.09
c-4.456,8.013-9.151,15.902-13.646,23.896c-2.362,4.207-2.094,4.724,2.142,7.277c4.8,2.878,9.505,5.947,14.373,8.711
c8.09,4.6,16.18,9.237,24.48,13.436c5.556,2.812,11.427,5.011,17.241,7.286c5.393,2.113,10.892,3.969,16.524,6.006
c14.908-27.119,29.653-53.942,44.322-80.631C207.775,381.381,183.563,349.012,183.983,301.85z"/>
<path d="M283.979,220.368c-36.777,4.839-64.327,32.302-72.245,60.99c55.348,0,110.629,0,166.129,0
C364.667,233.545,324.189,215.08,283.979,220.368z"/>
<path d="M381.019,300.482c-9.82,0-19.201,0-28.889,0c0.727,9.562-3.203,28.143-13.1,40.028
c-9.926,11.915-22.529,18.207-37.658,19.68c-16.983,1.645-32.694-1.692-45.546-13.464c-13.655-12.498-20.129-27.119-18.81-46.244
c-9.763,0-18.972,0-29.223,0c-0.239,38.25,14.688,62.089,45.719,78.986c29.863,16.266,60.559,15.242,88.883-3.433
C369.066,358.45,382.291,329.17,381.019,300.482z"/>
<path d="M260.656,176.715c3.242,5.948,6.474,11.886,9.477,17.404c6.541-0.88,12.622-2.458,18.675-2.343
c9.313,0.182,18.59,1.559,27.893,2.314c0.957,0.077,2.486-0.296,2.869-0.975c2.486-4.332,4.695-8.817,7.057-13.215
c2.238-4.169,4.543-8.3,6.752-12.316c-12.719-24.203-25.389-48.319-38.451-73.172c-0.822,1.482-1.358,2.381-1.836,3.309
c-1.96,3.825-3.854,7.688-5.862,11.484c-2.438,4.628-4.954,9.218-7.459,13.818c-2.228,4.083-4.456,8.157-6.722,12.221
c-2.381,4.274-4.858,8.501-7.201,12.804c-2.381,4.361-4.418,8.932-7.028,13.148c-2.611,4.208-2.917,7.526-0.249,11.762
C259.336,174.171,259.967,175.462,260.656,176.715z"/>
<path d="M272.991,331.341c10.949,8.501,29.424,10.643,42.047,1.157c10.566-7.938,16.734-22.453,13.721-32.016
c-22.807,0-45.632,0-68.41,0C257.127,310.045,263.008,323.595,272.991,331.341z"/>
<path d="M322.248,413.836c-1.281-2.447-2.811-3.356-6.119-2.515c-5.699,1.444-11.676,2.133-17.566,2.381
c-10.175,0.431-20.388,0.479-30.486-2.696c-2.62,6.034-5.125,11.8-7.688,17.69c22.96,8.894,45.729,8.894,68.889,0.899
c-0.049-0.794,0.105-1.492-0.145-1.999C326.886,422.987,324.638,418.379,322.248,413.836z"/>
<path d="M541.498,355.343c10.613-15.654,15.863-33.345,15.586-52.556c-0.43-30.237-12.9-55.721-36.088-73.708
c-12.527-9.715-25.887-16.065-39.914-18.972c0.469-0.794,0.928-1.597,1.377-2.4c2.295-4.15,4.514-8.338,6.74-12.527
c1.914-3.605,3.836-7.21,5.795-10.796c1.482-2.716,3.014-5.403,4.543-8.09c2.295-4.036,4.59-8.081,6.76-12.183
c4.189-7.908,3.031-18.59-2.744-25.398c-2.781-3.28-5.785-5.25-7.773-6.56l-0.871-0.583l-4.465-3.213
c-3.883-2.812-7.908-5.709-12.184-8.491c-7.707-5.011-14.793-9.343-21.668-13.244c-4.17-2.362-8.387-4.236-12.105-5.891
l-3.08-1.377c-1.988-0.909-3.969-1.846-5.957-2.773c-5.633-2.658-11.455-5.402-17.795-7.707c-7.422-2.697-14.861-5.001-22.07-7.22
c-3.672-1.138-7.354-2.276-11.008-3.462c-2.236-0.727-5.66-1.683-9.609-1.683c-5.375,0-15.367,1.855-21.832,14.248
c-1.338,2.562-2.658,5.125-3.977,7.698L311.625,30.59L294.708,0l-16.639,30.743l-36.873,68.124
c-1.884-3.232-3.749-6.474-5.575-9.735c-4.523-8.07-12.125-12.699-20.865-12.699c-2.305,0-4.657,0.334-7,1.004
c-4.208,1.195-9.113,2.601-14.038,4.293l-5.747,1.941c-6.866,2.305-13.961,4.686-21.057,7.641
c-12.393,5.154-23.543,9.916-34.616,15.902c-9.333,5.049-17.968,10.815-26.316,16.39l-5.106,3.404
c-3.796,2.515-7.172,5.25-10.146,7.669c-1.176,0.947-2.343,1.903-3.519,2.821l-12.852,10.002l7.832,14.287l26.479,48.291
c-14.86,2.993-28.745,9.763-41.463,20.225c-21.994,18.102-33.938,42.773-34.53,71.355c-0.526,25.293,8.186,48.195,25.178,66.249
c14.248,15.128,31.049,24.538,50.107,28.086c-2.936,5.288-5.872,10.575-8.798,15.863c-1.3,2.362-2.562,4.733-3.834,7.115
c-1.625,3.05-3.251,6.11-4.963,9.112c-1.214,2.133-2.524,4.218-3.834,6.293c-1.281,2.046-2.563,4.102-3.796,6.187
c-5.891,10.012-1.568,21.649,6.015,27.119c7.851,5.671,15.73,11.303,23.677,16.858c12.451,8.702,25.408,15.864,38.508,21.286
l4.676,1.941c7.468,3.117,15.195,6.331,23.227,9.123c7.631,2.648,15.3,4.915,22.711,7.104c3.137,0.928,6.264,1.855,9.391,2.812
l9.955,4.657c3.892,32.751,35.324,58.283,73.526,58.283c38.508,0,70.112-25.943,73.592-59.058l10.49-3.51l4.715-1.683
l10.107-3.118c2.018-0.593,4.035-1.214,6.062-1.778c4.973-1.367,10.117-2.821,15.396-4.743
c7.889-2.878,16.352-6.368,26.641-10.949c6.588-2.936,12.938-6.206,18.877-9.696c8.883-5.23,17.566-10.662,25.789-16.142
c5.184-3.452,9.707-7.172,14.076-10.776l1.463-1.205c8.492-6.962,9.18-19.153,4.936-26.909c-2.229-4.073-4.562-8.09-6.895-12.097
l-2.42-4.159l-3.271-5.651c-3.107-5.374-6.225-10.748-9.295-16.142c-1.156-2.037-2.303-4.073-3.441-6.12
c6.961-1.301,13.637-3.404,19.957-6.292C517.552,382.251,531.093,370.69,541.498,355.343z M463.82,378.465
c-4.809,0-9.734-0.411-14.764-1.167c3.461,6.254,6.396,11.552,9.332,16.84c3.232,5.823,6.436,11.656,9.727,17.441
c4.168,7.325,8.404,14.612,12.621,21.908c3.051,5.278,6.168,10.519,9.096,15.864c0.41,0.746,0.268,2.496-0.287,2.955
c-4.562,3.748-9.094,7.573-14,10.844c-8.148,5.422-16.457,10.604-24.891,15.567c-5.471,3.223-11.16,6.12-16.965,8.702
c-8.357,3.729-16.811,7.296-25.408,10.433c-6.617,2.409-13.512,4.035-20.281,6.024c-4.82,1.415-9.629,2.83-14.85,4.37
c-2.736-4.753-5.49-9.371-8.072-14.066c-2.477-4.504-4.732-9.123-7.172-13.646c-4.34-8.033-8.807-16.008-13.109-24.069
c-1.598-2.993-2.133-3.997-3.576-3.997c-0.871,0-2.076,0.363-4.045,0.87c-8.148,2.104-16.324,3.873-24.309,5.661
c22.223,7.659,38.221,28.735,38.221,53.607c0,31.326-25.35,56.725-56.609,56.725c-31.27,0-56.61-25.398-56.61-56.725
c0-24.566,15.606-45.422,37.409-53.312c-7.516-2.065-15.472-4.341-23.572-6.54c-0.918-0.249-1.721-0.584-2.448-0.584
c-1.301,0-2.362,0.546-3.366,2.592c-4.581,9.267-9.744,18.217-14.697,27.301c-3.911,7.182-7.86,14.325-11.791,21.497
c-0.804,1.463-1.645,2.897-2.812,4.972c-10.49-3.203-21.076-6.11-31.422-9.696c-9.094-3.155-17.949-6.99-26.852-10.671
c-12.345-5.106-23.925-11.638-34.865-19.288c-7.86-5.498-15.664-11.083-23.438-16.696c-0.478-0.344-0.947-1.529-0.717-1.912
c2.515-4.274,5.288-8.396,7.746-12.699c3.098-5.422,5.909-10.997,8.931-16.467c5.919-10.729,11.896-21.42,17.834-32.14
c1.979-3.576,3.892-7.2,6.264-11.58c-4.848,0.736-9.562,1.109-14.143,1.109c-20.952,0-39.082-7.755-54.085-23.687
c-13.78-14.63-20.406-32.607-19.986-52.737c0.478-23.074,9.811-42.38,27.559-56.992c13.952-11.484,29.663-17.643,47.354-17.643
c4.523,0,9.17,0.401,13.952,1.224c-14.028-25.589-27.75-50.615-41.692-76.06c4.112-3.204,8.1-6.723,12.479-9.63
c9.85-6.521,19.594-13.311,29.959-18.915c10.585-5.718,21.745-10.433,32.866-15.07c8.367-3.481,17.06-6.197,25.646-9.142
c4.303-1.472,8.683-2.744,13.053-3.987c0.641-0.182,1.233-0.277,1.788-0.277c1.721,0,3.05,0.908,4.179,2.926
c5.393,9.62,11.092,19.067,16.629,28.611c2.018,3.481,3.901,7.048,6.11,11.054c17.853-32.981,35.41-65.426,53.206-98.312
c18.322,33.134,36.348,65.732,54.65,98.819c2.467-4.485,4.828-8.597,7.018-12.804c4.553-8.74,8.98-17.538,13.531-26.268
c1.463-2.812,2.773-3.968,4.867-3.968c1.014,0,2.219,0.268,3.711,0.755c10.814,3.5,21.773,6.588,32.445,10.461
c7.65,2.773,14.938,6.531,22.367,9.916c4.59,2.085,9.285,4.007,13.654,6.483c7.029,3.988,13.914,8.243,20.684,12.651
c5.471,3.557,10.682,7.487,15.998,11.265c1.77,1.252,3.777,2.314,5.145,3.92c0.756,0.889,0.977,3.031,0.432,4.074
c-3.576,6.751-7.498,13.32-11.18,20.024c-4.236,7.717-8.252,15.558-12.508,23.266c-2.246,4.064-4.895,7.898-7.182,11.943
c-3.309,5.862-6.445,11.819-10.012,18.389c4.973-0.947,9.803-1.406,14.498-1.406c17.174,0,32.502,6.13,46.254,16.802
c18.951,14.707,28.352,35.065,28.688,58.866c0.209,14.803-3.74,28.927-12.299,41.559c-8.309,12.26-19.039,21.602-32.379,27.693
C483.902,376.6,474.101,378.465,463.82,378.465z"/>
<path d="M261.746,512.598c0,18.102,14.669,32.818,32.704,32.818c18.034,0,32.704-14.726,32.704-32.818
c0-18.092-14.67-32.818-32.704-32.818C276.415,479.779,261.746,494.506,261.746,512.598z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,3 +1,7 @@
[env]
_.path = ["/opt/homebrew/lib"]
DYLD_LIBRARY_PATH = "/opt/homebrew/lib"
[tools]
python = "3.12"
hk = "latest"

View File

@@ -30,6 +30,9 @@ mic = [
"sounddevice>=0.4.0",
"numpy>=1.24.0",
]
figment = [
"cairosvg>=2.7.0",
]
dev = [
"pytest>=8.0.0",
"pytest-cov>=4.1.0",

3
tests/fixtures/test.svg vendored Normal file
View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">
<rect x="10" y="10" width="80" height="80" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 155 B

151
tests/test_figment.py Normal file
View File

@@ -0,0 +1,151 @@
"""Tests for the FigmentEffect plugin."""
import os
from enum import Enum
import pytest
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
from effects_plugins.figment import FigmentEffect, FigmentPhase, FigmentState
from engine.effects.types import EffectConfig, EffectContext
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
FIGMENTS_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
class TestFigmentPhase:
def test_is_enum(self):
assert issubclass(FigmentPhase, Enum)
def test_has_all_phases(self):
assert hasattr(FigmentPhase, "REVEAL")
assert hasattr(FigmentPhase, "HOLD")
assert hasattr(FigmentPhase, "DISSOLVE")
class TestFigmentState:
def test_creation(self):
state = FigmentState(
phase=FigmentPhase.REVEAL,
progress=0.5,
rows=["█▀▄", ""],
gradient=[46, 40, 34, 28, 22, 22, 34, 40, 46, 82, 118, 231],
center_row=5,
center_col=10,
)
assert state.phase == FigmentPhase.REVEAL
assert state.progress == 0.5
assert len(state.rows) == 2
class TestFigmentEffectInit:
def test_name(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
assert effect.name == "figment"
def test_default_config(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
assert effect.config.enabled is False
assert effect.config.intensity == 1.0
assert effect.config.params["interval_secs"] == 60
assert effect.config.params["display_secs"] == 4.5
def test_process_is_noop(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
buf = ["line1", "line2"]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=20,
)
result = effect.process(buf, ctx)
assert result == buf
assert result is buf
def test_configure(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
new_cfg = EffectConfig(enabled=True, intensity=0.5)
effect.configure(new_cfg)
assert effect.config.enabled is True
assert effect.config.intensity == 0.5
class TestFigmentStateMachine:
def test_idle_initially(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = True
state = effect.get_figment_state(0, 80, 24)
# Timer hasn't fired yet, should be None (idle)
assert state is None
def test_trigger_starts_reveal(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = True
effect.trigger(80, 24)
state = effect.get_figment_state(1, 80, 24)
assert state is not None
assert state.phase == FigmentPhase.REVEAL
def test_full_cycle(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = True
effect.config.params["display_secs"] = 0.15 # 3 phases x 0.05s
effect.trigger(40, 20)
# Advance through reveal (30 frames at 0.05s = 1.5s, but we shrunk it)
# With display_secs=0.15, each phase is 0.05s = 1 frame
state = effect.get_figment_state(1, 40, 20)
assert state is not None
assert state.phase == FigmentPhase.REVEAL
# Advance enough frames to get through all phases
for frame in range(2, 100):
state = effect.get_figment_state(frame, 40, 20)
if state is None:
break
# Should have completed the full cycle back to idle
assert state is None
def test_timer_fires_at_interval(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = True
effect.config.params["interval_secs"] = 0.1 # 2 frames at 20fps
# Frame 0: idle
state = effect.get_figment_state(0, 40, 20)
assert state is None
# Advance past interval (0.1s = 2 frames)
state = effect.get_figment_state(1, 40, 20)
state = effect.get_figment_state(2, 40, 20)
state = effect.get_figment_state(3, 40, 20)
# Timer should have fired by now
assert state is not None
class TestFigmentEdgeCases:
def test_empty_figment_dir(self, tmp_path):
effect = FigmentEffect(figment_dir=str(tmp_path))
effect.config.enabled = True
effect.trigger(40, 20)
state = effect.get_figment_state(1, 40, 20)
# No SVGs available — should stay idle
assert state is None
def test_missing_figment_dir(self):
effect = FigmentEffect(figment_dir="/nonexistent/path")
effect.config.enabled = True
effect.trigger(40, 20)
state = effect.get_figment_state(1, 40, 20)
assert state is None
def test_disabled_ignores_trigger(self):
effect = FigmentEffect(figment_dir=FIGMENTS_DIR)
effect.config.enabled = False
effect.trigger(80, 24)
state = effect.get_figment_state(1, 80, 24)
assert state is None

View File

@@ -0,0 +1,64 @@
"""Tests for render_figment_overlay in engine.layers."""
import pytest
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
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 == []

View File

@@ -0,0 +1,52 @@
"""Tests for engine.figment_render module."""
import os
import pytest
pytest.importorskip("cairosvg", reason="cairosvg requires system Cairo library")
from engine.figment_render import rasterize_svg
FIXTURE_SVG = os.path.join(os.path.dirname(__file__), "fixtures", "test.svg")
class TestRasterizeSvg:
def test_returns_list_of_strings(self):
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
assert isinstance(rows, list)
assert all(isinstance(r, str) for r in rows)
def test_output_height_matches_terminal_height(self):
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
assert len(rows) == 20
def test_output_contains_block_characters(self):
rows = rasterize_svg(FIXTURE_SVG, 40, 20)
all_chars = "".join(rows)
block_chars = {"", "", ""}
assert any(ch in all_chars for ch in block_chars)
def test_different_sizes_produce_different_output(self):
rows_small = rasterize_svg(FIXTURE_SVG, 20, 10)
rows_large = rasterize_svg(FIXTURE_SVG, 80, 40)
assert len(rows_small) == 10
assert len(rows_large) == 40
def test_nonexistent_file_raises(self):
import pytest
with pytest.raises((FileNotFoundError, OSError)):
rasterize_svg("/nonexistent/file.svg", 40, 20)
class TestRasterizeCache:
def test_cache_returns_same_result(self):
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
rows2 = rasterize_svg(FIXTURE_SVG, 40, 20)
assert rows1 == rows2
def test_cache_invalidated_by_size_change(self):
rows1 = rasterize_svg(FIXTURE_SVG, 40, 20)
rows2 = rasterize_svg(FIXTURE_SVG, 60, 30)
assert len(rows1) != len(rows2)

View File

@@ -0,0 +1,40 @@
"""Tests for engine.figment_trigger module."""
from enum import Enum
from engine.figment_trigger import FigmentAction, FigmentCommand
class TestFigmentAction:
def test_is_enum(self):
assert issubclass(FigmentAction, Enum)
def test_has_trigger(self):
assert FigmentAction.TRIGGER.value == "trigger"
def test_has_set_intensity(self):
assert FigmentAction.SET_INTENSITY.value == "set_intensity"
def test_has_set_interval(self):
assert FigmentAction.SET_INTERVAL.value == "set_interval"
def test_has_set_color(self):
assert FigmentAction.SET_COLOR.value == "set_color"
def test_has_stop(self):
assert FigmentAction.STOP.value == "stop"
class TestFigmentCommand:
def test_trigger_command(self):
cmd = FigmentCommand(action=FigmentAction.TRIGGER)
assert cmd.action == FigmentAction.TRIGGER
assert cmd.value is None
def test_set_intensity_command(self):
cmd = FigmentCommand(action=FigmentAction.SET_INTENSITY, value=0.8)
assert cmd.value == 0.8
def test_set_color_command(self):
cmd = FigmentCommand(action=FigmentAction.SET_COLOR, value="orange")
assert cmd.value == "orange"