superlighttui 0.20.1

Super Light TUI - A lightweight, ergonomic terminal UI library
Documentation
# Backends and Run Loops

This guide is for the low-level path: custom render targets, external event loops, inline mode, and static output.

If you just want a normal terminal app, use `slt::run(...)` or `slt::run_with(...)` and stop here.

## Choose the right entry point

| Goal | API |
|------|-----|
| Full-screen terminal app | `run()` / `run_with()` |
| Inline widget below the current prompt | `run_inline()` / `run_inline_with()` |
| Inline widget plus scrollback log output | `run_static()` + `StaticOutput` |
| Non-terminal target or external loop | `Backend` + `AppState` + `frame()` |

## The backend mental model

SLT deliberately keeps the low-level contract small.

SLT owns:

- command recording from your UI closure
- layout computation
- focus and interaction bookkeeping
- rendering into a `Buffer`
- persistent per-session UI state in `AppState`

Your backend owns:

- the current size in terminal-style cells
- the backing `Buffer`
- presenting the finished buffer to a target

That split is intentional. A backend should not need to reimplement layout, focus, or widget logic.

## The stable contract

The public backend surface is intentionally tiny:

```rust
pub trait Backend {
    fn size(&self) -> (u32, u32);
    fn buffer_mut(&mut self) -> &mut Buffer;
    fn flush(&mut self) -> std::io::Result<()>;
}
```

And the runtime contract around it is equally small:

- `frame()` reads `backend.size()` every frame.
- `frame()` renders into `backend.buffer_mut()`.
- `frame()` calls `flush()` at the end of a successful frame.
- `flush()` errors propagate back to the caller.
- `AppState` must be reused across frames for hooks, focus, hit maps, scroll feedback, and tick/FPS state.
- `ui.quit()` causes `frame()` to return `Ok(false)`.

These are not just documentation promises. They are explicitly locked by backend contract tests and by parity tests between `frame()` and `TestBackend`.

## What the built-in backends guarantee

The built-in terminal backends are intentionally boring:

- full-screen and inline terminals share the same diff writer path for buffer, raw sequence, and cursor flushing
- terminal session setup and cleanup are centralized so raw mode, alternate screen, mouse capture, bracketed paste, and Kitty keyboard teardown stay symmetric
- built-in run loops and `TestBackend` share the same frame kernel, which reduces lifecycle drift between production rendering and tests

This matters because SLT is trying to be easy at the API layer without becoming sloppy underneath.

### Buffered stdout (v0.19.1)

Both `Terminal` and `InlineTerminal` wrap stdout in `BufWriter::with_capacity(65536, _)`. Every queued ANSI sequence — cursor moves, style deltas, raw sequences, Kitty placements — accumulates in a 64 KiB buffer and is committed with a single `flush()` per frame. This collapses what was previously dozens to thousands of individual `write` syscalls per frame into one, which materially reduces overhead for high-frequency rendering (charts, animations, image-heavy frames).

The contract for custom backends is unchanged: `Backend::flush()` is still called once per frame and may itself defer or batch writes however it likes.

### `kitty_keyboard` honored in inline mode (v0.19.1)

`InlineTerminal::new` now reads `RunConfig::kitty_keyboard` and propagates it through `TerminalSessionGuard`. Earlier versions hardcoded inline-mode kitty keyboard support to `false` regardless of config; both full-screen and inline runs now agree on the flag.

## `RunConfig` in practice

`RunConfig` is the runtime policy object for all built-in loops.

```rust
use slt::{RunConfig, Theme};
use std::time::Duration;

let config = RunConfig::default()
    .tick_rate(Duration::from_millis(16))
    .mouse(true)
    .theme(Theme::light())
    .max_fps(60)
    .scroll_speed(2)
    .title("My App");
```

Important details:

- `tick_rate` controls how often the loop wakes up even if no input arrives
- `max_fps` caps the render rate after work is done
- `mouse(true)` enables clicks, hovers, and wheel input
- `kitty_keyboard(true)` requests richer key events on supported terminals
- `RunConfig` and `Theme` are both `#[non_exhaustive]`, so prefer builder methods over struct literals

## Driving `frame()` yourself

```rust
use slt::{AppState, Backend, Buffer, Context, Event, Rect, RunConfig};

struct MyBackend {
    buffer: Buffer,
}

impl Backend for MyBackend {
    fn size(&self) -> (u32, u32) {
        (self.buffer.area.width, self.buffer.area.height)
    }

    fn buffer_mut(&mut self) -> &mut Buffer {
        &mut self.buffer
    }

    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

fn main() -> std::io::Result<()> {
    let mut backend = MyBackend {
        buffer: Buffer::empty(Rect::new(0, 0, 80, 24)),
    };
    let mut app = AppState::new();
    let config = RunConfig::default();

    loop {
        let events: Vec<Event> = vec![];
        let keep_going = slt::frame(
            &mut backend,
            &mut app,
            &config,
            &events,
            &mut |ui: &mut Context| {
                ui.text("Hello from a custom backend");
            },
        )?;

        if !keep_going {
            break;
        }
    }

    Ok(())
}
```

## What `AppState` actually stores

`AppState` is the persistent frame-to-frame session state for:

- hook storage (`use_state`, `use_memo`)
- focus position and focus counts
- previous-frame hit areas and scroll bounds
- toast queue and debug overlay state
- smoothed FPS estimate and tick counter

Do not recreate it every frame.
Think of it as the session object for the UI runtime, not as a cache you can throw away casually.

## What `frame()` expects from you

- Reuse the same `AppState` across frames.
- Pass the current frame's `events` slice.
- Rebuild the event list each frame in your outer loop.
- Resize your backend buffer when your host environment changes size.
- Stop when `frame()` returns `Ok(false)` after `ui.quit()`.

`frame()` is intentionally synchronous and explicit.
That makes it easy to embed inside terminals, browser loops, game loops, test harnesses, or remote rendering targets.

## What your backend does not need to do

Your backend does not need to:

- compute layout
- track focus order
- process `Response` state
- diff widgets semantically
- own hook state

If you find yourself rebuilding those layers in a custom backend, the abstraction line is probably wrong.

## Testing custom backends

Use two testing layers:

- `TestBackend` for widget, layout, and interaction rendering tests
- `frame()` + a tiny custom `Backend` for contract tests such as flush propagation, quit behavior, and resize handling

That split mirrors the actual architecture:

- `TestBackend` is the fastest tool for most UI work
- custom backend tests lock the low-level contract itself

See `docs/TESTING.md` for the recommended patterns.

## Inline mode details

`run_inline(height, ...)` is for CLI tools that should remain embedded in normal terminal flow.

- It does not enter alternate screen mode.
- It reserves a fixed display area below the cursor.
- Resize events can change the width, but the reserved height stays the one you requested.
- Pressing `Ctrl+C` still exits the loop like the regular terminal backend.

Use it when the TUI is a helper surface rather than the whole app.

## Static output mode details

`StaticOutput` is a scrollback-friendly companion for inline apps.

```rust
use slt::StaticOutput;

let mut output = StaticOutput::new();
output.println("Build started...");
output.println("Fetching data...");
```

Use it when you want:

- a fixed inline control surface at the bottom
- persistent logs or messages above it
- a CLI tool that mixes streaming text output with interaction

## Feature flag notes

When `default-features = false` and `crossterm` is not enabled:

- built-in terminal loops are unavailable
- terminal clipboard helpers are unavailable
- terminal-owned runtime setup is unavailable

What still remains:

- `Backend`
- `AppState`
- `frame()`
- `Context`, widgets, events, styles, layout, charts

That is what makes SLT usable as a rendering core instead of only a terminal runtime.

## Sixel auto-detection (v0.19.1)

`ui.sixel_image(...)` is gated by a runtime check that asks "does this terminal speak sixel?" before emitting any sixel sequence. Unsupported terminals see the reserved cell area but no garbled escape output.

Detection logic, in order:

1. `SLT_FORCE_SIXEL=1` (also `true` / `yes` / `on`) — explicit override, returns true unconditionally. Use this for patched-xterm-with-sixel, embedded targets, or testing.
2. Exact-match `TERM` against the known-good list: `mlterm`, `foot`, `yaft`, `xterm-256color-sixel`.
3. Substring match: `TERM` contains `sixel` (catches custom builds and forks).
4. `TERM_PROGRAM` is `foot` or `mlterm`.

Pre-v0.19.1 used `term.contains("xterm")`, which fired on the default `xterm-256color` `TERM` value used by macOS Terminal.app, VS Code's integrated terminal, and most SSH clients — none of which actually parse sixel. Output appeared as raw escape junk in the scrollback. The new exact-match list fixes the false positive while still covering the terminals that do support the protocol.

If you ship an app for end users on unknown terminals, prefer the half-block (`ui.image`) or Kitty (`ui.kitty_image`) path; sixel is a niche protocol and even the "supported" list above varies in fidelity.

## `image()` is one RawDraw command (v0.19.1)

`ui.image(&half_block_image)` previously emitted one `Command::RawDraw` per cell plus one `String` allocation per cell — for a modest 40×20 half-block image, that worked out to ~841 commands and ~800 transient `String` allocations per frame. The implementation now wraps the whole image in a single `container().draw(closure)` call: one `RawDraw`, one closure capture, the inner double-loop runs against the buffer directly with no per-cell allocation.

This matters most for animated image content (frame-by-frame video, sprite scrolling), where the per-frame allocation pressure is what was previously dominating CPU. The widget API (`ui.image(&half)`) is unchanged — the optimization is purely internal.

## Related APIs

- `docs/FEATURES.md` - feature-gated runtime behavior
- `docs/TESTING.md` - `TestBackend`, backend contract tests, multi-frame patterns
- `docs/DEBUGGING.md` - F12 overlay, one-frame delay, layout debugging
- `docs/PATTERNS.md` - hooks, overlays, custom widgets
- `src/lib.rs` - canonical rustdoc for `Backend`, `AppState`, `frame()`, `RunConfig`