15 Commits

Author SHA1 Message Date
a25b80d4a6 feat: Enable and configure figment mode via new CLI flags, update documentation, and improve Cairo library detection on macOS. 2026-03-19 13:41:40 -07:00
3a1aa975d1 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 13:39:58 -07:00
d5e5f39404 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 13:38:00 -07:00
2bfd3a01da 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 13:38:00 -07:00
4cf316c280 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 13:38:00 -07:00
79d271c42b 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 13:38:00 -07:00
525af4bc46 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 13:38:00 -07:00
085f150cb0 feat(figment): add SVG to half-block rasterization pipeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
0b6e2fae74 feat(figment): add trigger protocol and command types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
6864ad84c6 feat(figment): add test fixture SVG and FIGMENT_TRIGGER event type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
acb42ea140 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 13:38:00 -07:00
7014a9d5cd 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 13:38:00 -07:00
2cc8dbfc02 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 13:38:00 -07:00
f1d5162488 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 13:38:00 -07:00
9f61226779 Merge pull request 'docs/update-readme' (#33) from docs/update-readme into main
Reviewed-on: #33
2026-03-19 06:30:18 +00:00
22 changed files with 2523 additions and 35 deletions

1
.gitignore vendored
View File

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

152
README.md
View File

@@ -2,7 +2,34 @@
> *Digital consciousness stream. Matrix aesthetic · THX-1138 hue.* > *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 a white-hot → deep green gradient. 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)
- [Display Modes](#display-modes)
- [Feeds](#feeds)
- [Fonts](#fonts)
- [ntfy.sh](#ntfysh)
- [Figment Mode](#figment-mode)
- [Command & Control](#command--control-cc)
- [Internals](#internals)
- [How it works](#how-it-works)
- [Architecture](#architecture)
- [Development](#development)
- [Setup](#setup)
- [Tasks](#tasks)
- [Testing](#testing)
- [Linting](#linting)
- [Roadmap](#roadmap)
- [Performance](#performance)
- [Graphics](#graphics)
- [Cyberpunk Vibes](#cyberpunk-vibes)
- [Extensibility](#extensibility)
--- ---
@@ -15,8 +42,11 @@ python3 mainline.py # news stream
python3 mainline.py --poetry # literary consciousness mode python3 mainline.py --poetry # literary consciousness mode
python3 mainline.py -p # same python3 mainline.py -p # same
python3 mainline.py --firehose # dense rapid-fire headline mode 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 --display websocket # web browser display only python3 mainline.py --display websocket # web browser display only
python3 mainline.py --display both # terminal + web browser python3 mainline.py --display both # terminal + web browser
python3 mainline.py --refresh # force re-fetch (bypass cache)
python3 mainline.py --no-font-picker # skip interactive font picker python3 mainline.py --no-font-picker # skip interactive font picker
python3 mainline.py --font-file path.otf # use a specific font file python3 mainline.py --font-file path.otf # use a specific font file
python3 mainline.py --font-dir ~/fonts # scan a different font folder python3 mainline.py --font-dir ~/fonts # scan a different font folder
@@ -68,6 +98,7 @@ All constants live in `engine/config.py`:
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) | | `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) | | `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
| `GRAD_SPEED` | `0.08` | Gradient sweep speed | | `GRAD_SPEED` | `0.08` | Gradient sweep speed |
| `FIGMENT_INTERVAL` | `60` | Seconds between figment appearances (set by `--figment-interval`) |
### Display Modes ### Display Modes
@@ -104,20 +135,56 @@ To push a message:
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic 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 ## Internals
### How it works ### How it works
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection - On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
- Feeds are fetched and filtered on startup; results are cached for fast restarts - Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters - Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
- The ticker uses a sweeping white-hot → deep green gradient - The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
- Subject-region detection triggers Google Translate and font swap for non-Latin scripts - Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
- The mic stream runs in a background thread, feeding RMS dB into glitch probability - The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
- The viewport scrolls through pre-rendered blocks with fade zones - 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 for messages and C&C commands - An ntfy.sh SSE stream runs in a background thread for messages and C&C commands; 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 ### Architecture
@@ -138,32 +205,40 @@ engine/
controller.py handles /effects commands controller.py handles /effects commands
performance.py performance monitoring performance.py performance monitoring
legacy.py legacy functional effects legacy.py legacy functional effects
effects_plugins/ effect plugin implementations fetch.py RSS/Gutenberg fetching + cache load/save
noise.py noise effect
fade.py fade effect
glitch.py glitch effect
firehose.py firehose effect
fetch.py RSS/Gutenberg fetching + cache
ntfy.py NtfyPoller — standalone, zero internal deps ntfy.py NtfyPoller — standalone, zero internal deps
mic.py MicMonitor — standalone, graceful fallback mic.py MicMonitor — standalone, graceful fallback
scroll.py stream() frame loop + message rendering scroll.py stream() frame loop + message rendering
viewport.py terminal dimension tracking viewport.py terminal dimension tracking (tw/th)
frame.py scroll step calculation, timing frame.py scroll step calculation, timing
layers.py ticker zone, firehose, message overlay layers.py ticker zone, firehose, message + figment overlay rendering
eventbus.py thread-safe event publishing 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 events.py event types and definitions
controller.py coordinates ntfy/mic monitoring controller.py coordinates ntfy/mic monitoring and event publishing
emitters.py background emitters emitters.py background emitters for ntfy and mic
types.py type definitions types.py type definitions and dataclasses
themes.py THEME_REGISTRY — gradient color definitions
display/ Display backend system display/ Display backend system
__init__.py DisplayRegistry, get_monitor __init__.py DisplayRegistry, get_monitor
backends/ backends/
terminal.py ANSI terminal display terminal.py ANSI terminal display
websocket.py WebSocket server for browser clients websocket.py WebSocket server for browser clients
sixel.py Sixel graphics (pure Python) sixel.py Sixel graphics (pure Python)
null.py headless display for testing null.py headless display for testing
multi.py forwards to multiple displays multi.py forwards to multiple displays
benchmark.py performance benchmarking tool benchmark.py performance benchmarking tool
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
``` ```
--- ---
@@ -175,11 +250,15 @@ engine/
Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/). Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
```bash ```bash
uv sync # minimal (no mic) uv sync # minimal (no mic, no figment)
uv sync --all-extras # with mic support uv sync --extra 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 uv sync --all-extras --group dev # full dev environment
``` ```
Figment mode also requires the Cairo C library: `brew install cairo` (macOS).
### Tasks ### Tasks
With [mise](https://mise.jdx.dev/): With [mise](https://mise.jdx.dev/):
@@ -209,6 +288,8 @@ mise run topics-init # initialize ntfy topics
### Testing ### Testing
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 ```bash
uv run pytest uv run pytest
uv run pytest --cov=engine --cov-report=term-missing uv run pytest --cov=engine --cov-report=term-missing
@@ -252,12 +333,19 @@ Pre-commit hooks run lint automatically via `hk`.
- Parallax secondary column - Parallax secondary column
### Cyberpunk Vibes ### Cyberpunk Vibes
- Keyword watch list with strobe effects - **Figment intensity wiring** — `config.intensity` currently stored but not yet applied to reveal/dissolve speed or strobe frequency
- Breaking interrupt with synthesized audio - **ntfy figment trigger** — built-in `NtfyFigmentTrigger` that listens on a dedicated topic to fire figments on demand
- Live data overlay (BTC, ISS position) - **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
- Theme switcher (amber, ice, red) - **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
- Persona modes (surveillance, oracle, underground) - **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
### Extensibility
- **serve.py** — HTTP server that imports `engine.render` and `engine.fetch` directly to stream 1-bit bitmaps to an ESP32 display
- **Rust port** — `ntfy.py` and `render.py` are the natural first targets; clear module boundaries make incremental porting viable
--- ---
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.* *Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*

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: if config.FIREHOSE:
boot_ln("Firehose", "ENGAGED", True) 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) time.sleep(0.4)
slow_print(" > STREAMING...\n") slow_print(" > STREAMING...\n")
time.sleep(0.2) time.sleep(0.2)

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ from engine.frame import calculate_scroll_step
from engine.layers import ( from engine.layers import (
apply_glitch, apply_glitch,
process_effects, process_effects,
render_figment_overlay,
render_firehose, render_firehose,
render_message_overlay, render_message_overlay,
render_ticker_zone, render_ticker_zone,
@@ -53,6 +54,18 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
msg_cache = (None, None) msg_cache = (None, None)
frame_number = 0 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: while True:
if queued >= config.HEADLINE_LIMIT and not active: if queued >= config.HEADLINE_LIMIT and not active:
break break
@@ -123,6 +136,13 @@ def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
firehose_buf = render_firehose(items, w, fh, h) firehose_buf = render_firehose(items, w, fh, h)
buf.extend(firehose_buf) 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: if msg_overlay:
buf.extend(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] [tools]
python = "3.12" python = "3.12"
hk = "latest" hk = "latest"

View File

@@ -30,6 +30,9 @@ mic = [
"sounddevice>=0.4.0", "sounddevice>=0.4.0",
"numpy>=1.24.0", "numpy>=1.24.0",
] ]
figment = [
"cairosvg>=2.7.0",
]
dev = [ dev = [
"pytest>=8.0.0", "pytest>=8.0.0",
"pytest-cov>=4.1.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"