termflix 0.7.2

Terminal animation player with 60 procedurally generated animations, multiple render modes, and true color support
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
# termflix Architecture

Comprehensive technical architecture reference for termflix — a single-binary Rust TUI application that renders procedurally generated animations in the terminal using Unicode characters, with support for multiple render modes, color modes, external scripted control, and recording/playback.

## Table of Contents

- [Overview](#overview)
- [Module Structure](#module-structure)
- [The Animation Trait](#the-animation-trait)
- [Canvas and Pixel Space](#canvas-and-pixel-space)
- [Render Pipeline](#render-pipeline)
  - [Render Modes](#render-modes)
  - [Color Modes](#color-modes)
  - [Post-Process Effects](#post-process-effects)
  - [Color Assist](#color-assist)
- [Frame Loop](#frame-loop)
  - [Frame Timing and Adaptive Pacing](#frame-timing-and-adaptive-pacing)
  - [Synchronized Output](#synchronized-output)
  - [Dirty-Cell Rendering and Threaded Writer](#dirty-cell-rendering-and-threaded-writer)
  - [Resize Handling](#resize-handling)
- [Configuration System](#configuration-system)
- [External Control Subsystem](#external-control-subsystem)
- [Recording Subsystem](#recording-subsystem)
- [GIF Export](#gif-export)
  - [Pixel-Based GIF Path (Gallery)](#pixel-based-gif-path-gallery)
  - [LZW Width-Bump Rule](#lzw-width-bump-rule)
- [Gallery and Pages Deploy](#gallery-and-pages-deploy)
- [Generators (Shared Utilities)](#generators-shared-utilities)
- [Animation Catalog](#animation-catalog)
- [Terminal Exit Sequence](#terminal-exit-sequence)
- [Key Design Decisions](#key-design-decisions)
- [Related Documentation](#related-documentation)

---

## Overview

termflix renders 60 procedurally generated animations directly in the terminal using Unicode sub-cell characters. All animation logic writes to a mode-agnostic pixel buffer; the renderer translates that buffer into ANSI escape sequences appropriate for the configured render and color modes. The result is a clean separation between simulation and display that allows both to evolve independently.

The binary is fully synchronous — one main thread drives the event loop, a single optional background thread reads external control parameters, and `crossterm` handles terminal I/O.

---

## Module Structure

The following diagram shows the dependency graph between the top-level modules. Arrows represent `use` or `mod` relationships, with direction indicating the dependency direction (dependant → dependency).

```mermaid
graph TD
    main["main.rs\nCLI · startup · run_loop"]
    config["config.rs\nConfig · TOML loading"]
    external["external.rs\nExternalParams · CurrentState · spawn_reader"]
    record["record.rs\nRecorder · Player · .asciianim"]
    gif["gif.rs\nGIF89a encoder\nLZW compression"]
    generators["generators/mod.rs\nParticle · ParticleSystem\nColorGradient · EmitterConfig"]
    anim_mod["animations/mod.rs\nAnimation trait · create() factory\nANIMATION_NAMES · ANIMATIONS"]
    anim_impls["animations/*.rs\n60 animation modules"]
    render_mod["render/mod.rs\nre-exports Canvas · ColorMode · RenderMode\nColorAssist · smoothing_alpha"]
    canvas["render/canvas.rs\nCanvas · pixel buffer\napply_effects · post_process\napply_color_assist · build_grid"]
    braille["render/braille.rs\nBraille renderer\nU+2800–U+28FF"]
    halfblock["render/halfblock.rs\nHalfBlock renderer\n▀ / ▄ / █"]
    cell["render/cell.rs\nCell · CellGrid\nterminal-cell grid"]
    encoder["render/encoder.rs\nencode_full · encode_diff\ndirty_ratio · FULL_REDRAW_THRESHOLD"]
    color_assist["render/color_assist.rs\nPalette · Deficiency · daltonize\nColorAssist"]
    render_sink["render_sink.rs\nThreadedRenderer\nwrite_chunked · dirty-cell write path"]
    gallery["gallery.rs\noffscreen capture\nPNG · GIF · index.html"]

    main --> config
    main --> external
    main --> record
    main --> gif
    main --> anim_mod
    main --> render_mod
    main --> render_sink
    main --> gallery
    anim_mod --> anim_impls
    anim_mod --> render_mod
    anim_mod --> external
    anim_impls --> generators
    anim_impls --> canvas
    render_mod --> canvas
    render_mod --> braille
    render_mod --> halfblock
    render_mod --> cell
    render_mod --> encoder
    render_mod --> color_assist
    braille --> cell
    halfblock --> cell
    canvas --> color_assist
    encoder --> cell
    gif --> record

    style main fill:#e65100,stroke:#ff9800,stroke-width:3px,color:#ffffff
    style config fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style external fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style record fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style gif fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style generators fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style anim_mod fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style anim_impls fill:#0d47a1,stroke:#2196f3,stroke-width:1px,color:#ffffff
    style render_mod fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style canvas fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style braille fill:#880e4f,stroke:#c2185b,stroke-width:1px,color:#ffffff
    style halfblock fill:#880e4f,stroke:#c2185b,stroke-width:1px,color:#ffffff
    style cell fill:#880e4f,stroke:#c2185b,stroke-width:1px,color:#ffffff
    style encoder fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style color_assist fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style render_sink fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style gallery fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
```

**Source layout:**

```
src/
├── main.rs            — CLI parsing (clap), startup, run_loop event loop
├── config.rs          — Config struct, TOML config (~/.config/termflix/config.toml)
├── external.rs        — External control: ExternalParams, CurrentState, spawn_reader
├── record.rs          — Recording (Recorder) and playback (Player), .asciianim format
├── gif.rs             — Hand-written GIF89a encoder with LZW compression
├── gallery.rs         — Offscreen gallery capture (PNG + GIF + index.html)
├── render_sink.rs     — ThreadedRenderer, chunked/dirty-cell write path
├── generators/
│   └── mod.rs         — Shared: Particle, ParticleSystem, ColorGradient, EmitterConfig
├── animations/
│   ├── mod.rs         — Animation trait + create() factory + ANIMATION_NAMES/ANIMATIONS
│   └── *.rs           — 60 individual animation modules
└── render/
    ├── mod.rs          — Re-exports Canvas, ColorMode, PostProcessConfig, RenderMode, ColorAssist, smoothing_alpha
    ├── canvas.rs       — Canvas struct, pixel/color buffers, apply_effects, post_process, apply_color_assist, build_grid
    ├── braille.rs      — Braille renderer (2×4 sub-cell, Unicode U+2800–U+28FF)
    ├── halfblock.rs    — Half-block renderer (▀/▄/█, foreground+background color pairs)
    ├── cell.rs         — Cell / CellGrid terminal-cell grid type
    ├── encoder.rs      — encode_full / encode_diff / dirty_ratio (dirty-cell rendering)
    └── color_assist.rs — Palette / Deficiency / daltonize / ColorAssist (colorblind-safe assist)
```

---

## The Animation Trait

Every animation implements the `Animation` trait defined in `animations/mod.rs`. This trait object is stored as `Box<dyn Animation>`, enabling runtime animation switching without exhaustive enum dispatch.

```rust
pub trait Animation {
    fn name(&self) -> &str;
    fn update(&mut self, canvas: &mut Canvas, dt: f64, time: f64);
    fn preferred_render(&self) -> RenderMode { RenderMode::HalfBlock }  // default
    fn set_params(&mut self, _params: &ExternalParams) {}               // default no-op
    fn on_resize(&mut self, _width: usize, _height: usize) {}           // default no-op
    fn supported_params(&self) -> &'static [(&'static str, f64, f64)] { &[] }  // default empty
}
```

| Method | Purpose |
|--------|---------|
| `name()` | Human-readable display name shown in the status bar |
| `update()` | Advance simulation state and write pixels/colors to the canvas |
| `preferred_render()` | Declares the render mode that suits this animation best; used when no `-r` flag is given |
| `set_params()` | Receives external control parameters once per frame before `update()`; most animations inherit the no-op default |
| `on_resize()` | Called when the canvas is rebuilt with new dimensions; animations use this to update stored dimensions and rebuild size-dependent state |
| `supported_params()` | Returns a list of `(param_name, min_value, max_value)` tuples describing which external parameters the animation responds to |

**Parameter semantics:**

- `dt` — frame delta time in seconds, capped at 0.1 s, then multiplied by the external speed multiplier. Prevents large simulation jumps after pauses or slow frames.
- `time` — virtual elapsed time (not wall-clock). Increases by `dt × speed` each frame, so external speed control stretches or compresses perceived animation velocity without breaking physics.
- `set_params` — `fire`, `plasma`, `boids`, `particles`, `wave`, `sort`, `snake`, and `pong` provide semantic overrides (e.g., flame intensity, plasma frequency). All other animations silently ignore external params through the default no-op.

The factory function `create(name, width, height, scale)` in `animations/mod.rs` maps a name string to a concrete animation instance. The `scale` parameter adjusts particle counts and element densities proportionally.

---

## Canvas and Pixel Space

The `Canvas` struct in `render/canvas.rs` provides a mode-agnostic pixel buffer that all animations write to. The render mode determines how many sub-cell pixels map to each terminal character cell. Post-processing effects (bloom, vignette, scanlines) are applied via a separate `post_process()` call after `apply_effects()`.

```mermaid
graph LR
    subgraph "Terminal Character Cell"
        direction TB
        B["Braille\n2×4 = 8 pixels\nper cell"]
        H["HalfBlock\n1×2 = 2 pixels\nper cell"]
        A["ASCII\n1×1 = 1 pixel\nper cell"]
    end

    subgraph "Canvas Dimensions"
        BD["width = cols×2\nheight = rows×4"]
        HD["width = cols\nheight = rows×2"]
        AD["width = cols\nheight = rows"]
    end

    B --> BD
    H --> HD
    A --> AD

    style B fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style H fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style A fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style BD fill:#0d47a1,stroke:#2196f3,stroke-width:1px,color:#ffffff
    style HD fill:#1b5e20,stroke:#4caf50,stroke-width:1px,color:#ffffff
    style AD fill:#880e4f,stroke:#c2185b,stroke-width:1px,color:#ffffff
```

| Render mode | Terminal cell pixels | Canvas width | Canvas height |
|------------|---------------------|--------------|---------------|
| Braille | 2×4 sub-pixels | `cols × 2` | `rows × 4` |
| HalfBlock | 1×2 sub-pixels | `cols` | `rows × 2` |
| ASCII | 1×1 (cell = character) | `cols` | `rows` |

**Canvas internal storage:**

```rust
pub struct Canvas {
    pub width: usize,
    pub height: usize,
    pub pixels: Vec<f64>,           // brightness 0.0..=1.0 per sub-cell pixel
    pub colors: Vec<(u8, u8, u8)>,  // RGB per sub-cell pixel
    pub char_override: Vec<char>,   // optional per-cell character (ASCII mode only)
    pub render_mode: RenderMode,
    pub color_mode: ColorMode,
    pub color_quant: u8,            // color quantization step (0 = off)
    pub dither: bool,               // 4×4 Bayer ordered dithering (ANSI-256 mode)
}
```

Animations write to `pixels` and `colors` using `set_colored(x, y, brightness, r, g, b)`, working entirely in sub-cell pixel coordinates. The renderer is responsible for the character-mapping step.

> **📝 Note:** `color_quant` rounds RGB values to the nearest multiple of the configured step before emitting ANSI escape codes. Higher values reduce the number of unique escape sequences per frame, which meaningfully reduces output size in bandwidth-constrained environments such as tmux over SSH.

---

## Render Pipeline

The following sequence diagram shows the per-frame render pipeline from canvas population through terminal output.

```mermaid
sequenceDiagram
    participant AL as Animation Loop
    participant AN as Animation
    participant CV as Canvas
    participant CA as ColorAssist
    participant EN as encoder
    participant RN as Renderer (sink)
    participant ST as stdout

    AL->>AN: set_params(&ext_state.params)
    AL->>AN: update(&mut canvas, effective_dt, virtual_time)
    AN->>CV: set_colored(x, y, brightness, r, g, b)
    AL->>CV: apply_smoothing(alpha) (opt-in, --smoothing)
    AL->>CV: apply_effects(intensity, hue_shift)
    AL->>CV: apply_color_assist(&ColorAssist)
    AL->>CV: post_process(&PostProcessConfig)
    AL->>CV: render_cells() → build_grid() → CellGrid
    CV->>EN: encode_full(grid) or encode_diff(prev, grid)
    EN-->>AL: ANSI escape String
    AL->>AL: wrap in sync markers + status bar
    AL->>RN: ThreadedRenderer.submit() (default) or inline write
    RN->>ST: libc::write() in 16 KB chunks (Unix)
```

### Render Modes

**Braille renderer** (`render/braille.rs`):

Each terminal character cell covers a 2×4 dot grid (8 dots). A dot is considered "lit" when the corresponding pixel's brightness exceeds the 0.3 threshold. Active dots are OR'd into a bitmask that selects the Unicode braille character in the U+2800–U+28FF range. The foreground color is the average RGB of all lit dots in the cell.

```
Cell dot layout:      Unicode bit weights:
(0,0) (1,0)           bit 0x01  bit 0x08
(0,1) (1,1)           bit 0x02  bit 0x10
(0,2) (1,2)           bit 0x04  bit 0x20
(0,3) (1,3)           bit 0x40  bit 0x80
```

**HalfBlock renderer** (`render/halfblock.rs`):

Each terminal character cell covers two vertical sub-pixels: a top pixel rendered as the foreground color of `▀` (U+2580, Upper Half Block) and a bottom pixel rendered as the background color of the same character. This allows two independently colored sub-rows per character cell. Both pixels below the 0.02 dark threshold emit a plain space with no color codes.

**ASCII renderer** (`render/canvas.rs::ascii_build_grid`, emitted via `render/encoder.rs::encode_full`):

Brightness values map to characters from the density scale `" .:-=+*#%@"`. Animations may set `char_override` on specific cells to emit literal characters instead (used by `matrix`, `hackerman`, and other text-oriented animations).

### Color Modes

All three renderers share a common `map_color(x, y, r, g, b)` method on `Canvas` that translates RGB values to the appropriate ANSI escape representation. The `(x, y)` coordinates feed the optional 4×4 Bayer ordered dithering used in ANSI-256 mode (enabled via `--dither`):

| Mode | Behavior | ANSI sequence |
|------|---------|---------------|
| `TrueColor` | 24-bit RGB passthrough | `38;2;R;G;B` |
| `Ansi256` | Nearest 6×6×6 color cube index | `38;5;N` |
| `Ansi16` | Heuristic brightness + hue mapping | `31`–`97` |
| `Mono` | No color escape codes emitted | — |

All renderers track the previously emitted ANSI code and skip writing a new one when the color is unchanged — a micro-optimization that reduces output size significantly for large uniform regions.

### Post-Process Effects

The per-frame pipeline after `update()` is: `apply_smoothing()` (opt-in) → `apply_effects()` → `apply_color_assist()` → `post_process()` → `render_cells()`/`build_grid()`. Each stage is a separate `Canvas` method, keeping all transforms fully decoupled from animation logic.

- **`apply_smoothing(alpha)`** (opt-in via `--smoothing TAU`): first-order EMA that blends each pixel's brightness toward its target using `smoothing_alpha(dt, tau)`. Brightness-only; `colors` is untouched. Eliminates per-frame flicker in high-frequency animations.
- **`apply_effects(intensity, hue_shift)`**: global brightness multiplier and hue rotation (see below).
- **`apply_color_assist(&ColorAssist)`**: colorblind-safe remap or daltonization (see [Color Assist](#color-assist) below).
- **`post_process(&PostProcessConfig)`**: bloom, vignette, scanlines (see table below).

**`apply_effects` parameters:**

- **Intensity**: multiplies every brightness value by the factor (clamped 0.0–2.0). Values above 1.0 wash out the image; values below 1.0 dim it.
- **Hue shift**: rotates the hue of every color in the buffer by `hue_shift × 360°`, applied via RGB → HSV → RGB conversion. A value of 0.5 inverts hues; 1.0 completes a full cycle back to the original.

**`post_process` effects (via `PostProcessConfig`):**

| Effect | Config Field | Description |
|--------|-------------|-------------|
| Bloom | `bloom` (0.0–1.0), `bloom_threshold` (0.0–1.0) | Pixels above the brightness threshold spread a soft glow to their 8 neighbors. Brightness boost is proportional to the original pixel value and bloom strength. |
| Vignette | `vignette` (0.0–1.0) | Edge darkening that attenuates brightness based on distance from the canvas center, using a quadratic falloff. |
| Scanlines | `scanlines` (bool) | CRT-style effect that darkens every other row by 30%. |

These effects are configured via CLI flags (`--bloom-intensity`, `--bloom-threshold`, `--vignette`, `--scanlines`) or the `[postproc]` section in the config file. Bloom is enabled by default at 0.4 intensity with a 0.6 threshold.

### Color Assist

`render/color_assist.rs` provides colorblind-safe color correction, applied per-frame via `canvas.apply_color_assist(&assist)` between `apply_effects` and `post_process`. It is resolved once at startup from the mutually-exclusive `--palette` / `--colorblind` flags (or their config equivalents) by `ColorAssist::from_cli()`.

```rust
pub enum ColorAssist {
    None,                  // default — no assist
    Remap(Palette),        // remap every pixel onto a gradient by luminance
    Daltonize(Deficiency), // LMS-space daltonization for a color-vision deficiency
}
```

| Component | Purpose |
|-----------|---------|
| `Palette` | Perceptually uniform gradient (`viridis`, `magma`, `inferno`, `plasma`, `okabe-ito`). `Remap` rewrites each pixel's color to the gradient sample matching its Rec.601 luminance. |
| `Deficiency` | Color-vision deficiency (`protanopia`, `deuteranopia`, `tritanopia`) corrected by Viénot/Brettel LMS-space `daltonize()`. |
| `ColorAssist` | Resolved per-frame setting. `None` is a no-op; `Mono` color mode also short-circuits to a no-op. |

`--palette` and `--colorblind` are mutually exclusive (clap `conflicts_with`). An invalid name on either is silently ignored. The `d` hotkey toggles `canvas.dither` at runtime (Bayer dithering for ANSI-256; independent of color assist but also lives on `Canvas`).

---

## Frame Loop

The main render loop in `run_loop()` (`main.rs`) is a synchronous `loop {}` that uses `crossterm::event::poll()` as its frame timer. This approach properly yields execution to the OS for signal handling and is more reliable than `thread::sleep`.

```mermaid
flowchart TD
    START([Start frame]) --> POLL["event::poll(time_to_next_frame)\nyields to OS"]
    POLL --> EVENTS{Events?}
    EVENTS -->|yes| DRAIN["Drain all pending events\nresize / keypress / focus"]
    EVENTS -->|no timeout| COOLDOWN
    DRAIN --> COOLDOWN{Resize cooldown\n< 100ms?}
    COOLDOWN -->|yes| SKIP[Skip frame\ncontinue]
    SKIP --> START
    COOLDOWN -->|no| REBUILD{needs_rebuild?}
    REBUILD -->|yes| RCANVAS["Re-query terminal size\nRebuild Canvas\nRecreate animation\ncontinue"]
    RCANVAS --> START
    REBUILD -->|no| AUTOCYCLE{Auto-cycle\nexpired?}
    AUTOCYCLE -->|yes| NEXTANIM[Advance animation index\nrecreate]
    NEXTANIM --> TIMING
    AUTOCYCLE -->|no| TIMING["dt = elapsed.min(0.1)\nlast_frame = now"]
    TIMING --> DRAIN_EXT["Drain external params\ntry_recv() — non-blocking"]
    DRAIN_EXT --> EXTPARAMS["Apply one-shot changes\nanim / scale / render / color"]
    EXTPARAMS --> VTIME["effective_dt = (dt × speed).min(0.5)\nvirtual_time += effective_dt"]
    VTIME --> SETPARAMS["anim.set_params(&ext_state.params)"]
    SETPARAMS --> UPDATE["anim.update(&mut canvas, effective_dt, virtual_time)"]
    UPDATE --> SMOOTHING{smoothing_tau > 0?}
    SMOOTHING -->|yes| SMOOTH["canvas.apply_smoothing(alpha)"]
    SMOOTHING -->|no| EFFECTS
    SMOOTH --> EFFECTS["canvas.apply_effects(intensity, hue_shift)"]
    EFFECTS --> ASSIST["canvas.apply_color_assist(&ColorAssist)"]
    ASSIST --> POSTPROC["canvas.post_process(&PostProcessConfig)"]
    POSTPROC --> RENDER["canvas.render_cells() → build_grid() → encode_full/encode_diff"]
    RENDER --> RECORD{Recording?}
    RECORD -->|yes| CAPTURE["rec.capture(&frame)"]
    RECORD -->|no| BUILDFRAME
    CAPTURE --> BUILDFRAME["Build frame_buf:\n\\x1b[?2026h + \\x1b[H + frame\n+ status bar\n+ \\x1b[?2026l"]
    BUILDFRAME --> SIZECHECK{Terminal size\nchanged?}
    SIZECHECK -->|yes| DISCARD[Discard frame\nrebuild next iter]
    DISCARD --> START
    SIZECHECK -->|no| WRITE["libc::write() 16KB chunks\nwith quit checks between\n(Unix) / write_all (non-Unix)"]
    WRITE --> ADAPTIVE["Adaptive EMA pacing update\n(tmux or unlimited mode)"]
    ADAPTIVE --> START

    style START fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style POLL fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style EVENTS fill:#ff6f00,stroke:#ffa726,stroke-width:2px,color:#ffffff
    style DRAIN fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style COOLDOWN fill:#ff6f00,stroke:#ffa726,stroke-width:2px,color:#ffffff
    style SKIP fill:#b71c1c,stroke:#f44336,stroke-width:2px,color:#ffffff
    style REBUILD fill:#ff6f00,stroke:#ffa726,stroke-width:2px,color:#ffffff
    style RCANVAS fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style AUTOCYCLE fill:#ff6f00,stroke:#ffa726,stroke-width:2px,color:#ffffff
    style NEXTANIM fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style TIMING fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style DRAIN_EXT fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style EXTPARAMS fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style VTIME fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style SETPARAMS fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style UPDATE fill:#e65100,stroke:#ff9800,stroke-width:3px,color:#ffffff
    style EFFECTS fill:#e65100,stroke:#ff9800,stroke-width:2px,color:#ffffff
    style SMOOTHING fill:#ff6f00,stroke:#ffa726,stroke-width:2px,color:#ffffff
    style SMOOTH fill:#e65100,stroke:#ff9800,stroke-width:1px,color:#ffffff
    style ASSIST fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style POSTPROC fill:#e65100,stroke:#ff9800,stroke-width:2px,color:#ffffff
    style RENDER fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style RECORD fill:#ff6f00,stroke:#ffa726,stroke-width:2px,color:#ffffff
    style CAPTURE fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style BUILDFRAME fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style SIZECHECK fill:#ff6f00,stroke:#ffa726,stroke-width:2px,color:#ffffff
    style DISCARD fill:#b71c1c,stroke:#f44336,stroke-width:2px,color:#ffffff
    style WRITE fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style ADAPTIVE fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
```

### Frame Timing and Adaptive Pacing

**Standard mode**: `event::poll(time_to_next_frame)` blocks for the remainder of the frame duration, yielding cleanly to the OS. The default target is 24 FPS; `--fps` overrides this between 1 and 120.

**Unlimited mode** (`--unlimited`): `frame_dur` is set to `Duration::ZERO`. The poll call returns immediately, and adaptive pacing prevents terminal flooding.

**Adaptive EMA pacing**: Active in tmux and unlimited mode. After each frame write, the exponential moving average of write time is updated:

```
write_time_ema = write_time_ema × 0.8 + write_secs × 0.2
target_frame_dur = max(write_time_ema × 1.1, frame_dur)
adaptive_frame_dur = min(target_frame_dur, 200ms)
```

The 1.1 multiplier adds a 10% margin above measured write time to avoid buffer stalls. The 200ms cap enforces a minimum effective frame rate of 5 FPS even under extreme terminal load.

**tmux detection**: The `TMUX` environment variable is checked once at startup. In tmux, frame writes can block when the pane output buffer fills; the chunk-write loop (see below) keeps the UI responsive even during backpressure.

**dt capping**: Raw delta time is capped at 0.1 s before being passed to animations. This prevents large simulation jumps after pauses (window focus lost, debugger break, etc.).

**Animation transitions**: When switching between animations (via keyboard, auto-cycle, or external control), an 8-frame crossfade transition occurs. During fade-out, the current animation's intensity is reduced linearly. At zero, the new animation is created. During fade-in, intensity ramps back up. This provides smooth visual transitions rather than abrupt switches.

**Profile mode** (`--profile`): Records per-frame update and render durations in microseconds. On exit, prints a summary table with average, min, max, and p95 timings, plus the average FPS over the session.

**Screensaver mode** (`--screensaver`): Exits on any keypress or focus-gained event. Designed for use as a terminal screensaver that dismisses on interaction.

### Synchronized Output

Every frame is wrapped in ANSI synchronized output markers:

```
\x1b[?2026h   ← Begin Synchronized Update (BSU)
\x1b[H        ← Move cursor to home position
<frame ANSI>  ← rendered canvas
<status bar>  ← optional
\x1b[?2026l   ← End Synchronized Update (ESU)
```

Terminals that support this feature buffer all output between the markers and flush to screen atomically, eliminating the inter-frame flicker that occurs when a partial frame is visible during the draw. tmux strips these markers as unrecognized private sequences but they are harmless.

### Dirty-Cell Rendering and Threaded Writer

Two optimizations reduce the bytes written per frame and keep the UI responsive under backpressure:

- **Dirty-cell (differential) rendering** (`render/encoder.rs`): each frame is built into a `CellGrid` (`render/cell.rs`). When the grid dimensions match the previous frame, the encoder compares the two and emits only the cells that changed via `encode_diff(prev, grid)`, using cursor moves between dirty runs. If `dirty_ratio(prev, grid)` exceeds `FULL_REDRAW_THRESHOLD` (0.6) a full redraw via `encode_full` is cheaper and is emitted instead. Diffing is disabled when recording (`--record`), when `--full-frames` is set, or after a resize.
- **Threaded writer** (`render_sink.rs`): by default the chunked `libc::write()` runs on a dedicated writer thread via `ThreadedRenderer::submit()`, so a blocking write to a full tmux pane does not stall the simulation. `--single-threaded` disables the thread and writes inline on the main loop. The writer checks the quit flag between 16 KB chunks so `q` remains responsive either way.

### Resize Handling

```mermaid
stateDiagram-v2
    [*] --> Rendering: Normal operation
    Rendering --> Cooldown: Resize event — set needs_rebuild, reset cooldown timer
    Cooldown --> Cooldown: under 100ms elapsed — skip frame
    Cooldown --> Rebuilding: 100ms cooldown elapsed
    Rebuilding --> Rendering: Re-query size, rebuild Canvas, recreate animation, skip frame

    note right of Cooldown
        Terminal emulators emit
        multiple resize events
        while the user drags.
        The cooldown waits for
        the stream to settle.
    end note

    note right of Rebuilding
        No explicit clear is issued.
        The next frame overwrites
        all cells, avoiding a
        blocking flush in tmux.
    end note
```

The resize cooldown waits 100 ms after the last resize event before rebuilding. This prevents repeated canvas recreations as the user drags the terminal window. After rebuilding, a frame is skipped (via `continue`) so the animation starts fresh on a clean canvas without tearing.

---

## Configuration System

termflix uses a three-tier priority system where each level can override the one below it.

```mermaid
flowchart LR
    CLI["CLI Flags\n--fps --render --color\n--scale --cycle --smoothing\n--palette --colorblind --dither etc."]
    CFG["~/.config/termflix/config.toml\nTOML file (all keys optional)"]
    DEF["Compiled defaults\nfps=24 color=TrueColor\nanim=fire scale=1.0"]

    DEF -->|fallback| CFG
    CFG -->|fallback| CLI

    style CLI fill:#e65100,stroke:#ff9800,stroke-width:3px,color:#ffffff
    style CFG fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style DEF fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
```

All `Config` struct fields are `Option<T>` and deserialized from TOML. A missing key in the config file silently falls back to the compiled default — there is no error for an incomplete config file. `--init-config` writes a fully-commented template to the config path. `--show-config` prints the resolved active settings and file path.

**Config fields:**

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `animation` | string | `"fire"` | Default animation name |
| `render` | string | per-animation | Render mode (`braille`, `half-block`, `ascii`) |
| `color` | string | `"true-color"` | Color mode |
| `fps` | integer | `24` | Target frames per second (1–120) |
| `scale` | float | `1.0` | Particle/element count scale (0.5–2.0) |
| `clean` | bool | `false` | Hide status bar |
| `cycle` | integer | `0` | Auto-cycle interval in seconds (0 = disabled) |
| `color_quant` | integer | `0` | Color quantization step (0=off, 4/8/16=coarser) |
| `unlimited_fps` | bool | `false` | Remove FPS cap |
| `smoothing` | float | `0.0` | Temporal brightness smoothing time constant in seconds (0 = off) |
| `palette` | string | — | Colorblind-safe remap palette (`viridis`/`magma`/`inferno`/`plasma`/`okabe-ito`) |
| `colorblind` | string | — | Daltonization deficiency (`protanopia`/`deuteranopia`/`tritanopia`); mutually exclusive with `palette` |
| `dither` | bool | `false` | 4×4 Bayer ordered dithering in ANSI-256 mode |
| `data_file` | string | — | Path to ndjson external control file |
| `keybindings` | table | — | Custom keybindings (maps action names to key names) |
| `postproc.bloom` | float | `0.4` | Bloom/glow intensity (0.0–1.0) |
| `postproc.bloom_threshold` | float | `0.6` | Brightness threshold to trigger bloom (0.0–1.0) |
| `postproc.vignette` | float | `0.0` | Edge darkening (0.0–1.0) |
| `postproc.scanlines` | bool | `false` | CRT scanline effect |

> **✅ Tip:** Use `color_quant = 4` or `color_quant = 8` when running over SSH or inside tmux on a slow connection. Quantization reduces unique ANSI color codes per frame, which significantly decreases output byte volume.

---

## External Control Subsystem

The external control subsystem allows external processes to drive animation selection and visual parameters at runtime without keyboard interaction. This enables use cases such as music-reactive animations, dashboard widgets, and scripted demos.

```mermaid
sequenceDiagram
    participant EXT as External Process
    participant SRC as ParamsSource
    participant BG as Background Thread
    participant CH as mpsc::channel
    participant ML as Main Loop
    participant ST as CurrentState

    EXT->>SRC: write ndjson line to file or stdin
    SRC->>BG: file watcher event / stdin line
    BG->>BG: parse JSON line as ExternalParams
    BG->>CH: tx.send(params)
    ML->>CH: rx.try_recv() — non-blocking, once per frame
    CH->>ML: ExternalParams
    ML->>ST: ext_state.merge(params)
    ML->>ST: take_animation_change() / take_scale_change()
    ML->>ML: recreate animation if changed
    ML->>ST: ext_state.params() → passed to anim.set_params()
```

**Source selection** (evaluated once at startup, in priority order):

1. `--data-file PATH` — file watcher using the `notify` crate (kqueue on macOS, inotify on Linux)
2. Piped stdin (`!stdin.is_terminal()`) — line-buffered ndjson reader
3. Disabled — no background thread is spawned

**`ExternalParams` fields** (all optional, sent as a JSON object):

| Field | Type | Behavior |
|-------|------|---------|
| `animation` | string | One-shot: switch animation, then cleared |
| `scale` | float | One-shot: resize particle counts, then cleared |
| `render` | string | One-shot: change render mode, then cleared |
| `color` | string | One-shot: change color mode, then cleared |
| `speed` | float | Persistent: multiplies `dt` (0.1–5.0) |
| `intensity` | float | Persistent: brightness multiplier (0.0–2.0) |
| `color_shift` | float | Persistent: hue rotation (0.0–1.0) |

**Merge semantics**: `CurrentState.merge()` applies incoming params with partial-update semantics — only `Some` fields update state. One-shot fields (`animation`, `scale`, `render`, `color`) are stored as `_pending` variants and consumed via `take_*()` methods on the next frame; persistent fields (`speed`, `intensity`, `color_shift`) remain in effect until overridden by a subsequent message.

The file source reads the entire file on startup (last non-empty line), then watches for modifications and re-reads the last non-empty line on each change event. This allows external scripts to simply overwrite or append to a control file.

> **📝 Note:** `try_recv()` is called once per frame and is O(1) when idle. If the external source sends multiple messages between frames, all pending messages are drained before the frame renders.

---

## Recording Subsystem

The `--record FILE` flag captures rendered ANSI frames with millisecond timestamps. `--play FILE` replays them at the original pace, recreating the exact visual output independent of terminal animation support. `--play FILE --export-gif OUTPUT.gif` converts a recording to an animated GIF.

```mermaid
flowchart LR
    subgraph "Recording (--record)"
        REC_ANIM["Animation renders\nANSI frame string"]
        REC_CAP["Recorder.capture()\nstores frame + timestamp"]
        REC_QUIT["User presses q"]
        REC_SAVE["Recorder.save(path)\nwrites .asciianim"]
    end

    subgraph "Playback (--play)"
        PL_LOAD["Player.load(path)\nparses .asciianim"]
        PL_WAIT["thread::sleep until\ntarget timestamp"]
        PL_WRITE["stdout.write_all(frame)"]
        PL_DONE["Playback complete"]
    end

    REC_ANIM --> REC_CAP --> REC_QUIT --> REC_SAVE
    REC_SAVE -->|".asciianim file"| PL_LOAD
    PL_LOAD --> PL_WAIT --> PL_WRITE --> PL_DONE

    style REC_ANIM fill:#e65100,stroke:#ff9800,stroke-width:2px,color:#ffffff
    style REC_CAP fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style REC_QUIT fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style REC_SAVE fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style PL_LOAD fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style PL_WAIT fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style PL_WRITE fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style PL_DONE fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
```

**`.asciianim` file format:**

```
ASCIIANIM v1
FRAMES <count>
---
T <timestamp_ms>
<base64-encoded frame ANSI content>
---
T <timestamp_ms>
<base64-encoded frame ANSI content>
...
```

Frame content is base64-encoded using a self-contained implementation with no external dependencies. Base64 encoding prevents the `---` delimiter from appearing inside frame data (ANSI escape sequences are binary-safe ASCII but base64 guarantees no ambiguity).

During playback, `Player.play()` reconstructs the original timing using `thread::sleep` against each frame's recorded timestamp relative to the playback start. Pressing `q` or `Esc` during playback exits cleanly.

---

## GIF Export

The `--play FILE --export-gif OUTPUT.gif` CLI combination converts a recorded `.asciianim` file into an animated GIF. The encoder is hand-written with no external dependencies.

```mermaid
flowchart LR
    subgraph "GIF Export Pipeline"
        LOAD["Player.load(path)\nparses .asciianim"]
        DETECT["detect_recording_size()\nscan ANSI for max row/col"]
        VT["VirtualTerminal\ndecode ANSI escape\nsequences per frame"]
        QUANT["Palette quantization\n6x7x6 uniform palette\n252 colors + 4 reserved"]
        LZW["LZW compression\nvariable-width codes\nmin code size = 8"]
        WRITE["Write GIF89a\nheader + GCT +\nNETSCAPE2.0 loop\n+ frames + trailer"]
    end

    LOAD --> DETECT --> VT --> QUANT --> LZW --> WRITE

    style LOAD fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style DETECT fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style VT fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style QUANT fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style LZW fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style WRITE fill:#e65100,stroke:#ff9800,stroke-width:2px,color:#ffffff
```

**Pipeline stages:**

1. **Load and detect size** — The `.asciianim` file is loaded, and `detect_recording_size()` scans ANSI cursor-position sequences in the first frame to determine terminal dimensions.

2. **ANSI decoding** — A built-in `VirtualTerminal` processes each frame's ANSI escape sequences (cursor positioning, SGR color codes) into a grid of colored cells. BSU sync markers and other unrecognized sequences are ignored.

3. **Color quantization** — True-color RGB values are mapped to a 6x7x6 uniform palette (252 entries + 4 reserved safety colors: black, dark gray, light gray, white). Nearest-neighbor matching finds the closest palette entry.

4. **LZW compression** — Variable-width LZW encoding with a minimum code size of 8 (for the 256-entry palette). The encoder resets the code table when it reaches the 4096-entry limit.

5. **Frame deduplication** — Consecutive identical frames are merged, accumulating the delay. This significantly reduces GIF file size for animations with static periods.

6. **GIF89a output** — Writes the complete GIF89a binary: header, Logical Screen Descriptor, 256-entry Global Color Table, NETSCAPE2.0 looping extension, per-frame Graphic Control Extension + Image Descriptor + LZW data, and GIF trailer.

### Pixel-Based GIF Path (Gallery)

The `--gallery` capture pipeline does **not** go through `VirtualTerminal`. Instead, `gif::export_gif_pixels(writer, frames, width, height, scale)` accepts per-frame RGB pixel arrays directly from the canvas. This sidesteps two ANSI-decoder limitations that caused gallery GIFs to lose color:

- `VirtualTerminal` did not parse `48;…` background SGR, so the bottom pixel of every half-block cell was dropped.
- The SGR parser misread BG-RGB component zeros as the SGR-`0` reset code, clobbering the foreground to black.

For each frame, the pixel-based path computes palette indices at canvas-native resolution (used as the dedup key against the previous frame), then nearest-neighbor upscales the index buffer to `width*scale × height*scale` before LZW. Native-resolution dedup keeps cross-frame comparisons cheap, and upscaling at the index level (not the RGB level) avoids re-running palette lookup per output pixel.

### LZW Width-Bump Rule

The encoder uses the standard giflib/Pillow condition: bump width when post-add `next_code > 1 << code_width`. The reference decoder used in tests uses `>= 1 << code_width` because its add lags the encoder's by one read; both produce the same bump point in the data stream. Using `> max_code` (i.e., `> (1<<width) - 1`) on the encoder side bumps one step too early and corrupts everything past the first width transition. Roundtrip tests in `gif::tests` exercise pseudo-random data, long compressible runs, and the dictionary-fill / reset path.

---

## Gallery and Pages Deploy

The `--gallery` flag (`src/gallery.rs`) renders every animation (or a comma-separated subset) at `Canvas` resolution and writes per-animation `<name>.png` (still frame at `--gallery-wait`) and `<name>.gif` (full `--gallery-duration` clip), plus an `index.html` lightbox gallery, into `--gallery-dir` (default `./gallery`).

```mermaid
flowchart LR
    subgraph "Gallery Capture (per animation)"
        ANIM["Animation::update"]
        CANV["Canvas pixels\n(brightness + RGB)"]
        FRM["Per-frame RGB snapshot\n(width × height)"]
        PNG["png::export_png\n@ PNG_SCALE=8"]
        GIF["gif::export_gif_pixels\n@ GIF_SCALE=8"]
        HTML["generate_index_html"]
    end

    ANIM --> CANV --> FRM
    FRM -->|wait_secs frame| PNG
    FRM -->|all frames| GIF
    PNG --> HTML
    GIF --> HTML

    style ANIM fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style CANV fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style FRM fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style PNG fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style GIF fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style HTML fill:#e65100,stroke:#ff9800,stroke-width:2px,color:#ffffff
```

The pipeline is fully offscreen — no real terminal needed — so it runs cleanly on a stock `ubuntu-latest` GitHub Actions runner. The `Gallery` workflow (`.github/workflows/gallery.yml`) runs `make gallery` on every push to `main` that touches source / build files, and on manual dispatch, then publishes the output via `actions/deploy-pages`. The repo's **Settings → Pages → Build and deployment → Source** must be set to **GitHub Actions** for the deploy step to succeed.

The local `gallery/` directory is gitignored — the canonical copy lives on GitHub Pages.

---

## Generators (Shared Utilities)

`src/generators/mod.rs` provides reusable building blocks shared across many animations. Using these utilities keeps individual animation modules concise and ensures consistent physics behavior across the catalog.

```mermaid
classDiagram
    class Particle {
        +x: f64
        +y: f64
        +vx: f64
        +vy: f64
        +life: f64
        +max_life: f64
        +r: u8
        +g: u8
        +b: u8
        +age() f64
        +life_frac() f64
    }

    class ParticleSystem {
        +particles: Vec~Particle~
        +config: EmitterConfig
        -capacity: usize
        +new(config, capacity) Self
        +emit(count)
        +emit_at(x, y, vx, vy, life)
        +emit_colored(count, r_range, g_range, b_range)
        +update(dt)
        +draw(canvas)
        +draw_colored(canvas)
        +count() usize
        +clear()
    }

    class EmitterConfig {
        +x: f64
        +y: f64
        +spread: f64
        +angle: f64
        +speed_min: f64
        +speed_max: f64
        +life_min: f64
        +life_max: f64
        +gravity: f64
        +drag: f64
        +wind: f64
        +gradient: ColorGradient
    }

    class ColorGradient {
        -stops: Vec~ColorStop~
        +new(stops) Self
        +sample(t: f64) (u8,u8,u8)
    }

    class ColorStop {
        +t: f64
        +r: u8
        +g: u8
        +b: u8
    }

    ParticleSystem *-- Particle
    ParticleSystem *-- EmitterConfig
    EmitterConfig *-- ColorGradient
    ColorGradient *-- ColorStop
```

| Component | Responsibility |
|-----------|---------------|
| `Particle` | Single particle with position, velocity, lifetime, and per-particle color (r, g, b). `age()` returns 0.0 at birth, 1.0 at death. `life_frac()` is the inverse. |
| `ParticleSystem` | Manages a capped pool of particles. `emit()` spawns particles according to `EmitterConfig`. `emit_colored()` spawns particles with random per-particle color in given ranges. `update(dt)` applies gravity, drag, and wind, then removes dead particles. `draw(canvas)` writes each particle using gradient-sampled color; `draw_colored(canvas)` uses per-particle color with life-based fade. |
| `EmitterConfig` | Fully configurable emitter: position, cone spread, base angle, speed range, lifetime range, gravity, drag coefficient, wind force, and a `ColorGradient` for age-based coloring. |
| `ColorGradient` | Piecewise linear interpolation between `ColorStop` points, sampled by `t ∈ 0.0..=1.0`. Used to map particle age to color. |

**Animations using generators:** `particles`, `rain`, `fountain`, `smoke`, `campfire`, and `waterfall`.

---

## Animation Catalog

termflix ships 60 animations, organized by visual category. The `create()` factory in `animations/mod.rs` maps each name to its concrete type.

```mermaid
graph TD
    CAT["60 Animations"]

    subgraph FIRE["Fire / Fluid"]
        F1[fire] & F2[smoke] & F3[lava] & F4[campfire] & F5[waterfall] & F6[ink_in_water]
    end

    subgraph PART["Particle"]
        P1[particles] & P2[rain] & P3[fountain] & P4[fireflies] & P5[petals] & P6[sandstorm] & P7[snow] & P8[galton]
    end

    subgraph MATH["Mathematical"]
        M1[plasma] & M2[wave] & M3[ripple] & M4[pulse] & M5[spiral] & M6[mandelbrot] & M7[sierpinski] & M8[dragon] & M9[strange_attractor]
    end

    subgraph SPACE["Space"]
        S1[starfield] & S2[aurora] & S3[eclipse] & S4[blackhole] & S5[nbody] & S6[solar_system]
    end

    subgraph NATURE["Nature"]
        N1[ocean] & N2[boids] & N3[cells] & N4[life] & N5[garden] & N6[rainforest] & N7[physarum]
    end

    subgraph TECH["Tech / Retro"]
        T1[matrix] & T2[hackerman] & T3[visualizer] & T4[radar] & T5[dna] & T6[atom] & T7[globe] & T8[pendulum] & T9[newtons_cradle]
    end

    subgraph SIM["Simulation"]
        SI1[flow] & SI2[langton] & SI3[sort] & SI4[snake] & SI5[invaders] & SI6[pong] & SI7[crystallize] & SI8[lightning] & SI9[maze] & SI10[tetris] & SI11[flappy_bird]
    end

    subgraph PATTERN["Pattern"]
        PA1[automata] & PA2[metaballs] & PA3[voronoi] & PA4[reaction_diffusion]
    end

    CAT --> FIRE
    CAT --> PART
    CAT --> MATH
    CAT --> SPACE
    CAT --> NATURE
    CAT --> TECH
    CAT --> SIM
    CAT --> PATTERN

    style CAT fill:#e65100,stroke:#ff9800,stroke-width:3px,color:#ffffff
    style FIRE fill:#b71c1c,stroke:#f44336,stroke-width:2px,color:#ffffff
    style PART fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style MATH fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style SPACE fill:#1a237e,stroke:#3f51b5,stroke-width:2px,color:#ffffff
    style NATURE fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style TECH fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
    style SIM fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style PATTERN fill:#ff6f00,stroke:#ffa726,stroke-width:2px,color:#ffffff
```

**Complete catalog with descriptions:**

| Name | Category | Description |
|------|----------|-------------|
| `fire` | Fire/Fluid | Doom-style fire effect with heat propagation |
| `smoke` | Fire/Fluid | Smoke rising with Perlin turbulence |
| `lava` | Fire/Fluid | Lava lamp blobs rising, merging, and splitting |
| `campfire` | Fire/Fluid | Campfire with rising ember sparks |
| `waterfall` | Fire/Fluid | Cascading water with mist spray |
| `ink_in_water` | Fire/Fluid | Colored ink puffs dispersing through a turbulent flow field |
| `garden` | Nature | Growing garden with rain, clouds, and blooming plants |
| `particles` | Particle | Fireworks bursting with physics and fade |
| `rain` | Particle | Raindrops with splash particles and wind |
| `fountain` | Particle | Water fountain with jets, splashes, and mist |
| `fireflies` | Particle | Fireflies blinking with warm glow |
| `petals` | Particle | Cherry blossom petals drifting in wind |
| `sandstorm` | Particle | Blowing sand with dune formation |
| `snow` | Particle | Snowfall with accumulation on the ground |
| `galton` | Particle | Galton board with balls cascading into a bell-curve histogram |
| `plasma` | Mathematical | Classic plasma with overlapping sine waves |
| `wave` | Mathematical | Sine wave interference from moving sources |
| `ripple` | Mathematical | Ripple interference from random drop points |
| `pulse` | Mathematical | Expanding pulse rings from center |
| `spiral` | Mathematical | Rotating multi-arm spiral pattern |
| `mandelbrot` | Mathematical | Mandelbrot set with zoom and color cycling |
| `sierpinski` | Mathematical | Animated Sierpinski triangle with zoom |
| `dragon` | Mathematical | Dragon curve fractal with color cycling |
| `strange_attractor` | Mathematical | Lorenz strange attractor with a rotating rainbow trail |
| `starfield` | Space | 3D starfield with depth parallax |
| `aurora` | Space | Aurora borealis with layered curtains |
| `eclipse` | Space | Moon crossing sun with corona rays |
| `blackhole` | Space | Black hole with accretion disk and lensing |
| `nbody` | Space | N-body gravitational simulation with colorful orbiting masses and merging |
| `solar_system` | Space | Solar system with planets, moons, rings, and an asteroid belt |
| `ocean` | Nature | Ocean waves with foam and depth shading |
| `boids` | Nature | Boids flocking simulation with trails |
| `cells` | Nature | Cell division and mitosis animation |
| `life` | Nature | Conway's Game of Life cellular automaton |
| `rainforest` | Nature | Layered rainforest with parallax scrolling, rain, birds, and falling leaves |
| `physarum` | Nature | Physarum slime mold agents self-organizing into networks |
| `matrix` | Tech/Retro | Matrix digital rain with trailing drops |
| `hackerman` | Tech/Retro | Scrolling hex/binary hacker terminal |
| `visualizer` | Tech/Retro | Audio spectrum analyzer with bouncing bars |
| `radar` | Tech/Retro | Rotating radar sweep with fading blips |
| `dna` | Tech/Retro | Rotating DNA double helix with base pairs |
| `atom` | Tech/Retro | Electrons orbiting a nucleus in 3D |
| `globe` | Tech/Retro | Rotating wireframe Earth with continents |
| `pendulum` | Tech/Retro | Pendulum wave with mesmerizing phase patterns |
| `newtons_cradle` | Tech/Retro | Newton's cradle with energy-conserving swings |
| `flow` | Simulation | Perlin noise flow field with particle trails |
| `langton` | Simulation | Langton's Ant cellular automaton |
| `sort` | Simulation | Sorting algorithm visualizer |
| `snake` | Simulation | Self-playing Snake game AI |
| `invaders` | Simulation | Space Invaders attract mode demo |
| `pong` | Simulation | Self-playing Pong with AI paddles |
| `crystallize` | Simulation | DLA crystal growth from center seed |
| `lightning` | Simulation | Lightning bolts with recursive branching |
| `maze` | Simulation | Animated maze generation with recursive backtracking and BFS solving |
| `tetris` | Simulation | Self-playing Tetris with AI piece placement |
| `flappy_bird` | Simulation | Self-playing Flappy Bird with AI |
| `automata` | Pattern | Cellular automata cycling through multiple rulesets |
| `metaballs` | Pattern | Organic metaballs merging and splitting with smooth distance fields |
| `voronoi` | Pattern | Animated Voronoi diagram with drifting colored cells and edge detection |
| `reaction_diffusion` | Pattern | Gray-Scott reaction-diffusion coral/brain patterns |

---

## Terminal Exit Sequence

The cleanup sequence on exit is ordered carefully to prevent the terminal from appearing frozen on the last animation frame.

```mermaid
sequenceDiagram
    participant ML as Main Loop
    participant KRN as Kernel PTY
    participant TRM as Terminal

    ML->>ML: User presses q → return Ok(())
    ML->>TRM: terminal::disable_raw_mode()\n(no stdout write)
    ML->>KRN: libc::tcflush(TCIOFLUSH)\n(discard kernel PTY buffer — Unix)
    ML->>TRM: write "\x1b[?2026l"\n(close any open sync block)
    ML->>TRM: write "\x1b[?25h"\n(restore cursor)
    ML->>TRM: write "\x1b[?1049l"\n(leave alternate screen)
    ML->>ML: if TMUX env set
    ML->>TRM: tmux clear-history\n(discard pane output buffer)
    ML->>TRM: tmux refresh-client\n(force redraw from current state)
```

> **⚠️ Warning:** The `\x1b[?2026l` (End Synchronized Update) escape must be sent before the cursor restore and alternate screen exit. Every frame begins with `\x1b[?2026h` (Begin Synchronized Update). If termflix exits mid-write — for example during a blocking write in tmux — the terminal has seen the BSU begin marker but not the ESU end marker. The terminal then buffers all subsequent output, including the restore sequences, and appears frozen on the last frame. Sending `\x1b[?2026l` closes the pending sync block; it is a no-op if the terminal is not in sync mode.

**Exit cleanup steps (Unix):**

1. `terminal::disable_raw_mode()` — restores canonical mode; does not write to stdout
2. `libc::tcflush(TCIOFLUSH)` — discards pending input and output in the kernel PTY buffer
3. `\x1b[?2026l` — closes any open synchronized output block
4. `\x1b[?25h` — makes the cursor visible
5. `\x1b[?1049l` — switches back from the alternate screen buffer
6. If in tmux: `tmux clear-history` discards buffered animation frames; `tmux refresh-client` forces a redraw from current terminal state

---

## Key Design Decisions

The following design decisions reflect deliberate trade-offs made to keep the codebase simple and the runtime performant.

```mermaid
graph TD
    D1["No async runtime\nPure synchronous Rust\n+ one background thread"]
    D2["Sub-cell pixel abstraction\nAnimations unaware\nof render mode"]
    D3["Trait object dispatch\nBox&lt;dyn Animation&gt;\nfor runtime switching"]
    D4["Manual libc::write on Unix\nChunked writes with\ninter-chunk quit checks"]
    D5["event::poll as frame timer\nOS-yielding sleep\nvs thread::sleep"]
    D6["Post-process effects on canvas\napply_effects() decoupled\nfrom animation logic"]

    style D1 fill:#e65100,stroke:#ff9800,stroke-width:2px,color:#ffffff
    style D2 fill:#0d47a1,stroke:#2196f3,stroke-width:2px,color:#ffffff
    style D3 fill:#1b5e20,stroke:#4caf50,stroke-width:2px,color:#ffffff
    style D4 fill:#880e4f,stroke:#c2185b,stroke-width:2px,color:#ffffff
    style D5 fill:#4a148c,stroke:#9c27b0,stroke-width:2px,color:#ffffff
    style D6 fill:#37474f,stroke:#78909c,stroke-width:2px,color:#ffffff
```

**1. No async runtime**

termflix uses pure synchronous Rust with one optional background thread for external parameter reading. The rendering loop is CPU-bound with no I/O awaiting; an async executor would add complexity and overhead without any benefit.

**2. Sub-cell pixel abstraction**

Animations write to a flat pixel buffer using sub-cell coordinates without knowledge of the active render mode. The renderer handles all character mapping. Adding a new render mode (e.g., quarter-block at 1×4) requires changes only in `render/` — no animation code changes.

**3. Trait object dispatch**

`Box<dyn Animation>` lets the active animation be replaced at runtime (animation switch, resize) with a single pointer swap. The alternative — an `enum` with a `match` arm for each of 60 variants on every frame — would be more code and no faster for this workload.

**4. Manual `libc::write` on Unix**

The frame buffer is written to stdout via direct `libc::write()` calls in 16 KB chunks rather than through `BufWriter`. This allows the loop to check for quit key presses between chunks. Without this, a single large frame write to a full tmux pane buffer could block for hundreds of milliseconds, making the UI unresponsive.

**5. `event::poll()` as frame timer**

`crossterm::event::poll()` is used as the frame sleep mechanism rather than `thread::sleep`. This approach properly yields to the OS for signal and input handling, and it integrates naturally with the event-driven keyboard and resize handling in the same loop.

**6. Post-process effects on canvas**

`apply_effects()` runs after `update()` on the populated canvas. Animations do not need to know about or account for external intensity and hue-shift controls. This keeps animation logic focused on simulation and allows effect parameters to be changed at any time without recreating the animation.

---

## Related Documentation

- [EXTERNAL_ANIMATION.md](EXTERNAL_ANIMATION.md) — Complete reference for the external control ndjson protocol, including all parameter fields, JSON schema, and scripting examples
- [DOCUMENTATION_STYLE_GUIDE.md](DOCUMENTATION_STYLE_GUIDE.md) — Documentation standards and conventions for this project