tuika 0.7.0

The application framework for Rust terminal UIs — flexbox layout, overlays, focus, keymap, components, and safe ratatui interoperability.
Documentation
# tuika styling

tuika splits how a UI looks into two layers:

- a **`Theme`** — a flat set of named *colors* (`background`, `accent`, `border`,
  the markdown/code palette, …). Every component pulls from the theme; none
  hard-codes a color. This is the token layer, and it already existed — see
  [themes]themes.md.
- a **`StyleSheet`** — a mapping from a semantic **role** (a heading, a link, an
  inline-code span, a toast severity, a diff row, a key hint, …) onto a
  **`StyleBundle`**: the color and text attributes that role draws with. This is
  the rule layer, and it is what lets you restyle *by meaning*, in one place,
  without touching component code.

Swapping the sheet handed to a render restyles every element that role touches at
once — here the same markdown block and panels, painted under three different
stylesheets:

![Cycling through three stylesheets](styling/styling-cycle.gif)

## Theme vs. stylesheet

Think CSS: the `Theme` is your custom properties (`--accent`, `--border`), and the
`StyleSheet` is the rules that map elements to them. The default sheet for a theme
reproduces exactly what components looked like before stylesheets existed, so
adopting it changes nothing until you override a role:

```rust
use tuika::prelude::*;

let theme = Theme::default();
let sheet = StyleSheet::from_theme(&theme); // a no-op: today's look, as data
```

Override a role with struct-update syntax — everything you don't name keeps
tracking the theme:

```rust
use tuika::prelude::*;
use ratatui::style::Color;

let theme = Theme::default();
let sheet = StyleSheet {
    // green, bold links (and bare URLs) instead of the default
    link: StyleBundle::new().fg(Color::Green).bold().underlined(),
    // a magenta heading
    heading: StyleBundle::new().fg(Color::Rgb(210, 120, 200)).bold(),
    ..StyleSheet::from_theme(&theme)
};
```

A `StyleBundle` is *partial*: it overlays only the attributes it sets. A
color-less bundle (like the default emphasis rule, which just adds italic) keeps
the surrounding text's color and only contributes its modifier.

## Installing a stylesheet

A host sets one policy for the whole tree. Either paint with it directly:

```rust
use tuika::prelude::*;

let theme = Theme::default();
let sheet = StyleSheet::from_theme(&theme);
// paint_with_sheet(buffer, area, &theme, sheet, root, &[]);
```

or, if you build your own `RenderCtx`, install it there with `.with_sheet(sheet)`.
Plain `paint` and `RenderCtx::new(theme)` keep using the theme's default sheet, so
nothing else has to change.

## Extending the role vocabulary

`StyleSheet` has typed fields for tuika's built-in components. A companion crate
or application can add namespaced `StyleRole` constants without tuika having to
know about them, then supply one `StyleResolver` for the tree:

```rust
use tuika::prelude::*;

const METRIC_WARNING: StyleRole = StyleRole::new("my-app.metric.warning");

struct AppStyles;

impl StyleResolver for AppStyles {
    fn resolve(&self, role: StyleRole) -> Option<StyleBundle> {
        match role {
            METRIC_WARNING => Some(StyleBundle::new().fg(Color::Yellow).bold()),
            // A partial built-in override inherits the sheet's key-cap background.
            StyleRole::KEY_HINT_KEY => Some(StyleBundle::new().fg(Color::Black)),
            _ => None,
        }
    }
}

let theme = Theme::default();
let styles = AppStyles;
let ctx = RenderCtx::new(&theme).with_style_resolver(&styles);
// paint_with_context(buffer, area, &ctx, root, &[]);
```

Inside a custom `View`, `ctx.style(METRIC_WARNING)` resolves application and
built-in roles uniformly. Resolver bundles overlay the active sheet rather than
replace it. If a resolver's answers can change while the same object remains
installed, increment `StyleResolver::revision`; measurement caches include that
revision.

The built-in open roles cover toast base/severities, diff base/rows/gutter/
divider, and key-hint keys/labels. Their `StyleSheet` fields are the ordinary,
data-only path for most hosts. Explicit component overrides remain the narrowest
layer: for example, `Diff::style` wins over semantic diff-row colors for that one
view.

## Styling markdown — headings, links, and URLs

Markdown is fully role-driven. Every part below resolves its style from the sheet,
so one rule restyles it everywhere it appears — a bracketed `[link](url)` and a
bare `https://…` URL share the **same** `link` role, so restyling links restyles
both:

| Markdown part | Role |
| --- | --- |
| `# Heading` | `heading` |
| `[text](url)` and bare `https://…` | `link` |
| `` `inline code` `` | `inline_code` |
| `*emphasis*` | `emphasis` |
| `**strong**` | `strong` |
| `~~strikethrough~~` | `strikethrough` |
| `- ` / `1. ` bullets | `list_marker` |
| `- [ ]` task boxes | `task_marker` |
| `---` rules | `rule` |
| `![alt](url)` placeholder marker | `image_marker` |

The one-shot and streaming renderers both take the sheet:

```rust
use tuika::components::markdown::to_lines;
use tuika::prelude::*;
use ratatui::style::Color;

let theme = Theme::default();
let sheet = StyleSheet {
    link: StyleBundle::new().fg(Color::Green).bold(),
    ..StyleSheet::from_theme(&theme)
};
let lines = to_lines(
    "See the [docs](https://tuika.dev) or https://everruns.dev",
    60,
    &theme,
    &sheet,
    CodeHighlighter::Plain,
);
// both "docs" and the bare URL now render green + bold
```

`MarkdownState` (the streaming renderer) caches per `(theme, sheet)` and rebuilds
when either changes, so live restyling is safe.

## Styling panels

`Boxed` resolves the `panel` role. Its default sets nothing, so a plain box keeps
its theme border and no fill; set `panel.bg` to give **every** panel a shared
surface fill, and `panel.fg` to recolor unfocused borders — without a per-call
`.background(..)`:

```rust
use tuika::prelude::*;

let theme = Theme::default();
let sheet = StyleSheet {
    panel: StyleBundle::new()
        .fg(theme.accent_alt)
        .bg(theme.surface)
        .padding(Padding::all(2)),
    ..StyleSheet::from_theme(&theme)
};
```

Measurement and rendering receive the same context, so the panel role's padding
changes both the size a `Boxed` requests and the inner rectangle it paints.
`Boxed::padding` is the local override when one panel needs different spacing.
Whether a border exists remains instance-level (`Boxed::border`); color and
text modifiers remain ordinary paint-time rules.

## Side by side

The three sheets above, held still:

| default | vivid | mono |
| --- | --- | --- |
| ![default]styling/styling-default.gif | ![vivid]styling/styling-vivid.gif | ![mono]styling/styling-mono.gif |

Each is the [`examples/styling.rs`](../examples/styling.rs) scene under
a different `StyleSheet`; see its `variants()` for the exact rules.

## Regenerating the demos

The GIFs are recorded from the styling example with
[VHS](https://github.com/charmbracelet/vhs) (needs `ttyd` and `ffmpeg` on `PATH`):

```bash
scripts/gen-styling-demos.sh          # all variants
scripts/gen-styling-demos.sh vivid    # just one
```

To preview a variant live in your own terminal:

```bash
cargo run --example styling -- run          # cycle the sheets
cargo run --example styling -- run vivid    # hold one
```