tui-lipan 0.2.0

Opinionated, component-based TUI framework for Rust - declarative components, reconciliation, layout engine, focus, overlays, and rich widgets on top of ratatui.
Documentation
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
# Display Widgets (Read-Only)

Structural and Mermaid-style diagram widgets (`Graph`, `Flowchart`,
`SequenceDiagram`, `ClassDiagram`, `StateDiagram`, `ErDiagram`, `GanttDiagram`)
live on their own page: [Diagrams](diagrams.md).

## Text

Renders styled text.

| Prop | Type | Description |
|------|------|-------------|
| `content` | `impl Into<String>` | **Constructor** - text content |
| `spans` | `impl IntoIterator<Item = Span>` | Construct from styled spans |
| `from_ansi` | `&str` | Construct from ANSI-escaped string (SGR sequences → styled spans) |
| `style` | `Style` | Text style |
| `overflow` | `Overflow` | `Clip`, `Ellipsis`, `Wrap` |
| `width` | `Length` | Width |
| `height` | `Length` | Height |

```rust
Text::new("Hello, World!")
    .style(Style::new().fg(Color::Cyan).bold())
    .overflow(Overflow::Ellipsis)
```

```rust
// Render ANSI-styled output (ls --color, compiler errors, git diff, etc.)
Text::from_ansi("\x1b[31merror\x1b[0m: file not found")
```

---

## DocumentView

Read-only rich document renderer with pluggable formatting.

Use it for markdown previews, formatted logs, and custom read-only views where
display content differs from source text.

| Prop | Type | Description |
|------|------|-------------|
| `value` | `impl Into<Arc<str>>` | **Constructor** - source text |
| `content_type` | `Arc<str>` | Optional formatter hint (`"markdown"`, etc.) |
| `formatter` | `impl ContentFormatter` | Custom formatting strategy |
| `width` | `Length` | Width |
| `height` | `Length` | Height |
| `wrap` | `bool` | Word-wrap long lines (default: `true`) |
| `border` | `bool` | Show/hide outer border (default: `true`) |
| `border_style` | `BorderStyle` | Border glyph variant |
| `hover_border_style` | `BorderStyle` | Border variant when hovered |
| `padding` | `impl Into<Padding>` | Inner padding |
| `table_wrap` | `bool` | Wrap table cell text within column width |
| `table_width_mode` | `DocumentTableWidthMode` | `Content` (natural) or `Fill` (stretch to viewport) |
| `table_outer_frame` | `bool` | Show/hide outer table frame |
| `table_inner_frame` | `bool` | Show/hide inner table separators |
| `table_cell_padding` | `u16` | Horizontal padding inside each table cell |
| `table_border_variant` | `BorderStyle` | Table border glyph variant (`Plain`, `Rounded`, `Double`, etc.) |
| `table_border_style` | `Style` | Style for table borders (color/emphasis) |
| `line_numbers` | `bool` | Source-mapped line-number gutter |
| `line_number_mode` | `DocumentLineNumberMode` | `Visual` (visual row index) or `Source` (source line mapping) |
| `min_line_number_width` | `u8` | Minimum line-number gutter digit width |
| `line_number_separator` | `bool` | Show/hide built-in line-number separator (`" │ "`, default: `true`) |
| `line_number_content_gap` | `u16` | Empty cells between built-in line numbers and content |
| `line_number_style` | `Style` | Style override for built-in line-number gutter text |
| `gutter_inset` | `u16` | Empty cells before the gutter / line numbers |
| `style` | `Style` | Base style |
| `hover_style` | `Style` | Hover style |
| `extend_hover_style` / `inherit_hover_style` | `Style` / `()` | Extend or inherit the hover theme role instead of replacing it |
| `focus_style` | `Style` | Focus chrome style |
| `extend_focus_style` / `inherit_focus_style` | `Style` / `()` | Extend or inherit the focus theme role instead of replacing it |
| `focus_content_style` | `Style` | Text content style when focused |
| `selection_style` | `Style` | Text selection style |
| `extend_selection_style` / `inherit_selection_style` | `Style` / `()` | Extend or inherit the text-selection theme role instead of replacing it |
| `highlight_full_width` | `bool` | Extend per-line background highlights across full content width |
| `doc_styles` | `DocumentStyles` | Element styles (heading/code/link/table/hr/diagram/etc.) |
| `code_block_style` | `Style` | Shortcut - sets `doc_styles.code_block_style` |
| `scroll_offset` | `usize` | Controlled vertical scroll offset |
| `scroll_to_source_line` | `usize` | Scroll-sync target source line |
| `scroll_behavior` | `ScrollBehavior` | `Instant` by default; opt into smooth `scroll_to_source_line` movement |
| `scroll_transition` | `TransitionConfig` | Shortcut for smooth source-line target movement |
| `scrollbar` | `bool` | Show/hide vertical scrollbar |
| `scrollbar_config` | `ScrollbarConfig` | Full scrollbar configuration (variant, gap, thumb, thumb styles) |
| `h_scrollbar` | `bool` | Show/hide horizontal scrollbar (only when `wrap` is `false`) |
| `scroll_wheel` | `bool` | Enable/disable mouse wheel scrolling (default: `true`) |
| `scroll_wheel_multiplier` | `u16` | Override the app-wide wheel line multiplier for this DocumentView |
| `focusable` | `bool` | Accept keyboard focus (default: `true`); mouse selection and copy shortcuts still work when false |
| `tab_stop` | `bool` | Participate in Tab/Shift-Tab traversal while focusable (default: `true`) |
| `on_focus` / `on_blur` | `Callback<()>` | Focus transition callbacks |
| `on_scroll` | `Callback<ScrollEvent>` | Scroll callback |
| `on_click` | `Callback<DocumentClickEvent>` | Click callback with source-line mapping |
| `on_select` | `Callback<DocumentSelectEvent>` | Text selection callback |
| `on_key` | `KeyHandler` | Focused keyboard handler |
| `shared_selection_id` | `Arc<str>` | Group id for cross-DocumentView selection/copy within the same `ScrollView` |
| `triple_click_mode` | `TripleClickSelectionMode` | Triple-click selects a visual line or paragraph |

`DocumentView` ships with `PlainFormatter` by default.

For inline/message-style read-only blocks, wrapped `DocumentView` now
implicitly behaves like `height: Length::Auto` when all of these are true:
default `height: Flex(1)`, `wrap: true`, `scrollbar: false`,
`h_scrollbar: false`, and `focusable: false`. Set `.height(...)`
explicitly when you want viewport-style behavior instead.

When using `DocumentView::markdown()`, default heading/link/code/table styling is
derived from `Theme::document`. Use `.doc_styles(...)` or a custom
`MarkdownFormatter::styles(...)` only when you want local overrides.

With feature `markdown`, you can use `MarkdownFormatter`:

```rust
DocumentView::new("# Hello\n\n| A | B |\n|---|---|\n| 1 | 2 |")
    .markdown()
    .line_numbers(true)
    .wrap(true)

DocumentView::new(text)
    .markdown_compact(true)
```

`.markdown_compact(true)` drops blank source lines between blocks so the
rendered output sits flush against itself. Useful for tight previews such as
chat bubbles, list cells, or narrow side panels where vertical space matters.
`.markdown()` preserves blank lines and adds a spacer before adjacent fenced
code/diagram blocks for readable paragraph spacing. The toggle is also
available directly on the formatter as
`MarkdownFormatter::compact_blocks(bool)`.

Mermaid fenced blocks render as diagrams for the supported subset: flowchart,
sequence, class, state, ER, pie, and gantt diagrams. To build any of these as a
widget directly (rather than from a markdown fence), see the
[Diagrams](diagrams.md) page. Flowcharts tolerate `subgraph`
grouping and standalone node declarations. Sequence diagrams support
participants, actors, arrow messages, self messages, `loop` grouping, and
single-line `Note over`, `Note left of`, or `Note right of` notes. State
diagrams tolerate composite `state ... { ... }` grouping. Gantt diagrams support
`title`, `dateFormat YYYY-MM-DD`, `section`, task ids, explicit `YYYY-MM-DD`
starts, `after <id>` dependencies, day durations, and `crit` / `active` /
`done` / `milestone` tags. Diagram cells inherit `code_block_style`, then apply
the diagram-specific `DocumentStyles` slots
(`diagram_node_fill_style`, `diagram_node_border_style`,
`diagram_node_label_style`, and `diagram_edge_style`) for flowchart, sequence,
class, state, and ER diagrams. Gantt diagrams use label, primary/border, edge,
and muted diagram slots, then derive foreground-only status task-bar shades from
the primary/border color with the shared gradient utility so `pending`,
`active`, `done`, `critical`, and `milestone` rows remain distinguishable inside
themed markdown previews without painting a task-area background. Pie diagrams
are text-only and inherit only the code-block style.
Flowcharts support Mermaid `style` directives for opaque hex
`fill`, `stroke`, and `color` node colors; those explicit directives override
the diagram theme slots. Alpha hex in Mermaid style directives is rejected for
now rather than silently ignored.

To render mermaid fences as plain code blocks instead of diagrams, call
`.render_diagrams(false)` on a `DocumentView` already configured with
`.markdown()` / `.markdown_compact(...)`. The setting also exists directly on
`MarkdownFormatter::render_diagrams(bool)` for callers wiring a custom
formatter. Default is `true`.

```rust
DocumentView::new(markdown_source)
    .markdown()
    .render_diagrams(false)
```

Keyboard scrolling (when focused): arrows, `j/k`, `PageUp/PageDown`, `Home/End`.

Under the default `FocusPolicy::OnDemand`, `DocumentView` remains focusable but is not focused at
startup. Tab, pointer focus, or `request_focus(key)` establishes focus. Use `.tab_stop(false)` to
keep explicit/pointer access while omitting the view from traversal.

`scroll_to_source_line(...)` maps the zero-based source line to the first matching
wrapped visual row. Add `.scroll_behavior(ScrollBehavior::smooth_default())`,
`.scroll_behavior(ScrollBehavior::smooth_adaptive())`, or `.scroll_transition(config)`
to animate that programmatic target; adaptive timing derives duration from row
distance and caps long jumps. Controlled `scroll_offset`, mouse wheel/key scrolling,
and scrollbar drag remain immediate and cancel any active smooth target.

Mouse text selection is independent of focusability: `.focusable(false)` removes
the widget from focus traversal while still allowing drag selection. Wheel events
bubble to ancestor scroll containers when `.scroll_wheel(false)` is set or when
the `DocumentView` content is not clipped.

Use `.scroll_wheel_multiplier(lines)` when one `DocumentView` should scroll a
different number of lines per wheel tick than the app-wide
`App::scroll_wheel_multiplier(...)` setting.

Table drag-selection supports rectangular selection by row/column and copies as TSV.

When sibling `DocumentView` widgets under the same `ScrollView` share
`shared_selection_id`, linear drag selection can continue across widget
boundaries and shared copy concatenates text in visual order with newline
separators between document boundaries.

### Custom markdown styles

`DocumentStyles` controls per-element colors. Default values come from `Theme::document`
automatically - only set this when you want widget-local overrides.

```rust
// Via DocumentView::doc_styles - applies to any formatter
DocumentView::new(text)
    .markdown()
    .doc_styles(DocumentStyles {
        heading_styles: [
            Style::new().bold().fg(Color::Cyan),   // h1
            Style::new().bold().fg(Color::Blue),   // h2
            Style::new().bold().fg(Color::Green),  // h3
            Style::new().bold(),                   // h4
            Style::new().bold(),                   // h5
            Style::new().bold().dim(),             // h6
        ],
        link_style: Style::new().fg(Color::Blue).underline(),
        code_inline_style: Style::new().fg(Color::Green),
        code_block_style: Style::new().bg(Color::rgb(0x1E, 0x1E, 0x1E)),
        emphasis_style: Style::new().italic(),
        strong_style: Style::new().bold(),
        strikethrough_style: Style::new().strikethrough(),
        blockquote_bar_style: Style::new().fg(Color::DarkGray),
        table_border_style: Style::new().fg(Color::DarkGray),
        table_header_style: Style::new().bold(),
        hr_style: Style::new().fg(Color::DarkGray).dim(),
        list_item_style: Style::new().fg(Color::Blue).bold(),
        list_enumeration_style: Style::new().fg(Color::Blue).bold(),
        diagram_node_fill_style: Style::new().bg(Color::rgb(0x12, 0x18, 0x22)),
        diagram_node_border_style: Style::new().fg(Color::Cyan),
        diagram_node_label_style: Style::new().fg(Color::White),
        diagram_edge_style: Style::new().fg(Color::LightBlue),
    });

// Via MarkdownFormatter::styles - same effect, explicit formatter path
DocumentView::new(text)
    .formatter(MarkdownFormatter::default().styles(DocumentStyles {
        strong_style: Style::new().bold().fg(Color::Yellow),
        ..DocumentStyles::default()  // theme fills in the rest automatically
    }));
```

### Syntax highlighting in code blocks *(requires feature `syntax-syntect`)*

```rust
DocumentView::new(text)
    .markdown()
    .code_syntax_strategy(SyntectStrategy::default().default_theme("One Dark (Atom)"))
// .markdown() already sets this default theme; override here to use a different one
```

For broad grammar coverage, enable `syntax-extra` in addition to
`syntax-syntect`. It supplies the bat-curated syntax set, including TOML,
TypeScript/TSX, Dockerfile, Vue, Zig, and Terraform.

---

## AsciiCanvas

Renders ASCII art as text lines, cell grids, or multi-frame sprite sheets.

### Constructors

```rust
// Line-based content
AsciiCanvas::new(["Line 1", "Line 2"])

// Cell grid
AsciiCanvas::from_cells(width, height, cells)

// Blank grid for programmatic fill
AsciiCanvas::blank(width, height)

// Generated grid
AsciiCanvas::with_cell_fn(width, height, |x, y| AsciiCanvasCell { ch, fg, bg })

// Multi-frame sprite sheet
AsciiCanvas::from_sequence(Arc::new(frame_sequence))
```

### Frame Sequence API

```rust
let seq = Arc::new(FrameSequence::from_json(&json_str).unwrap());

let canvas = AsciiCanvas::from_sequence(seq.clone())
    .frame(0)                            // Select frame by index
    // or: .frame_by_tag("key", "value") // Select frame by tag
```

`FrameSequence::from_json()` parses ASCII Motion export format. Supports `foreground` and `background` color maps.

### Color Remapping

```rust
// Unified map (both fg and bg)
let colors = seq.collect_colors();         // All unique colors, fg+bg merged
let canvas = AsciiCanvas::from_sequence(seq)
    .color_map(vec![
        (colors[0], theme.highlight.fg.unwrap_or(Color::White)),
        (colors[1], Color::hex("#3A3A3A")),
    ]);

// Per-channel maps (when same hex appears in both fg and bg)
let fg_colors = seq.collect_fg_colors();   // Unique foreground colors
let bg_colors = seq.collect_bg_colors();   // Unique background colors
let canvas = AsciiCanvas::from_sequence(seq)
    .fg_color_map(vec![(fg_colors[0], Color::White)])
    .bg_color_map(vec![(bg_colors[0], Color::Black)]);
```

Per-channel maps take precedence over the unified map for their respective channel.

### Props

| Prop | Type | Description |
|------|------|-------------|
| `lines` | `Vec<String>` | **Constructor** - line-based content |
| `style` | `Style` | Base style |
| `background` | `Style` | Background-only style |
| `color_map` | `Vec<(Color, Color)>` | Unified fg+bg remap |
| `fg_color_map` | `Vec<(Color, Color)>` | Foreground-only remap |
| `bg_color_map` | `Vec<(Color, Color)>` | Background-only remap |
| `grid_size` | `(u16, u16)` | Grid dimensions (must match cells count) |
| `width` | `Length` | Width |
| `height` | `Length` | Height |

---

## BigText *(requires feature `big-text`)*

Large text rendered with ASCII or pixel fonts.

| Prop | Type | Description |
|------|------|-------------|
| `text` | `impl Into<RichText>` | Text content, set with `.text(...)`; accepts `Vec<Span>` for multicolor |
| `font` | `BigFont` | Font choice (see below) |
| `style` | `Style` | Base style (per-span styles override this) |
| `shadow` | `impl Into<Option<Shadow>>` | Shadow configuration |
| `with_shadow` | `Style` | Quick shadow with given style |
| `custom_figlet` | `impl Into<Arc<str>>` | Custom `.flf` FIGlet font content |
| `custom_figlet_from_file` | `impl AsRef<Path>` | Load custom `.flf` FIGlet font content from a file |
| `gradient` | `ColorGradient`, `GradientDirection` | Render-time color gradient |

`BigText::new()` creates an empty auto-sized widget; set content with `.text(...)`.
It does not expose `width` or `height` builder setters.

**FIGlet fonts**: `Standard`, `Slant`, `Bloody`, `Colossal`, `Roman`, `SubZero`, `Poison`, `Nancyj`, `SmallPoison`, `DosRebel`, `AnsiShadow`, `Small`, `CustomFiglet`

**Pixel fonts**: `Pixel` (8x8 half blocks), `PixelBold`, `Quadrant` (2×2 block mapping)

```rust
BigText::new()
    .text("Hello")
    .font(BigFont::AnsiShadow)
    .style(Style::new().fg(Color::Cyan))

// Multicolor via spans
BigText::new()
    .text(vec![
        Span::new("open").fg(Color::Cyan),
        Span::new("code").fg(Color::White),
    ])
    .font(BigFont::Standard)

// Render-time gradients
BigText::new()
    .text("FIRE")
    .font(BigFont::SubZero)
    .gradient(
        ColorGradient::new(Color::Yellow, Color::rgb(200, 0, 0)),
        GradientDirection::Vertical,
    )
```

> FIGlet smushing does not cross span boundaries in multicolor mode.

---

## QrCode *(requires feature `qr-code`)*

Scannable QR code rendered as terminal cells.

Use it to hand something off to a phone: device-flow login URLs, WiFi
credentials, TOTP enrollment secrets, or a link the user wants on another screen.

| Prop | Type | Description |
|------|------|-------------|
| `data` | `impl Into<Arc<str>>` | **Constructor** - payload to encode |
| `ecc` | `QrEcc` | Error correction level (default: `Medium`) |
| `render` | `QrRender` | Module-to-cell mapping (default: `HalfBlock`) |
| `quiet_zone` | `u16` | Light margin in modules (default: `4`, capped at `32`) |
| `dark` | `Color` | Dark module color (default: `Color::Black`) |
| `light` | `Color` | Light module color (default: `Color::White`) |
| `invert` | | Swap `dark` and `light` |
| `fallback` | `impl IntoElement` | Rendered when the payload exceeds QR capacity |

Two read-only helpers report the symbol's fixed geometry: `module_count()` gives
the symbol width in modules, and `size()` gives the `(width, height)` cell
footprint including the quiet zone. Both return `None` when the payload is too
long to encode.

```rust
QrCode::new("https://tui-lipan.dev")
    .ecc(QrEcc::Quartile)
```

**Render modes**: terminal cells are roughly twice as tall as they are wide, so a
naive one-cell-per-module symbol comes out at a 1:2 aspect ratio that most
scanners reject. Both modes correct for this and differ only in module size:

| Mode | Footprint for `n` modules | Notes |
|------|---------------------------|-------|
| `HalfBlock` | `n` x `n / 2` cells | Compact. Two module rows per cell row via `▀ ▄ █` |
| `Wide` | `2n` x `n` cells | Twice the physical size, reads better on low-resolution cameras |

### Sizing

Unlike every other widget, a QR symbol cannot reflow - its size is fixed by the
payload length and error correction level. A clipped symbol still *looks* like a
QR code but will not scan, so check `size()` against the viewport and substitute
a fallback when the terminal is too small:

```rust
let qr = QrCode::new("https://tui-lipan.dev");
let viewport = ctx.viewport();

match qr.size() {
    Some((w, h)) if w <= viewport.w && h <= viewport.h => qr.into(),
    _ => Text::new("https://tui-lipan.dev").into(),
}
```

> Scanners expect dark modules on a light background, so the default styling
> paints explicit black on white rather than inheriting the terminal palette. A
> symbol rendered on a dark background is inverted and many readers will not
> decode it - reach for `invert()` only when you know the target scanner handles
> it.

See `cargo run --example qr_code --features qr-code`.

---

## Image *(requires feature `image`)*

Protocol-aware image rendering.

The default `image` feature enables PNG, JPEG, GIF, and WebP codecs to keep app
binary size modest. Add `image-full-formats` if your app needs the broader
`image` crate default codec set, such as AVIF, BMP, DDS, EXR, HDR, ICO, PNM,
QOI, TGA, or TIFF.

| Prop | Type | Description |
|------|------|-------------|
| `src` | `impl Into<String>` | **Constructor** - image file path |
| `bytes` | `Arc<[u8]>` | In-memory image (use `Image::from_bytes(...)`) |
| `fit` | `ImageFit` | `Contain` (default), `Crop`, `Scale` |
| `protocol` | `ImageProtocol` | `Auto`, `Kitty`, `Iterm2`, `Sixel`, `Halfblocks` |
| `style` | `Style` | Container style |
| `alt` | `String` | Alt text shown when protocol fails |
| `playback` | `ImagePlayback` | `Playing`, `Paused` |
| `repeat` | `ImageRepeat` | `Loop`, `Once` |
| `speed_percent` | `u32` | Animation speed percentage |
| `width` | `Length` | Width |
| `height` | `Length` | Height |

```rust
Image::new("logo.png")
    .fit(ImageFit::Contain)
    .alt("Company Logo")

// From memory
let bytes: Arc<[u8]> = load_image_bytes();
Image::from_bytes(bytes)
    .protocol(ImageProtocol::Auto)
```

**Animation** (GIF, animated WebP, APNG): advance automatically.
- Small GIFs are preloaded; large GIFs use a bounded worker channel.
- Controls via `playback`, `repeat`, `speed_percent`.

**Environment knobs:**

| Variable | Default | Description |
|----------|---------|-------------|
| `TUI_LIPAN_IMAGE_MAX_FPS` | `30` | Frame rate cap |
| `TUI_LIPAN_IMAGE_MAX_CATCHUP_MS` | `100` | Frame catch-up window |
| `TUI_LIPAN_IMAGE_AUTO_ANIM_HALF_BLOCKS` | `false` | Allow halfblocks for animations |
| `TUI_LIPAN_IMAGE_ENCODE_WORKERS` | `1` | Async encoding workers (1–2) |
| `TUI_LIPAN_IMAGE_GIF_PRELOAD` | `true` | Eager GIF preloading |
| `TUI_LIPAN_IMAGE_GIF_PRELOAD_MAX_BYTES` | `262144` | Preload size cap |
| `TUI_LIPAN_IMAGE_GIF_PRELOAD_MAX_FRAMES` | `24` | Preload frame cap |
| `TUI_LIPAN_IMAGE_GIF_PRELOAD_BUDGET_MS` | `16` | Preload time cap |
| `TUI_LIPAN_IMAGE_GIF_WORKER_QUEUE` | `2` | Worker queue size (1–4) |
| `TUI_LIPAN_IMAGE_RESIZE_PAUSE_MS` | `180` | Pause rendering during resize |
| `TUI_LIPAN_IMAGE_LAYOUT_STABILIZE_MS` | `120` | Pause while layout changes |

---

## Sparkline

Minimal inline chart for time-series data.

| Prop | Type | Description |
|------|------|-------------|
| `data` | `Arc<[u64]>` / `data_arc` | **Constructor** - data points (`data(...)` collects into `Arc`; prefer `data_arc` when sharing) |
| `variant` | `SparklineVariant` | `Bars` (default), `Braille`, `Line` |
| `min` | `Option<u64>` | Data minimum (auto if None) |
| `max` | `Option<u64>` | Data maximum (auto if None) |
| `chart_height` | `u16` | Multi-row height for Bars/Braille/Line |
| `mirror_x` | `bool` | Reverse sample order (time axis) |
| `mirror_y` | `bool` | Flip vertical direction |
| `max_points` | `Option<usize>` | Cap rendered width (enables downsampling) |
| `aggregation` | `SparklineAggregation` | `Average`, `Min`, `Max`, `First`, `Last` |
| `zero_policy` | `SparklineZeroPolicy` | `MinGlyph` for baseline on zeros |
| `gradient` | `ColorGradient` | Map values → RGB colors |
| `height_gradient` | `ColorGradient` | Map row position → RGB colors |
| `gradient_range` | `GradientRange` | Normalize gradient range |
| `style` | `Style` | Base style |
| `rising_style` | `Style` | Style for rising values |
| `falling_style` | `Style` | Style for falling values |
| `overflow` | `Overflow` | Default: `Ellipsis`; use `ClipStart` for live charts |
| `width` | `Length` | Width |
| `height` | `Length` | Height |

```rust
Sparkline::new(metrics.clone())
    .variant(SparklineVariant::Braille)
    .chart_height(4)
    .overflow(Overflow::ClipStart)   // Keep newest data visible
    .gradient(ColorGradient::new(vec![
        (0.0, Color::Green),
        (1.0, Color::Red),
    ]))
```

---

## Chart

Multi-series chart with axes, legend, thresholds, and viewport windowing.

`ChartSeriesMode::Line` draws one glyph per terminal cell, `Braille` uses a dense 2x4 subcell
grid, and `Bars` draws vertical columns. Custom `point_char` and `line_char` apply to `Line` mode.

| Prop | Type | Description |
|------|------|-------------|
| `series` | `Arc<[ChartSeries]>` / `series_arc` | Data series (`series(...)` collects into `Arc`; prefer `series_arc` when sharing) |
| `x_axis` | `ChartAxis` | X axis configuration |
| `y_axis` | `ChartAxis` | Y axis configuration |
| `thresholds` | `Vec<ChartThreshold>` | Horizontal threshold lines |
| `viewport_start` | `Option<usize>` | Start sample index for zoom |
| `viewport_len` | `Option<usize>` | Number of samples to show |
| `show_legend` | `bool` | Show series legend |
| `show_grid` | `bool` | Show background grid |
| `legend_style` | `Style` | Legend style |
| `grid_style` | `Style` | Grid style |
| `legend_separator` | `char` | Legend entry separator |
| `border` | `bool` | Draw border |
| `border_style` | `BorderStyle` | Border appearance |
| `padding` | `impl Into<Padding>` | Inner padding |
| `width` | `Length` | Width |
| `height` | `Length` | Height |

```rust
let series = ChartSeries::new("CPU", cpu_data)
    .mode(ChartSeriesMode::Braille)
    .style(Style::new().fg(Color::Cyan));

Chart::new()
    .series(vec![series])
    .show_legend(true)
    .show_grid(true)
    .thresholds(vec![ChartThreshold::new(80.0).style(Style::new().fg(Color::Red))])
```

### Axis tick labels

An axis labels its ends with sample indices by default (`0` … `59`). When the samples stand for
something else — clock times, dates, bucket names — pass the labels explicitly:

```rust
Chart::new()
    .series(vec![series])
    .x_axis(ChartAxis::new().tick_labels([
        "22:40:00", "22:40:15", "22:40:30", "22:40:45", "22:40:59",
    ]))
```

Labels spread evenly across the axis: the first sits flush at the low end, the last flush at the
high end, the rest centre on their fractional position. A label that would collide with the one
before it is skipped, so the same list thins from five entries to three in a narrow plot rather
than overprinting. `tick_labels` works on `y_axis` too, where labels run bottom-to-top and are
right-aligned in the axis gutter.

---

## Heatmap

2D matrix visualization with gradient-colored cells or glyphs.

| Prop | Type | Description |
|------|------|-------------|
| `data` | `Vec<Vec<f64>>` | **Constructor** - 2D matrix of values |
| `row_labels` | `Vec<impl Into<Arc<str>>>` | Labels on the left side |
| `column_labels` | `Vec<impl Into<Arc<str>>>` | Labels on top |
| `gradient` | `ColorGradient` | Color gradient for value mapping |
| `range` | `(f64, f64)` | Explicit min/max for gradient normalization |
| `cell_mode` | `HeatmapCellMode` | `Background`, `Glyph(Arc<str>)`, or `GlyphForeground(Arc<str>)` |
| `cell_width` | `u16` | Character width per cell (default: 4) |
| `gap_x` | `u16` | Horizontal gap between cells in characters |
| `gap_y` | `u16` | Vertical gap between heatmap rows in lines |
| `legend_gap` | `u16` | Horizontal gap between legend markers/swatches |
| `legend_spacing` | `u16` | Vertical gap between the heatmap grid and legend |
| `legend_width` | `HeatmapLegendWidth` | Legend alignment: grid width or full inner width |
| `show_values` | `bool` | Display numeric values in cells |
| `show_legend` | `bool` | Show gradient legend below |
| `style` | `Style` | Base style |
| `label_style` | `Style` | Row/column label style |
| `legend_style` | `Style` | Legend style |
| `padding` | `impl Into<Padding>` | Inner padding |
| `border` | `bool` | Draw border |
| `border_style` | `BorderStyle` | Border appearance |
| `width` | `Length` | Width |
| `height` | `Length` | Height |

Use `HeatmapCellMode::Glyph(...)` when you want a repeated glyph texture with colored tile
backgrounds, or `HeatmapCellMode::GlyphForeground(...)` when you want only the glyph colored and
the background left untouched. Both glyph modes accept strings like `" "` as well as single
characters. `gap_x` and `gap_y` work in all modes and make sparse glyph layouts much easier to
read. `legend_gap` separates legend markers from each other, `legend_spacing` adds space between
the heatmap grid and the legend itself, and `legend_width(HeatmapLegendWidth::Full)` lets the
legend ignore the row-label gutter and stretch across the full inner width.

```rust
let data = vec![
    vec![10.0, 25.0, 40.0, 55.0],
    vec![20.0, 35.0, 50.0, 65.0],
    vec![30.0, 45.0, 60.0, 75.0],
];

Heatmap::new(data)
    .row_labels(["Low", "Med", "High"])
    .column_labels(["Q1", "Q2", "Q3", "Q4"])
    .gradient(ColorGradient::new(Color::Rgb(60, 179, 113), Color::Rgb(226, 82, 87)))
    .range(0.0, 100.0)
    .cell_mode(HeatmapCellMode::GlyphForeground(" ".into()))
    .gap_x(1)
    .gap_y(1)
    .legend_gap(1)
    .legend_spacing(1)
    .legend_width(HeatmapLegendWidth::Full)
    .show_legend(true)
    .border(true)
```