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
# Quick Start

## Introduction

**tui-lipan** is an opinionated, component-based TUI framework for Rust, inspired by React and Elm.

Key characteristics:
- **Declarative UI** - builder API + `ui!` macro (with full autocomplete), plus optional `rsx!`.
- **Component model** - properties, local state, and message-based updates.
- **Flexbox-like layout** - sensible defaults, no raw coordinate math.
- **Rich widget set** - Frames, Tabs, Lists, Inputs, Tables, Modals, and more.

## Import Map

```rust
// Recommended: start here for typical app-author code
use tui_lipan::prelude::*;
```

The prelude is intentionally curated for app authors. It re-exports the common
component/runtime types, styling primitives, macros, and a broad set of
user-facing widgets and widget event types. For framework internals or unusual
helpers, prefer explicit imports from `tui_lipan`.

Representative `prelude::*` re-exports:

| Symbol | Category |
|--------|----------|
| `App`, `AppRunner`, `ContrastPolicy`, `TextAreaNewlineBinding` | App runner |
| `Component`, `Context`, `Update`, `Command`, `Breakpoint`, `KeyUpdate`, `TaskPolicy` | Component trait |
| `Element`, `IntoElement`, `Key` | Tree primitives |
| `Callback`, `CommandLink`, `KeyHandler`, `Link` | Messaging |
| `KeyCode`, `KeyEvent`, `KeyMods`, `MouseEvent`, `MouseMoveEvent` | Events |
| `KeyBinding`, `KeyBindings` | Common keybinding types |
| `Style`, `Color`, `Length`, `Padding`, `Align`, `Justify`, `BorderStyle`, `BorderEdges`, `CaretPalette`, `CaretShape` | Styling |
| `RichText`, `Span`, `Edge`, `Rect`, `Size`, `ScrollbarConfig`, `ScrollbarVariant` | Layout & text types |
| `Theme`, `ColorGradient`, `GradientDirection`, `GradientRange`, `VisualEffect`, `RippleRadius`, `RetroPreset` | Themes & effects |
| `ClipboardConfig`, `PasteShiftInsertBehavior` | Clipboard config |
| `TextEditor`, `TextInput`, `TextEditEvent`, `TextEditKind` | Text editing |
| `word_forward_start`, `word_end`, `line_start_at`, `first_nonblank_in_line`, … (`text_motion` module) | Vim-style word/line text motion helpers |
| `OverlayId`, `OverlayScope`, `ToastHandle`, `ToastPlacement` | Overlays |
| `App`, `CommandEntry`, `CommandRegistry` | App commands |
| `FrameworkAction`, `FrameworkKeymap`, `KeyDispatchPolicy`, `TerminalKeyPolicy`, `UserKeymapPolicy`, `CommandConflictPolicy`, `ChordMismatchPolicy` | Layered key dispatch |
| `child`, `mockup!`, `rsx!`, `ui!` | Macros & helpers |
| `VStack`, `HStack`, `ZStack`, `Canvas`, `Frame`, `Button`, `Text`, `Input`, `List`, `Tabs`, `Table`, `Modal`, `TextArea`, `Tree`, `DocumentView`, `FileTree`, `Animated`, `AsciiCanvas` | Common and advanced widgets |

The prelude no longer re-exports broad internal modules like `core`, `utils`,
or `widgets::*` wholesale.

Extra imports **not** in `prelude::*`:

```rust
// Clipboard image support (requires feature "image" or "clipboard-images")
use tui_lipan::{ImageContent, ImageFormat, ClipboardProvider, ClipboardError};

// Lower-level framework or specialized APIs
use tui_lipan::NodeId;
```

## Feature Flags

```toml
[dependencies]
tui-lipan = { version = "*", features = ["image", "big-text"] }
```

| Feature | Default | What it enables |
|---------|---------|-----------------|
| `clipboard` | **Yes** | System clipboard via arboard (X11/Wayland/macOS/Windows) |
| `devtools` | No | In-app DevTools overlay (`F12` by default, rebindable) with frame stats and debug log console; controllable from `Context` and configurable via `DevToolsConfig` |
| `ui-snapshot-json` | No | JSON export for `UiSnapshot::to_json()` (markdown export is always available) |
| `ui-snapshot-png` | No | Font-backed PNG export for `UiSnapshot::to_png()` / `to_png_default()` and `CapturedFrame::to_png()` |
| `clipboard-images` | No | Image clipboard read/write (without `Image` rendering widget) |
| `big-text` | No | Large ASCII/pixel text via FIGlet and pixel fonts - `BigText` |
| `diff-view` | No | Side-by-side/unified diff viewer - `DiffView` |
| `image` | No | Protocol-aware image rendering (Kitty, iTerm2, Sixel, halfblocks) with PNG/JPEG/GIF/WebP codecs - includes `clipboard-images` |
| `image-full-formats` | No | Restores the broad `image` crate default codec set for `image`, `clipboard-images`, or `ui-snapshot-png` builds |
| `hints-regex` | No | `regex-lite` scanner for string-configured custom text hints; the dependency-free `HintScanner` trait and URL/path/Git scanners are always available |
| `markdown` | No | Markdown formatter for `DocumentView` + markdown preview example |
| `qr-code` | No | Scannable QR symbols rendered as terminal cells - `QrCode` |
| `profiling-tracing` | No | `tracing` spans/events around render loop and `DocumentView` formatting/reconcile hot paths |
| `syntax-syntect` | No | Lightweight syntax highlighting in `TextArea`, `DocumentView`, and `DiffView` via syntect; WASM uses pure-Rust `fancy-regex` |
| `syntax-extra` | No | Opt-in bat-curated syntax set with broad grammar coverage; adds about 0.6 MiB and includes `syntax-syntect` |
| `terminal` | No | Embedded PTY / terminal viewport - `Terminal`, `ManagedTerminal` |
| `terminal-images` | No | Kitty graphics in terminal panes: the program running in a pane can draw images, whatever the host terminal supports; includes `terminal` and `image` |
| `terminal-serde` | No | Serde derives for terminal snapshot leaf style/mouse types used by external, versioned snapshot transports; includes `terminal` |
| `theme-reload` | No | Live reload of TOML theme files without restarting the app - see [Styling](styling.md) |
| `web` | No | Browser/WASM backend - see [Web / WASM Backend](web-backend.md) |

`HintScanner` is the primary custom-hint interface and does not require a
feature. Enable `hints-regex` only when config needs patterns as strings. An app
that already depends on a different `regex-lite` version may compile a second
copy until its config layer also uses the framework scanner.

### Profiling with `tracing`

Enable instrumentation:

```toml
tui-lipan = { version = "*", features = ["markdown", "profiling-tracing"] }
```

Then install any standard `tracing` subscriber in your app binary (for example
`tracing-subscriber`, `tracing-tracy`, or OpenTelemetry exporters). tui-lipan emits
spans/events for frame loop, draw, and `DocumentView` formatting/reconcile hot paths.
See [Performance](perf.md) for update-scope guidance, DevTools diagnostics, and
repeatable benchmarks.

To **disable** clipboard (e.g. for minimal no-system-dep builds):

```toml
tui-lipan = { version = "*", default-features = false }
```

For smaller shipping binaries, build app artifacts with the size-optimized profile:

```bash
cargo build --profile release-size --no-default-features
```

Use the normal `release` profile when runtime throughput is more important than artifact size.

Examples requiring specific features:

| Example | Required feature |
|---------|-----------------|
| `big_text`, `figlet_editor` | `big-text` |
| `diff_hub` | `diff-view` |
| `qr_code` | `qr-code` |
| `image`, `image_modes`, `messenger` | `image` |
| `markdown_hub` | `markdown` |
| `markdown_editor_sync` | `markdown`, `syntax-syntect` |
| `yazi` | `syntax-extra` |
| `terminal_filetree_devtools` | `terminal` |
| `terminal_images` | `terminal-images` |
| `devtools` | `devtools` |
| `theme_hot_reload` | `theme-reload` |

With `devtools` enabled, the built-in panel uses compact defaults for Stats and
Logs. The App tab sizes to host-provided metric content within the viewport and
scrolls vertically when its rows are capped. Use `Context` (`show_devtools`, `hide_devtools`,
`toggle_devtools`) for visibility.

### DevTools runtime configuration

When the `devtools` feature is enabled, you can opt out of individual subsystems at app start time:

```rust
use tui_lipan::prelude::*;

App::new()
    .devtools_config(DevToolsConfig {
        logs: true,    // ingest debug_log! lines into the DevTools log panel
        metrics: true, // collect per-frame stats (FPS, reconcile/draw times, node count)
        show_framework_logs: false, // hide tui-lipan's own internal log lines by default
    })
    .mount(MyApp)
    .run()
```

- `logs: false` removes the `debug_log!` → devtools sink path entirely (the macro still respects `TUI_LIPAN_DEBUG=1` env logging).
- `metrics: false` skips frame timing and tree-size collection; the panel will show "No frame metrics yet".
- `show_framework_logs: false` starts the Logs tab with tui-lipan's own framework
  noise (key events, dirty tracking, etc.) hidden, leaving only your app's
  `debug_log!` lines. Toggle it live with the **tui-lipan** button in the Logs tab.
- `logs`, `metrics`, and `show_framework_logs` all default to `true`, so
  `features = ["devtools"]` behaves exactly as before.

In the Logs tab you can also copy the selected row to the clipboard with `Ctrl+C`,
or by activating a row (double-click / `Enter`).

Subsystem cost — what each toggle controls:

| Subsystem | When `true` (default)                                                                                        | When to turn off                                                                |
| --------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
| `logs`    | Every `debug_log!` allocates a `String` and pushes a `DevLogEntry` onto a bounded ring buffer (small).       | Hot loops calling `debug_log!` thousands of times per frame, or to drop the formatting cost when the panel is never opened. |
| `metrics` | Per-frame timing samples + node-tree size snapshot collected while the DevTools panel is visible; small fixed-size ring buffer. | Profiling renders against a `release` build where you don't want sampling overhead. |

Note: `debug_log!` works in `--release` builds when the `devtools` feature is enabled — there is no `debug_assertions` gate. Lines are emitted to the panel regardless of profile, so guard hot paths yourself if you need zero overhead in release.

## Minimal Example

```rust
use tui_lipan::prelude::*;

struct Counter;

#[derive(Default)]
struct State {
    count: i32,
}

#[derive(Clone)]
enum Msg {
    Increment,
    Decrement,
}

impl Component for Counter {
    type Message = Msg;
    type Properties = ();
    type State = State;

    fn create_state(&self, _props: &Self::Properties) -> Self::State {
        State::default()
    }

    fn view(&self, ctx: &Context<Self>) -> Element {
        rsx! {
            VStack {
                gap: 1,
                padding: 2,

                Text { content: format!("Count: {}", ctx.state.count) }

                HStack {
                    gap: 1,
                    Button { label: "-", on_click: ctx.link().callback(|_| Msg::Decrement) }
                    Button { label: "+", on_click: ctx.link().callback(|_| Msg::Increment) }
                }
            }
        }
    }

    fn update(&mut self, msg: Msg, ctx: &mut Context<Self>) -> Update {
        match msg {
            Msg::Increment => ctx.state.count += 1,
            Msg::Decrement => ctx.state.count -= 1,
        }
        Update::full()  // (needs_redraw, optional_command)
    }
}

fn main() -> tui_lipan::Result<()> {
    App::new()
        .title("Counter")
        .mount(Counter)
        .run()
}
```

## Fast Prototyping with `mockup!`

Skip all `Component` boilerplate for layout previews:

```rust
use tui_lipan::prelude::*;

fn main() -> tui_lipan::Result<()> {
    mockup!("Dashboard Preview", {
        HStack::new()
            .gap(1)
            .child(
                Frame::new()
                    .header_left("Sidebar")
                    .border(true)
                    .width(Length::Px(30))
                    .child(List::new().items([
                        ListItem::new("Dashboard"),
                        ListItem::new("Settings"),
                        ListItem::new("Logs"),
                    ]).selected(0)),
            )
            .child(
                Frame::new()
                    .header_left("Content")
                    .border(true)
                    .padding(1)
                    .child(Text::new("Hello from mockup!")),
            )
    })
}
```

**Key behaviors:**
- Press `Esc` or `q` to quit.
- The body expression is auto-wrapped in `.into()` - return any widget builder directly.
- Interactive widgets (List, Tabs, Inputs) still respond to focus and mouse.
- The closure uses `move` capture, so local data is accessible.

**Using `Mockup` adapter directly:**

```rust
App::new()
    .title("My Layout")
    .mount(Mockup::new(|| {
        Frame::new().header_left("Panel").border(true)
            .child(Text::new("World")).into()  // closure must return Element
    }))
    .run()
```

### Mockup → App Workflow

Extract views as plain functions reusable in both mockups and real components:

```rust
fn sidebar(items: &[&str], selected: usize) -> Element {
    Frame::new().header_left("Nav").border(true)
        .width(Length::Px(28))
        .child(List::new().items(items.iter().map(|s| ListItem::new(*s))).selected(selected))
        .into()
}

// Step 1: preview with mockup
fn main() -> tui_lipan::Result<()> {
    let items = vec!["Home", "Settings", "Logs"];
    mockup!("Preview", { sidebar(&items, 0) })
}

// Step 2: reuse in real component - zero rewrite
fn view(&self, ctx: &Context<Self>) -> Element {
    sidebar(&ctx.state.nav_items, ctx.state.selected)
}
```

## App Configuration

```rust
App::new()
    .title("My App")           // Optional outer chrome frame
    .theme(Theme::one_dark())  // Optional theme override
    .system_theme()            // Optional: derive theme from host terminal colors
    .inline_ephemeral(8)       // Optional: inline mode (8 terminal rows)
    .mouse(true)               // Mouse capture (default: true in fullscreen, false in inline)
    .scroll_wheel_multiplier(3) // Optional: lines per wheel tick (default: 1)
    .toast_placement(ToastPlacement::BottomEnd)
    .keymap_path("/path/to/keymap.conf")  // see docs/keybindings.md
    .global_quit(None)                   // disable Ctrl-Q quit without a keymap file
    .framework_keymap(
        FrameworkKeymap::default().unbind(FrameworkAction::Quit),
    )
    .user_keymap_policy(UserKeymapPolicy::Disabled) // ignore env/default user keymaps
    .key_dispatch_policy(KeyDispatchPolicy::AppCommandsFirst)
    .terminal_key_policy(TerminalKeyPolicy::AppCommandsThenTerminal)
    .command_conflict_policy(CommandConflictPolicy::HighestPriority)
    .chord_mismatch_policy(ChordMismatchPolicy::ForwardPrefixAndCurrent)
    .clipboard_config(ClipboardConfig { .. })
    .contrast_policy(ContrastPolicy::Wcag)
    .terminal_bg(query_host_colors().map(|c| c.bg))  // enables Opacity through Color::Reset
    .live_host_terminal_colors(true)  // opt-in runner-managed live host palette refresh
    .mount(Root)
    .exit_view(|_component, ctx| {
        Text::new(format!("Final count: {}", ctx.state.count)).into()
    })
    .run()
```

`ScrollView::scroll_wheel_multiplier(...)`,
`TextArea::scroll_wheel_multiplier(...)`, and
`DocumentView::scroll_wheel_multiplier(...)` override the app-wide wheel
multiplier for a specific widget.

> **`terminal_bg` / live host colors**: `ColorTransform::Opacity` blends foreground colors toward the resolved cell background. When the cell background is `Color::Reset` (terminal default) there is no RGB to blend toward, so opacity has no effect. Calling `.terminal_bg(query_host_colors().map(|c| c.bg))` before `run()` provides the terminal's actual default background color and enables correct opacity blending for static apps. Use `.system_theme()` to opt into a framework-wide theme derived from live host colors, or `.live_host_terminal_colors(true)` when app code wants to read `ctx.host_terminal_colors()` and build its own tokens. The runner probes once at startup, refreshes on terminal focus gained, services `ctx.request_host_terminal_color_refresh()`, and never polls continuously. Opted-in Unix fullscreen apps also enable DEC private mode 2031: compatible terminals send exact palette-change notifications that trigger an immediate OSC 10/11 refresh and, when resolved foreground or background colors changed, a complete repaint. Those refreshes retain the startup probe's resolved ANSI slots because Termina does not yet expose OSC 4 responses. Inline, non-Unix, and unsupported terminals retain startup, focus-gained, and manual OSC 4/10/11 refresh behavior. Refreshed host backgrounds update `terminal_bg` automatically. Omitting both leaves opacity unchanged on reset-background cells.

> **`exit_view`**: Attach this on `AppRunner<C>` after `.mount(...)` when you want a final one-shot element rendered to stdout after the TUI exits. The callback runs before unmount, so component state is still available in `ctx.state`. This is useful for persisting a session summary or logo in terminal scrollback.

Layered key dispatch policy builders are always available (no extra feature flag). See [`keybindings.md`](keybindings.md) for precedence rules, command `shortcut(...)` vs `keybinding_hint(...)`, and [`focus.md`](focus.md) / [`widgets/terminal.md`](widgets/terminal.md) for dispatch order tables.

## Development Workflow

1. **Define State** - `struct State { ... }` with `#[derive(Default)]`
2. **Define Messages** - `enum Msg { ... }` with `#[derive(Clone)]`
3. **Implement Component** - `create_state`, `update`, `view`
4. **Run** - `App::new().mount(Root).run()`

## Debugging

### Debug logging

Enable debug output with environment variables:

```sh
TUI_LIPAN_DEBUG=1 cargo run                         # Print to stderr
TUI_LIPAN_DEBUG_FILE=/tmp/tui.log cargo run          # Also append to file
```

### Headless snapshots

Capture what an app looks like without a terminal, and without editing its
source. `TUI_LIPAN_SNAPSHOT` makes `run()` render one frame off-screen, write it,
and exit:

```sh
TUI_LIPAN_SNAPSHOT=/tmp/app.png cargo run --features ui-snapshot-png
TUI_LIPAN_SNAPSHOT=/tmp/app.md TUI_LIPAN_SNAPSHOT_VIEWPORT=140x40 cargo run
```

Companion variables: `TUI_LIPAN_SNAPSHOT_VIEWPORT` (`WIDTHxHEIGHT`, default
`100x30`), `TUI_LIPAN_SNAPSHOT_VIEWPORTS` (comma-separated list, writes suffixed
files), `TUI_LIPAN_SNAPSHOT_FRAMES`, `TUI_LIPAN_SNAPSHOT_FOCUS`,
`TUI_LIPAN_SNAPSHOT_KEYS`, `TUI_LIPAN_SNAPSHOT_ADVANCE_MS` (virtual-clock
advance for time-gated UI), and `TUI_LIPAN_SNAPSHOT_DIAGNOSTIC=1`.

`TUI_LIPAN_SNAPSHOT_KEYS` scripts input so states behind a keystroke can be
captured without writing code:

```sh
TUI_LIPAN_SNAPSHOT=/tmp/modal.png TUI_LIPAN_SNAPSHOT_KEYS="tab,enter" cargo run
```

See [`docs/testing.md`](testing.md) for the full table, the `Sketch` API,
and visual regression baselines.

### Terminal recordings

`TUI_LIPAN_RECORD` plays a key script and writes an asciinema cast - a text
recording that scrubs and plays in a browser, typically smaller than one PNG
frame of the same app. It needs no feature flag:

```sh
TUI_LIPAN_RECORD=/tmp/demo.cast TUI_LIPAN_RECORD_KEYS="tab,enter" cargo run
```

Companion variables: `TUI_LIPAN_RECORD_VIEWPORT`, `_FPS`, `_KEY_DELAY_MS`,
`_SETTLE_MS`, and `_FRAMES`. The `Recording` builder is the in-code equivalent.

`TUI_LIPAN_RECORD_FRAMES=<dir>` additionally writes one truecolor PNG per frame
(needs `ui-snapshot-png`) and prints a ready-to-run `ffmpeg` command for encoding
them to MP4. See [`docs/components.md`](components.md#choosing-an-output-format)
for a size and quality comparison of `.cast`, GIF, and MP4.

Use the `debug_log!` macro in your own code to emit messages through the same channel:

```rust
use tui_lipan::debug_log;

debug_log!("Current state: {:?}", ctx.state);
```

### Layout snapshot diagnostics

When content vanishes in tests or mockups, first check the viewport and sizing:
- `TestBackend` starts at an 80x24 viewport unless you call `set_viewport(...)`; use a fixed viewport for reproducible snapshots.
- `Mockup` renders at the live terminal size, so a layout can change when the terminal is narrow or short.
- `VStack`, `HStack`, and `Frame` default to `Length::Flex(1)` on both axes; fixed headers, footers, and side bars usually need `Length::Px(...)`.
- Capture with `UiSnapshotOptions::diagnostic()` to include zero-area nodes, spacers, and dividers; markdown snapshots flag zero-size widgets as `zero-area`.

### Mouse event diagnostics

The `tui_lipan::debug` module exposes counters for diagnosing mouse event throughput:

```rust
use tui_lipan::debug;

let count = debug::mouse_events_processed();  // Total mouse events since start
debug::reset_mouse_events();                  // Reset counter to zero
```